From 427714f8d0784cca873d8ccbab5323dcfca03e61 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Thu, 27 Jul 2023 13:11:15 -0300 Subject: [PATCH] Export: Remove DS input when dashboard is imported with a lib panel that already exists (#69412) --- .betterer.results | 11 +- pkg/services/libraryelements/api.go | 2 +- pkg/services/libraryelements/database.go | 30 +- .../libraryelements/libraryelements.go | 22 + .../DashExportModal/DashboardExporter.test.ts | 4 +- .../DashExportModal/DashboardExporter.ts | 40 +- .../components/ImportDashboardForm.tsx | 1 + .../manage-dashboards/state/actions.test.ts | 654 +++++++++++++++++- .../manage-dashboards/state/actions.ts | 186 +++-- .../manage-dashboards/state/reducers.ts | 3 +- .../app/features/manage-dashboards/types.ts | 6 + 11 files changed, 878 insertions(+), 81 deletions(-) diff --git a/.betterer.results b/.betterer.results index 353577392c5..63208ca90b0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2731,10 +2731,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"], @@ -2746,8 +2746,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "16"], [0, 0, 0, "Unexpected any. Specify a different type.", "17"], [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"] + [0, 0, 0, "Unexpected any. Specify a different type.", "19"] ], "public/app/features/manage-dashboards/state/reducers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 95bbcb3cad4..00dc8248459 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -56,7 +56,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon cmd.FolderID = folder.ID } } - element, err := l.createLibraryElement(c.Req.Context(), c.SignedInUser, cmd) + element, err := l.CreateElement(c.Req.Context(), c.SignedInUser, cmd) if err != nil { return toLibraryElementError(err, "Failed to create library element") } diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 86ed8ae6e75..2d8bff6bea1 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -120,12 +120,22 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn return model.LibraryElementDTO{}, model.ErrLibraryElementUIDTooLong } } + + updatedModel := cmd.Model + var err error + if cmd.Kind == int64(model.PanelElement) { + updatedModel, err = l.addUidToLibraryPanel(cmd.Model, createUID) + if err != nil { + return model.LibraryElementDTO{}, err + } + } + element := model.LibraryElement{ OrgID: signedInUser.OrgID, FolderID: cmd.FolderID, UID: createUID, Name: cmd.Name, - Model: cmd.Model, + Model: updatedModel, Version: 1, Kind: cmd.Kind, @@ -140,7 +150,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn return model.LibraryElementDTO{}, err } - err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error { + err = l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error { if err := l.requireEditPermissionsOnFolder(c, signedInUser, cmd.FolderID); err != nil { return err } @@ -228,7 +238,7 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn } // getLibraryElements gets a Library Element where param == value -func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) { +func (l *LibraryElementService) getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) { libraryElements := make([]model.LibraryElementWithMeta, 0) recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() @@ -267,6 +277,14 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed leDtos := make([]model.LibraryElementDTO, len(libraryElements)) for i, libraryElement := range libraryElements { + var updatedModel json.RawMessage + if libraryElement.Kind == int64(model.PanelElement) { + updatedModel, err = l.addUidToLibraryPanel(libraryElement.Model, libraryElement.UID) + if err != nil { + return []model.LibraryElementDTO{}, err + } + } + leDtos[i] = model.LibraryElementDTO{ ID: libraryElement.ID, OrgID: libraryElement.OrgID, @@ -277,7 +295,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed Kind: libraryElement.Kind, Type: libraryElement.Type, Description: libraryElement.Description, - Model: libraryElement.Model, + Model: updatedModel, Version: libraryElement.Version, Meta: model.LibraryElementDTOMeta{ FolderName: libraryElement.FolderName, @@ -304,7 +322,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed // getLibraryElementByUid gets a Library Element by uid. func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) { - libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: cmd.UID}}, l.features, cmd) + libraryElements, err := l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: cmd.UID}}, l.features, cmd) if err != nil { return model.LibraryElementDTO{}, err } @@ -317,7 +335,7 @@ func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signed // getLibraryElementByName gets a Library Element by name. func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser *user.SignedInUser, name string) ([]model.LibraryElementDTO, error) { - return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features, + return l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features, model.GetLibraryElementCommand{ FolderName: dashboards.RootFolderName, }) diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index 3a0c4aa1e2b..4abc2666547 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -2,6 +2,7 @@ package libraryelements import ( "context" + "encoding/json" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" @@ -75,3 +76,24 @@ func (l *LibraryElementService) DisconnectElementsFromDashboard(c context.Contex func (l *LibraryElementService) DeleteLibraryElementsInFolder(c context.Context, signedInUser *user.SignedInUser, folderUID string) error { return l.deleteLibraryElementsInFolderUID(c, signedInUser, folderUID) } + +func (l *LibraryElementService) addUidToLibraryPanel(model []byte, newUid string) (json.RawMessage, error) { + var modelMap map[string]interface{} + err := json.Unmarshal(model, &modelMap) + if err != nil { + return nil, err + } + + if libraryPanel, ok := modelMap["libraryPanel"].(map[string]interface{}); ok { + if uid, ok := libraryPanel["uid"]; ok && uid == "" { + libraryPanel["uid"] = newUid + } + } + + updatedModel, err := json.Marshal(modelMap) + if err != nil { + return nil, err + } + + return updatedModel, nil +} diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index b84ac5caefb..10b4448d88f 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -164,8 +164,8 @@ it('replaces datasource ref in library panel', async () => { if ('error' in exported) { throw new Error('error should not be returned when making exportable json'); } - expect(exported.__elements['c46a6b49-de40-43b3-982c-1b5e1ec084a4'].model.datasource.uid).toBe('${DS_GFDB}'); - expect(exported.__inputs[0].name).toBe('DS_GFDB'); + expect(exported.__elements!['c46a6b49-de40-43b3-982c-1b5e1ec084a4'].model.datasource.uid).toBe('${DS_GFDB}'); + expect(exported.__inputs![0].name).toBe('DS_GFDB'); }); it('If a panel queries has no datasource prop ignore it', async () => { diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 96f9ce2fa69..c57069f339d 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -13,12 +13,21 @@ import { VariableOption, VariableRefresh } from '../../../variables/types'; import { DashboardModel } from '../../state/DashboardModel'; import { GridPos } from '../../state/PanelModel'; -interface Input { +export interface InputUsage { + libraryPanels?: LibraryPanel[]; +} + +export interface LibraryPanel { + name: string; + uid: string; +} +export interface Input { name: string; type: string; label: string; value: any; description: string; + usage?: InputUsage; } interface Requires { @@ -30,20 +39,17 @@ interface Requires { }; } -interface ExternalDashboard { - __inputs: Input[]; - __elements: Record; - __requires: Array; +export interface ExternalDashboard { + __inputs?: Input[]; + __elements?: Record; + __requires?: Array; panels: Array; } interface PanelWithExportableLibraryPanel { gridPos: GridPos; id: number; - libraryPanel: { - name: string; - uid: string; - }; + libraryPanel: LibraryPanel; } function isExportableLibraryPanel(p: any): p is PanelWithExportableLibraryPanel { @@ -58,6 +64,7 @@ interface DataSources { type: string; pluginId: string; pluginName: string; + usage?: InputUsage; }; } @@ -132,7 +139,10 @@ export class DashboardExporter { return; } - const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + const libraryPanel = obj.libraryPanel; + const libraryPanelSuffix = !!libraryPanel ? '-for-library-panel' : ''; + let refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase() + libraryPanelSuffix.toUpperCase(); + datasources[refName] = { name: refName, label: ds.name, @@ -140,8 +150,18 @@ export class DashboardExporter { type: 'datasource', pluginId: ds.meta?.id, pluginName: ds.meta?.name, + usage: datasources[refName]?.usage, }; + if (!!libraryPanel) { + const libPanels = datasources[refName]?.usage?.libraryPanels || []; + libPanels.push({ name: libraryPanel.name, uid: libraryPanel.uid }); + + datasources[refName].usage = { + libraryPanels: libPanels, + }; + } + obj.datasource = { type: ds.meta.id, uid: '${' + refName + '}' }; }); }; diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 61a1e185d36..e76d552b614 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -118,6 +118,7 @@ export const ImportDashboardForm = ({ return ( { it('Should send data source uid', async () => { const form: ImportDashboardDTO = { @@ -107,3 +117,643 @@ describe('validateDashboardJson', () => { expect(validateDashboardJsonNotValid).toBe('Not valid JSON'); }); }); + +describe('processDashboard', () => { + const panel = new PanelModel({ + datasource: { + type: 'mysql', + uid: '${DS_GDEV-MYSQL}', + }, + }); + + const panelWithLibPanel = { + gridPos: { + h: 8, + w: 12, + x: 0, + y: 8, + }, + id: 3, + libraryPanel: { + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + name: 'another prom lib panel', + }, + }; + const libPanel = { + 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90': { + name: 'another prom lib panel', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + kind: 1, + model: { + datasource: { + type: 'prometheus', + uid: '${DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL}', + }, + description: '', + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + libraryPanel: { + name: 'another prom lib panel', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + editorMode: 'builder', + expr: 'access_evaluation_duration_bucket', + instant: false, + range: true, + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + }, + }; + + const panelWithSecondLibPanel = { + gridPos: { + h: 8, + w: 12, + x: 0, + y: 16, + }, + id: 1, + libraryPanel: { + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + name: 'Testing lib panel', + }, + }; + const secondLibPanel = { + 'c46a6b49-de40-43b3-982c-1b5e1ec084a4': { + name: 'Testing lib panel', + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + kind: 1, + model: { + datasource: { + type: 'prometheus', + uid: '${DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL}', + }, + description: '', + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + libraryPanel: { + name: 'Testing lib panel', + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + editorMode: 'builder', + expr: 'access_evaluation_duration_count', + instant: false, + range: true, + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + }, + }; + + const importedJson: DashboardJson = { + ...defaultDashboard, + __inputs: [ + { + name: 'DS_GDEV-MYSQL', + label: 'gdev-mysql', + description: '', + type: 'datasource', + value: '', + }, + { + name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL', + label: 'gdev-prometheus', + description: '', + type: 'datasource', + value: '', + usage: { + libraryPanels: [ + { + name: 'another prom lib panel', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + }, + ], + }, + }, + ], + __elements: { + ...libPanel, + }, + __requires: [ + { + type: 'grafana', + id: 'grafana', + name: 'Grafana', + version: '10.1.0-pre', + }, + { + type: 'datasource', + id: 'mysql', + name: 'MySQL', + version: '1.0.0', + }, + { + type: 'datasource', + id: 'prometheus', + name: 'Prometheus', + version: '1.0.0', + }, + { + type: 'panel', + id: 'table', + name: 'Table', + version: '', + }, + ], + panels: [], + }; + + it("Should return 2 inputs, 1 for library panel because it's used for 2 panels", async () => { + mocks.getLibraryPanel.mockImplementation(() => { + throw { status: 404 }; + }); + const importDashboardState = initialImportDashboardState; + const dashboardJson: DashboardJson = { + ...importedJson, + panels: [panel, panelWithLibPanel, panelWithLibPanel], + }; + const libPanelInputs = await getLibraryPanelInputs(dashboardJson); + const newDashboardState = { + ...importDashboardState, + inputs: { + ...importDashboardState.inputs, + libraryPanels: libPanelInputs!, + }, + }; + + const processedDashboard = processDashboard(dashboardJson, newDashboardState); + const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels); + expect(processedDashboard.__inputs).toHaveLength(2); + expect(dsInputsForLibPanels).toHaveLength(1); + }); + it('Should return 3 inputs, 2 for library panels', async () => { + mocks.getLibraryPanel.mockImplementation(() => { + throw { status: 404 }; + }); + const importDashboardState = initialImportDashboardState; + const dashboardJson: DashboardJson = { + ...importedJson, + __inputs: [ + { + name: 'DS_GDEV-MYSQL', + label: 'gdev-mysql', + description: '', + type: 'datasource', + value: '', + }, + { + name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL', + label: 'gdev-prometheus', + description: '', + type: 'datasource', + value: '', + usage: { + libraryPanels: [ + { + name: 'another prom lib panel', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + }, + ], + }, + }, + { + name: 'DS_GDEV-MYSQL-FOR-LIBRARY-PANEL', + label: 'gdev-mysql-2', + description: '', + type: 'datasource', + value: '', + usage: { + libraryPanels: [ + { + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + name: 'Testing lib panel', + }, + ], + }, + }, + ], + __elements: { + ...libPanel, + ...secondLibPanel, + }, + panels: [panel, panelWithLibPanel, panelWithSecondLibPanel], + }; + const libPanelInputs = await getLibraryPanelInputs(dashboardJson); + const newDashboardState = { + ...importDashboardState, + inputs: { + ...importDashboardState.inputs, + libraryPanels: libPanelInputs!, + }, + }; + + const processedDashboard = processDashboard(dashboardJson, newDashboardState); + const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels); + expect(processedDashboard.__inputs).toHaveLength(3); + expect(dsInputsForLibPanels).toHaveLength(2); + }); + + it('Should return 1 input, since library panels already exist in the instance', async () => { + const getLibPanelFirstRS: LibraryElementDTO = { + folderUid: '', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + name: 'another prom lib panel', + type: 'timeseries', + description: '', + model: { + transparent: false, + transformations: [], + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + description: '', + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.PaletteClassic, + }, + custom: { + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + editorMode: 'builder', + expr: 'access_evaluation_duration_bucket', + instant: false, + range: true, + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + version: 1, + }; + + const getLibPanelSecondRS: LibraryElementDTO = { + folderUid: '', + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + name: 'Testing lib panel', + type: 'timeseries', + description: '', + model: { + transparent: false, + transformations: [], + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + description: '', + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.PaletteClassic, + }, + custom: { + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + editorMode: 'builder', + expr: 'access_evaluation_duration_count', + instant: false, + range: true, + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + version: 1, + }; + mocks.getLibraryPanel + .mockReturnValueOnce(Promise.resolve(getLibPanelFirstRS)) + .mockReturnValueOnce(Promise.resolve(getLibPanelSecondRS)); + + const importDashboardState = initialImportDashboardState; + const dashboardJson: DashboardJson = { + ...importedJson, + __inputs: [ + { + name: 'DS_GDEV-MYSQL', + label: 'gdev-mysql', + description: '', + type: 'datasource', + value: '', + }, + { + name: 'DS_GDEV-PROMETHEUS-FOR-LIBRARY-PANEL', + label: 'gdev-prometheus', + description: '', + type: 'datasource', + value: '', + usage: { + libraryPanels: [ + { + name: 'another prom lib panel', + uid: 'a0379b21-fa20-4313-bf12-d7fd7ceb6f90', + }, + ], + }, + }, + { + name: 'DS_GDEV-MYSQL-FOR-LIBRARY-PANEL', + label: 'gdev-mysql-2', + description: '', + type: 'datasource', + value: '', + usage: { + libraryPanels: [ + { + uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', + name: 'Testing lib panel', + }, + ], + }, + }, + ], + __elements: { + ...libPanel, + ...secondLibPanel, + }, + panels: [panel, panelWithLibPanel, panelWithSecondLibPanel], + }; + const libPanelInputs = await getLibraryPanelInputs(dashboardJson); + const newDashboardState = { + ...importDashboardState, + inputs: { + ...importDashboardState.inputs, + libraryPanels: libPanelInputs!, + }, + }; + + const processedDashboard = processDashboard(dashboardJson, newDashboardState); + const dsInputsForLibPanels = processedDashboard.__inputs!.filter((input) => !!input.usage?.libraryPanels); + expect(processedDashboard.__inputs).toHaveLength(1); + expect(dsInputsForLibPanels).toHaveLength(0); + }); +}); diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index c244a5193b8..5474afacf21 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,22 +1,28 @@ import { DataSourceInstanceSettings, locationUtil } from '@grafana/data'; -import { getDataSourceSrv, locationService, getBackendSrv, isFetchError } from '@grafana/runtime'; +import { getBackendSrv, getDataSourceSrv, isFetchError, locationService } from '@grafana/runtime'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DashboardDTO, FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types'; -import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter'; +import { + Input, + InputUsage, + LibraryElementExport, + LibraryPanel, +} from '../../dashboard/components/DashExportModal/DashboardExporter'; import { getLibraryPanel } from '../../library-panels/state/api'; import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types'; import { DashboardSearchHit } from '../../search/types'; -import { DeleteDashboardResponse } from '../types'; +import { DashboardJson, DeleteDashboardResponse } from '../types'; import { clearDashboard, fetchDashboard, fetchFailed, ImportDashboardDTO, + ImportDashboardState, InputType, LibraryPanelInput, LibraryPanelInputState, @@ -31,9 +37,9 @@ export function fetchGcomDashboard(id: string): ThunkResult { try { dispatch(fetchDashboard()); const dashboard = await getBackendSrv().get(`/api/gnet/dashboards/${id}`); - dispatch(setGcomDashboard(dashboard)); - dispatch(processInputs(dashboard.json)); - dispatch(processElements(dashboard.json)); + await dispatch(processElements(dashboard.json)); + await dispatch(processGcomDashboard(dashboard)); + dispatch(processInputs()); } catch (error) { dispatch(fetchFailed()); if (isFetchError(error)) { @@ -45,17 +51,66 @@ export function fetchGcomDashboard(id: string): ThunkResult { export function importDashboardJson(dashboard: any): ThunkResult { return async (dispatch) => { - dispatch(setJsonDashboard(dashboard)); - dispatch(processInputs(dashboard)); - dispatch(processElements(dashboard)); + await dispatch(processElements(dashboard)); + await dispatch(processJsonDashboard(dashboard)); + dispatch(processInputs()); }; } -function processInputs(dashboardJson: any): ThunkResult { - return (dispatch) => { - if (dashboardJson && dashboardJson.__inputs) { +const getNewLibraryPanelsByInput = (input: Input, state: ImportDashboardState): LibraryPanel[] | undefined => { + return input?.usage?.libraryPanels?.filter((usageLibPanel) => + state.inputs.libraryPanels.some( + (libPanel) => libPanel.state !== LibraryPanelInputState.Exists && libPanel.model.uid === usageLibPanel.uid + ) + ); +}; + +export function processDashboard(dashboardJson: DashboardJson, state: ImportDashboardState): DashboardJson { + let inputs = dashboardJson.__inputs; + if (!!state.inputs.libraryPanels?.length) { + const filteredUsedInputs: Input[] = []; + dashboardJson.__inputs?.forEach((input: Input) => { + if (!input?.usage?.libraryPanels) { + filteredUsedInputs.push(input); + return; + } + + const newLibraryPanels = getNewLibraryPanelsByInput(input, state); + input.usage = { libraryPanels: newLibraryPanels }; + + const isInputBeingUsedByANewLibraryPanel = !!newLibraryPanels?.length; + if (isInputBeingUsedByANewLibraryPanel) { + filteredUsedInputs.push(input); + } + }); + inputs = filteredUsedInputs; + } + + return { ...dashboardJson, __inputs: inputs }; +} + +function processGcomDashboard(dashboard: { json: DashboardJson }): ThunkResult { + return (dispatch, getState) => { + const state = getState().importDashboard; + const dashboardJson = processDashboard(dashboard.json, state); + dispatch(setGcomDashboard({ ...dashboard, json: dashboardJson })); + }; +} + +function processJsonDashboard(dashboardJson: DashboardJson): ThunkResult { + return (dispatch, getState) => { + const state = getState().importDashboard; + const dashboard = processDashboard(dashboardJson, state); + dispatch(setJsonDashboard(dashboard)); + }; +} + +function processInputs(): ThunkResult { + return (dispatch, getState) => { + const dashboard = getState().importDashboard.dashboard; + if (dashboard && dashboard.__inputs) { const inputs: any[] = []; - dashboardJson.__inputs.forEach((input: any) => { + dashboard.__inputs.forEach((input: any) => { const inputModel: any = { name: input.name, label: input.label, @@ -66,6 +121,8 @@ function processInputs(dashboardJson: any): ThunkResult { options: [], }; + inputModel.description = getDataSourceDescription(input); + if (input.type === InputType.DataSource) { getDataSourceOptions(input, inputModel); } else if (!inputModel.info) { @@ -81,50 +138,57 @@ function processInputs(dashboardJson: any): ThunkResult { function processElements(dashboardJson?: { __elements?: Record }): ThunkResult { return async function (dispatch) { - if (!dashboardJson || !dashboardJson.__elements) { - return; - } - - const libraryPanelInputs: LibraryPanelInput[] = []; - - for (const element of Object.values(dashboardJson.__elements)) { - if (element.kind !== LibraryElementKind.Panel) { - continue; - } - - const model = element.model; - const { type, description } = model; - const { uid, name } = element; - const input: LibraryPanelInput = { - model: { - model, - uid, - name, - version: 0, - type, - kind: LibraryElementKind.Panel, - description, - } as LibraryElementDTO, - state: LibraryPanelInputState.New, - }; - - try { - const panelInDb = await getLibraryPanel(uid, true); - input.state = LibraryPanelInputState.Exists; - input.model = panelInDb; - } catch (e: any) { - if (e.status !== 404) { - throw e; - } - } - - libraryPanelInputs.push(input); - } - + const libraryPanelInputs = await getLibraryPanelInputs(dashboardJson); dispatch(setLibraryPanelInputs(libraryPanelInputs)); }; } +export async function getLibraryPanelInputs(dashboardJson?: { + __elements?: Record; +}): Promise { + if (!dashboardJson || !dashboardJson.__elements) { + return []; + } + + const libraryPanelInputs: LibraryPanelInput[] = []; + + for (const element of Object.values(dashboardJson.__elements)) { + if (element.kind !== LibraryElementKind.Panel) { + continue; + } + + const model = element.model; + const { type, description } = model; + const { uid, name } = element; + const input: LibraryPanelInput = { + model: { + model, + uid, + name, + version: 0, + type, + kind: LibraryElementKind.Panel, + description, + } as LibraryElementDTO, + state: LibraryPanelInputState.New, + }; + + try { + const panelInDb = await getLibraryPanel(uid, true); + input.state = LibraryPanelInputState.Exists; + input.model = panelInDb; + } catch (e: any) { + if (e.status !== 404) { + throw e; + } + } + + libraryPanelInputs.push(input); + } + + return libraryPanelInputs; +} + export function clearLoadedDashboard(): ThunkResult { return (dispatch) => { dispatch(clearDashboard()); @@ -182,6 +246,22 @@ const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, i } }; +const getDataSourceDescription = (input: { usage?: InputUsage }): string | undefined => { + if (!input.usage) { + return undefined; + } + + if (input.usage.libraryPanels) { + const libPanelNames = input.usage.libraryPanels.reduce( + (acc: string, libPanel, index) => (index === 0 ? libPanel.name : `${acc}, ${libPanel.name}`), + '' + ); + return `List of affected library panels: ${libPanelNames}`; + } + + return undefined; +}; + export async function moveFolders(folderUIDs: string[], toFolder: FolderInfo) { const result = { totalCount: folderUIDs.length, diff --git a/public/app/features/manage-dashboards/state/reducers.ts b/public/app/features/manage-dashboards/state/reducers.ts index 97f05697fa4..bea14221a51 100644 --- a/public/app/features/manage-dashboards/state/reducers.ts +++ b/public/app/features/manage-dashboards/state/reducers.ts @@ -34,6 +34,7 @@ export enum LibraryPanelInputState { export interface DashboardInput { name: string; label: string; + description?: string; info: string; value: string; type: InputType; @@ -100,7 +101,7 @@ const importDashboardSlice = createSlice({ state.inputs = { dataSources: action.payload.filter((p) => p.type === InputType.DataSource), constants: action.payload.filter((p) => p.type === InputType.Constant), - libraryPanels: [], + libraryPanels: state.inputs.libraryPanels || [], }; }, setLibraryPanelInputs: (state: Draft, action: PayloadAction) => { diff --git a/public/app/features/manage-dashboards/types.ts b/public/app/features/manage-dashboards/types.ts index 0b24c223403..48bd056afb7 100644 --- a/public/app/features/manage-dashboards/types.ts +++ b/public/app/features/manage-dashboards/types.ts @@ -1,3 +1,7 @@ +import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types'; + +import { ExternalDashboard } from '../dashboard/components/DashExportModal/DashboardExporter'; + export interface Snapshot { created: string; expires: string; @@ -36,3 +40,5 @@ export interface PublicDashboardListResponse { export interface PublicDashboardListWithPagination extends PublicDashboardListWithPaginationResponse { totalPages: number; } + +export type DashboardJson = ExternalDashboard & Omit;