mirror of https://github.com/grafana/grafana.git
				
				
				
			Dashboard Schema V2: Export dashboard JSON (#101180)
* wip * fix the async bug * Fix tests * support library panels * Update comment * clean up * refactor, use multi resource kind * uncomment * return only the spec * simplify templateizing logic * update dashboardkind * revert imprtable requirements to use the current interface * tests and fixes * add panel configs to required; more tests; * everything in elements should be processed * clean up * clean up * rename for clarity; clean up tests * export resource v spec * i18n * clean unused * fix library panels * revert comments * revert lefthook.rc * don't support lib panels * another lefhook revert * remove lib panel in test * Remove library panels on external export; show warning if dash contains library panels * fix tests * Support old export mode * clean up * rely on dash shape vs feature toggle, add spacing to alert, update message * lint * typo fix * make makeExportable part of dashboard serializer; clean up old exporter * clean up * more cleanup * gem file cleanup * remove unused function * remove unused selector * don't remove ds refs that use ds template variable * clean up
This commit is contained in:
		
							parent
							
								
									bba85c1128
								
							
						
					
					
						commit
						5011907dae
					
				|  | @ -1569,6 +1569,27 @@ exports[`better eslint`] = { | ||||||
|     "public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [ |     "public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [ | ||||||
|       [0, 0, 0, "Do not use any type assertions.", "0"] |       [0, 0, 0, "Do not use any type assertions.", "0"] | ||||||
|     ], |     ], | ||||||
|  |     "public/app/features/dashboard-scene/scene/export/exporters.test.ts:5381": [ | ||||||
|  |       [0, 0, 0, "Unexpected any. Specify a different type.", "0"], | ||||||
|  |       [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, "Unexpected any. Specify a different type.", "5"] | ||||||
|  |     ], | ||||||
|  |     "public/app/features/dashboard-scene/scene/export/exporters.ts:5381": [ | ||||||
|  |       [0, 0, 0, "Do not use any type assertions.", "0"], | ||||||
|  |       [0, 0, 0, "Do not use any type assertions.", "1"], | ||||||
|  |       [0, 0, 0, "Do not use any type assertions.", "2"], | ||||||
|  |       [0, 0, 0, "Unexpected any. Specify a different type.", "3"], | ||||||
|  |       [0, 0, 0, "Unexpected any. Specify a different type.", "4"], | ||||||
|  |       [0, 0, 0, "Unexpected any. Specify a different type.", "5"], | ||||||
|  |       [0, 0, 0, "Unexpected any. Specify a different type.", "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"] | ||||||
|  |     ], | ||||||
|     "public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [ |     "public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [ | ||||||
|       [0, 0, 0, "Unexpected any. Specify a different type.", "0"] |       [0, 0, 0, "Unexpected any. Specify a different type.", "0"] | ||||||
|     ], |     ], | ||||||
|  |  | ||||||
|  | @ -29,6 +29,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; | ||||||
| import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel'; | import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel'; | ||||||
| import { PanelModel } from 'app/features/dashboard/state/PanelModel'; | import { PanelModel } from 'app/features/dashboard/state/PanelModel'; | ||||||
| import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; | import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; | ||||||
|  | import { DashboardJson } from 'app/features/manage-dashboards/types'; | ||||||
| import { VariablesChanged } from 'app/features/variables/types'; | import { VariablesChanged } from 'app/features/variables/types'; | ||||||
| import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; | import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; | ||||||
| import { ShowConfirmModalEvent } from 'app/types/events'; | import { ShowConfirmModalEvent } from 'app/types/events'; | ||||||
|  | @ -181,7 +182,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme | ||||||
| 
 | 
 | ||||||
|   public serializer: DashboardSceneSerializerLike< |   public serializer: DashboardSceneSerializerLike< | ||||||
|     Dashboard | DashboardV2Spec, |     Dashboard | DashboardV2Spec, | ||||||
|     DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'] |     DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'], | ||||||
|  |     Dashboard | DashboardV2Spec, | ||||||
|  |     DashboardJson | DashboardV2Spec | ||||||
|   >; |   >; | ||||||
| 
 | 
 | ||||||
|   private _layoutRestorer = new LayoutRestorer(); |   private _layoutRestorer = new LayoutRestorer(); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,656 @@ | ||||||
|  | import { find } from 'lodash'; | ||||||
|  | 
 | ||||||
|  | import { DataSourceInstanceSettings, DataSourceRef, PanelPluginMeta, TypedVariableModel } from '@grafana/data'; | ||||||
|  | import { Dashboard, DashboardCursorSync, ThresholdsMode } from '@grafana/schema'; | ||||||
|  | import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; | ||||||
|  | import { | ||||||
|  |   DatasourceVariableKind, | ||||||
|  |   QueryVariableKind, | ||||||
|  | } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; | ||||||
|  | import config from 'app/core/config'; | ||||||
|  | import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; | ||||||
|  | 
 | ||||||
|  | import { LibraryElementKind } from '../../../library-panels/types'; | ||||||
|  | import { DashboardJson } from '../../../manage-dashboards/types'; | ||||||
|  | import { variableAdapters } from '../../../variables/adapters'; | ||||||
|  | import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; | ||||||
|  | import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; | ||||||
|  | import { createQueryVariableAdapter } from '../../../variables/query/adapter'; | ||||||
|  | 
 | ||||||
|  | import { makeExportableV1, makeExportableV2, LibraryElementExport } from './exporters'; | ||||||
|  | 
 | ||||||
|  | jest.mock('app/core/store', () => { | ||||||
|  |   return { | ||||||
|  |     getBool: jest.fn(), | ||||||
|  |     getObject: jest.fn((_a, b) => b), | ||||||
|  |     get: jest.fn(), | ||||||
|  |   }; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | jest.mock('@grafana/runtime', () => ({ | ||||||
|  |   ...jest.requireActual('@grafana/runtime'), | ||||||
|  |   getDataSourceSrv: () => { | ||||||
|  |     return { | ||||||
|  |       get: (v: string | DataSourceRef) => { | ||||||
|  |         const s = getStubInstanceSettings(v); | ||||||
|  |         return Promise.resolve(s); | ||||||
|  |       }, | ||||||
|  |       getInstanceSettings: getStubInstanceSettings, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   config: { | ||||||
|  |     buildInfo: {}, | ||||||
|  |     panels: {}, | ||||||
|  |     apps: {}, | ||||||
|  |     featureToggles: { | ||||||
|  |       newVariables: false, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | jest.mock('app/features/library-panels/state/api', () => ({ | ||||||
|  |   getLibraryPanel: jest.fn().mockReturnValue( | ||||||
|  |     Promise.resolve({ | ||||||
|  |       name: 'Testing lib panel 1', | ||||||
|  |       uid: 'abc-123', | ||||||
|  |       model: { | ||||||
|  |         type: 'graph', | ||||||
|  |         datasource: { | ||||||
|  |           type: 'testdb', | ||||||
|  |           uid: '${DS_GFDB}', | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }) | ||||||
|  |   ), | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | variableAdapters.register(createQueryVariableAdapter()); | ||||||
|  | variableAdapters.register(createConstantVariableAdapter()); | ||||||
|  | variableAdapters.register(createDataSourceVariableAdapter()); | ||||||
|  | 
 | ||||||
|  | describe('dashboard exporter v1', () => { | ||||||
|  |   it('handles a default datasource in a template variable', async () => { | ||||||
|  |     const dashboard: any = { | ||||||
|  |       templating: { | ||||||
|  |         list: [ | ||||||
|  |           { | ||||||
|  |             current: {}, | ||||||
|  |             definition: 'test', | ||||||
|  |             error: {}, | ||||||
|  |             hide: 0, | ||||||
|  |             includeAll: false, | ||||||
|  |             multi: false, | ||||||
|  |             name: 'query0', | ||||||
|  |             options: [], | ||||||
|  |             query: { | ||||||
|  |               query: 'test', | ||||||
|  |               refId: 'StandardVariableQuery', | ||||||
|  |             }, | ||||||
|  |             refresh: 1, | ||||||
|  |             regex: '', | ||||||
|  |             skipUrlSync: false, | ||||||
|  |             sort: 0, | ||||||
|  |             type: 'query', | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |     const dashboardModel = new DashboardModel(dashboard, undefined, { | ||||||
|  |       getVariablesFromState: () => dashboard.templating.list, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const exported: any = await makeExportableV1(dashboardModel); | ||||||
|  |     expect(exported.templating.list[0].datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('do not expose datasource name and id in a in a template variable of type datasource', async () => { | ||||||
|  |     const dashboard: Dashboard = { | ||||||
|  |       title: 'My dashboard', | ||||||
|  |       revision: 1, | ||||||
|  |       editable: false, | ||||||
|  |       graphTooltip: DashboardCursorSync.Off, | ||||||
|  |       schemaVersion: 1, | ||||||
|  |       timepicker: { hidden: true }, | ||||||
|  |       timezone: '', | ||||||
|  |       panels: [ | ||||||
|  |         { | ||||||
|  |           id: 1, | ||||||
|  |           type: 'timeseries', | ||||||
|  |           title: 'My panel title', | ||||||
|  |           gridPos: { x: 0, y: 0, w: 1, h: 1 }, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       templating: { | ||||||
|  |         list: [ | ||||||
|  |           { | ||||||
|  |             current: { | ||||||
|  |               selected: false, | ||||||
|  |               text: 'my-prometheus-datasource', | ||||||
|  |               value: 'my-prometheus-datasource-uid', | ||||||
|  |             }, | ||||||
|  |             hide: 0, | ||||||
|  |             includeAll: false, | ||||||
|  |             multi: false, | ||||||
|  |             name: 'query1', | ||||||
|  |             options: [], | ||||||
|  |             query: 'prometheus', | ||||||
|  |             refresh: 1, | ||||||
|  |             regex: '', | ||||||
|  |             skipUrlSync: false, | ||||||
|  |             type: 'datasource', | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |     const dashboardModel = new DashboardModel(dashboard, undefined, { | ||||||
|  |       getVariablesFromState: () => dashboard.templating!.list! as TypedVariableModel[], | ||||||
|  |     }); | ||||||
|  |     const exported = (await makeExportableV1(dashboardModel)) as DashboardJson; | ||||||
|  |     const value = exported?.templating?.list ? exported?.templating?.list[0].current : ''; | ||||||
|  |     expect(value).toEqual({}); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('replaces datasource ref in library panel', async () => { | ||||||
|  |     const dashboard: Dashboard = { | ||||||
|  |       editable: true, | ||||||
|  |       graphTooltip: 1, | ||||||
|  |       schemaVersion: 38, | ||||||
|  |       panels: [ | ||||||
|  |         { | ||||||
|  |           id: 1, | ||||||
|  |           title: 'Panel title', | ||||||
|  |           type: 'timeseries', | ||||||
|  |           options: { | ||||||
|  |             cellHeight: 'sm', | ||||||
|  |             footer: { | ||||||
|  |               countRows: false, | ||||||
|  |               fields: '', | ||||||
|  |               reducer: ['sum'], | ||||||
|  |               show: false, | ||||||
|  |             }, | ||||||
|  |             showHeader: true, | ||||||
|  |           }, | ||||||
|  |           transformations: [], | ||||||
|  |           transparent: false, | ||||||
|  |           fieldConfig: { | ||||||
|  |             defaults: { | ||||||
|  |               custom: { | ||||||
|  |                 align: 'auto', | ||||||
|  |                 cellOptions: { | ||||||
|  |                   type: 'auto', | ||||||
|  |                 }, | ||||||
|  |                 inspect: false, | ||||||
|  |               }, | ||||||
|  |               mappings: [], | ||||||
|  |               thresholds: { | ||||||
|  |                 mode: ThresholdsMode.Absolute, | ||||||
|  |                 steps: [ | ||||||
|  |                   { | ||||||
|  |                     color: 'green', | ||||||
|  |                     value: 10, | ||||||
|  |                   }, | ||||||
|  |                   { | ||||||
|  |                     color: 'red', | ||||||
|  |                     value: 80, | ||||||
|  |                   }, | ||||||
|  |                 ], | ||||||
|  |               }, | ||||||
|  |             }, | ||||||
|  |             overrides: [], | ||||||
|  |           }, | ||||||
|  |           gridPos: { | ||||||
|  |             h: 8, | ||||||
|  |             w: 12, | ||||||
|  |             x: 0, | ||||||
|  |             y: 0, | ||||||
|  |           }, | ||||||
|  |           libraryPanel: { | ||||||
|  |             name: 'Testing lib panel', | ||||||
|  |             uid: 'c46a6b49-de40-43b3-982c-1b5e1ec084a4', | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const dashboardModel = new DashboardModel(dashboard, {}); | ||||||
|  | 
 | ||||||
|  |     const exported = (await makeExportableV1(dashboardModel)) as DashboardJson; | ||||||
|  |     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'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('If a panel queries has no datasource prop ignore it', async () => { | ||||||
|  |     const dashboard = { | ||||||
|  |       panels: [ | ||||||
|  |         { | ||||||
|  |           id: 1, | ||||||
|  |           type: 'graph', | ||||||
|  |           datasource: { | ||||||
|  |             uid: 'other', | ||||||
|  |             type: 'other', | ||||||
|  |           }, | ||||||
|  |           targets: [{ refId: 'A', a: 'A' }], | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     } as unknown as Dashboard; | ||||||
|  |     const dashboardModel = new DashboardModel(dashboard, undefined, { | ||||||
|  |       getVariablesFromState: () => [], | ||||||
|  |     }); | ||||||
|  |     const exported: any = await makeExportableV1(dashboardModel); | ||||||
|  |     expect(exported.panels[0].datasource).toEqual({ uid: '${DS_OTHER}', type: 'other' }); | ||||||
|  |     expect(exported.panels[0].targets[0].datasource).toEqual({ uid: '${DS_OTHER}', type: 'other' }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('given dashboard with repeated panels', () => { | ||||||
|  |     let dash: any, exported: any; | ||||||
|  | 
 | ||||||
|  |     beforeEach((done) => { | ||||||
|  |       dash = { | ||||||
|  |         templating: { | ||||||
|  |           list: [ | ||||||
|  |             { | ||||||
|  |               name: 'apps', | ||||||
|  |               type: 'query', | ||||||
|  |               datasource: { uid: 'gfdb', type: 'testdb' }, | ||||||
|  |               current: { value: 'Asd', text: 'Asd' }, | ||||||
|  |               options: [{ value: 'Asd', text: 'Asd' }], | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               name: 'prefix', | ||||||
|  |               type: 'constant', | ||||||
|  |               current: { value: 'collectd', text: 'collectd' }, | ||||||
|  |               options: [], | ||||||
|  |               query: 'collectd', | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               name: 'ds', | ||||||
|  |               type: 'datasource', | ||||||
|  |               query: 'other2', | ||||||
|  |               current: { value: 'other2', text: 'other2' }, | ||||||
|  |               options: [], | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |         }, | ||||||
|  |         annotations: { | ||||||
|  |           list: [ | ||||||
|  |             { | ||||||
|  |               name: 'logs', | ||||||
|  |               datasource: 'gfdb', | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |         }, | ||||||
|  |         panels: [ | ||||||
|  |           { id: 6, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'graph' }, | ||||||
|  |           { id: 7 }, | ||||||
|  |           { | ||||||
|  |             id: 8, | ||||||
|  |             datasource: { uid: '-- Mixed --', type: 'mixed' }, | ||||||
|  |             targets: [{ datasource: { uid: 'other', type: 'other' } }], | ||||||
|  |           }, | ||||||
|  |           { id: 9, datasource: { uid: '$ds', type: 'other2' } }, | ||||||
|  |           { | ||||||
|  |             id: 17, | ||||||
|  |             libraryPanel: { | ||||||
|  |               name: 'Library Panel 2', | ||||||
|  |               uid: 'ah8NqyDPs', | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             id: 2, | ||||||
|  |             repeat: 'apps', | ||||||
|  |             datasource: { uid: 'gfdb', type: 'testdb' }, | ||||||
|  |             type: 'graph', | ||||||
|  |           }, | ||||||
|  |           { id: 3, repeat: null, repeatPanelId: 2 }, | ||||||
|  |           { | ||||||
|  |             id: 4, | ||||||
|  |             collapsed: true, | ||||||
|  |             panels: [ | ||||||
|  |               { id: 10, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'table' }, | ||||||
|  |               { id: 11 }, | ||||||
|  |               { | ||||||
|  |                 id: 12, | ||||||
|  |                 datasource: { uid: '-- Mixed --', type: 'mixed' }, | ||||||
|  |                 targets: [{ datasource: { uid: 'other', type: 'other' } }], | ||||||
|  |               }, | ||||||
|  |               { id: 13, datasource: { uid: '$uid', type: 'other' } }, | ||||||
|  |               { | ||||||
|  |                 id: 14, | ||||||
|  |                 repeat: 'apps', | ||||||
|  |                 datasource: { uid: 'gfdb', type: 'testdb' }, | ||||||
|  |                 type: 'heatmap', | ||||||
|  |               }, | ||||||
|  |               { id: 15, repeat: null, repeatPanelId: 14 }, | ||||||
|  |               { | ||||||
|  |                 id: 16, | ||||||
|  |                 datasource: { uid: 'gfdb', type: 'testdb' }, | ||||||
|  |                 type: 'graph', | ||||||
|  |                 libraryPanel: { | ||||||
|  |                   name: 'Library Panel', | ||||||
|  |                   uid: 'jL6MrxCMz', | ||||||
|  |                 }, | ||||||
|  |               }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             id: 5, | ||||||
|  |             targets: [{ scenarioId: 'random_walk', refId: 'A' }], | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }; | ||||||
|  | 
 | ||||||
|  |       config.buildInfo.version = '3.0.2'; | ||||||
|  | 
 | ||||||
|  |       config.panels['graph'] = { | ||||||
|  |         id: 'graph', | ||||||
|  |         name: 'Graph', | ||||||
|  |         info: { version: '1.1.0' }, | ||||||
|  |       } as PanelPluginMeta; | ||||||
|  | 
 | ||||||
|  |       config.panels['table'] = { | ||||||
|  |         id: 'table', | ||||||
|  |         name: 'Table', | ||||||
|  |         info: { version: '1.1.1' }, | ||||||
|  |       } as PanelPluginMeta; | ||||||
|  | 
 | ||||||
|  |       config.panels['heatmap'] = { | ||||||
|  |         id: 'heatmap', | ||||||
|  |         name: 'Heatmap', | ||||||
|  |         info: { version: '1.1.2' }, | ||||||
|  |       } as PanelPluginMeta; | ||||||
|  | 
 | ||||||
|  |       dash = new DashboardModel( | ||||||
|  |         dash, | ||||||
|  |         {}, | ||||||
|  |         { | ||||||
|  |           getVariablesFromState: () => dash.templating.list, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       // init library panels
 | ||||||
|  |       dash.getPanelById(17).initLibraryPanel({ | ||||||
|  |         uid: 'ah8NqyDPs', | ||||||
|  |         name: 'Library Panel 2', | ||||||
|  |         model: { | ||||||
|  |           datasource: { type: 'other2', uid: '$ds' }, | ||||||
|  |           targets: [{ refId: 'A', datasource: { type: 'other2', uid: '$ds' } }], | ||||||
|  |           type: 'graph', | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       makeExportableV1(dash).then((clean) => { | ||||||
|  |         exported = clean; | ||||||
|  |         done(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should replace datasource refs', () => { | ||||||
|  |       const panel = exported.panels[0]; | ||||||
|  |       expect(panel.datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should explicitly specify default datasources', () => { | ||||||
|  |       const panel = exported.panels[7]; | ||||||
|  |       expect(exported.__inputs.some((ds: Record<string, string>) => ds.name === 'DS_GFDB')).toBeTruthy(); | ||||||
|  |       expect(panel.datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |       expect(panel.targets[0].datasource).toEqual({ type: 'testdb', uid: '${DS_GFDB}' }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not include default datasource in __inputs unnecessarily', async () => { | ||||||
|  |       const testJson = { | ||||||
|  |         panels: [{ id: 1, datasource: { uid: 'other', type: 'other' }, type: 'graph' }], | ||||||
|  |       } as unknown as Dashboard; | ||||||
|  |       const testDash = new DashboardModel(testJson); | ||||||
|  |       const exportedJson: any = await makeExportableV1(testDash); | ||||||
|  |       expect(exportedJson.__inputs.some((ds: Record<string, string>) => ds.name === 'DS_GFDB')).toBeFalsy(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should replace datasource refs in collapsed row', () => { | ||||||
|  |       const panel = exported.panels[6].panels[0]; | ||||||
|  |       expect(panel.datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should replace datasource in variable query', () => { | ||||||
|  |       expect(exported.templating.list[0].datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |       expect(exported.templating.list[0].options.length).toBe(0); | ||||||
|  |       expect(exported.templating.list[0].current.value).toBe(undefined); | ||||||
|  |       expect(exported.templating.list[0].current.text).toBe(undefined); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should replace datasource in annotation query', () => { | ||||||
|  |       expect(exported.annotations.list[1].datasource.uid).toBe('${DS_GFDB}'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add datasource as input', () => { | ||||||
|  |       expect(exported.__inputs[0].name).toBe('DS_GFDB'); | ||||||
|  |       expect(exported.__inputs[0].pluginId).toBe('testdb'); | ||||||
|  |       expect(exported.__inputs[0].type).toBe('datasource'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add datasource to required', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'TestDB' }); | ||||||
|  |       expect(require.name).toBe('TestDB'); | ||||||
|  |       expect(require.id).toBe('testdb'); | ||||||
|  |       expect(require.type).toBe('datasource'); | ||||||
|  |       expect(require.version).toBe('1.2.1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not add built in datasources to required', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'Mixed' }); | ||||||
|  |       expect(require).toBe(undefined); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add datasources used in mixed mode', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'OtherDB' }); | ||||||
|  |       expect(require).not.toBe(undefined); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add graph panel to required', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'Graph' }); | ||||||
|  |       expect(require.name).toBe('Graph'); | ||||||
|  |       expect(require.id).toBe('graph'); | ||||||
|  |       expect(require.version).toBe('1.1.0'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add table panel to required', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'Table' }); | ||||||
|  |       expect(require.name).toBe('Table'); | ||||||
|  |       expect(require.id).toBe('table'); | ||||||
|  |       expect(require.version).toBe('1.1.1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add heatmap panel to required', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'Heatmap' }); | ||||||
|  |       expect(require.name).toBe('Heatmap'); | ||||||
|  |       expect(require.id).toBe('heatmap'); | ||||||
|  |       expect(require.version).toBe('1.1.2'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add grafana version', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'Grafana' }); | ||||||
|  |       expect(require.type).toBe('grafana'); | ||||||
|  |       expect(require.id).toBe('grafana'); | ||||||
|  |       expect(require.version).toBe('3.0.2'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add constant template variables as inputs', () => { | ||||||
|  |       const input = find(exported.__inputs, { name: 'VAR_PREFIX' }); | ||||||
|  |       expect(input.type).toBe('constant'); | ||||||
|  |       expect(input.label).toBe('prefix'); | ||||||
|  |       expect(input.value).toBe('collectd'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should templatize constant variables', () => { | ||||||
|  |       const variable = find(exported.templating.list, { name: 'prefix' }); | ||||||
|  |       expect(variable.query).toBe('${VAR_PREFIX}'); | ||||||
|  |       expect(variable.current.text).toBe('${VAR_PREFIX}'); | ||||||
|  |       expect(variable.current.value).toBe('${VAR_PREFIX}'); | ||||||
|  |       expect(variable.options[0].text).toBe('${VAR_PREFIX}'); | ||||||
|  |       expect(variable.options[0].value).toBe('${VAR_PREFIX}'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add datasources only use via datasource variable to requires', () => { | ||||||
|  |       const require = find(exported.__requires, { name: 'OtherDB_2' }); | ||||||
|  |       expect(require.id).toBe('other2'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add library panels as elements', () => { | ||||||
|  |       const element: LibraryElementExport = exported.__elements['ah8NqyDPs']; | ||||||
|  |       expect(element.name).toBe('Library Panel 2'); | ||||||
|  |       expect(element.kind).toBe(LibraryElementKind.Panel); | ||||||
|  |       expect(element.model).toEqual({ | ||||||
|  |         datasource: { type: 'testdb', uid: '${DS_GFDB}' }, | ||||||
|  |         type: 'graph', | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should add library panels in collapsed rows as elements', () => { | ||||||
|  |       const element: LibraryElementExport = exported.__elements['jL6MrxCMz']; | ||||||
|  |       expect(element.name).toBe('Library Panel'); | ||||||
|  |       expect(element.kind).toBe(LibraryElementKind.Panel); | ||||||
|  |       expect(element.model).toEqual({ | ||||||
|  |         type: 'graph', | ||||||
|  |         datasource: { | ||||||
|  |           type: 'testdb', | ||||||
|  |           uid: '${DS_GFDB}', | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('dashboard exporter v2', () => { | ||||||
|  |   const setup = async () => { | ||||||
|  |     // Making a deep copy here because original JSON is mutated by the exporter
 | ||||||
|  |     const schemaCopy = JSON.parse(JSON.stringify(handyTestingSchema)); | ||||||
|  | 
 | ||||||
|  |     // add a panel that uses a datasource variable
 | ||||||
|  |     schemaCopy.elements['panel-using-datasource-var'] = { | ||||||
|  |       kind: 'Panel', | ||||||
|  |       spec: { | ||||||
|  |         data: { | ||||||
|  |           kind: 'QueryGroup', | ||||||
|  |           spec: { | ||||||
|  |             queries: [ | ||||||
|  |               { | ||||||
|  |                 kind: 'PanelQuery', | ||||||
|  |                 spec: { | ||||||
|  |                   datasource: { | ||||||
|  |                     type: 'prometheus', | ||||||
|  |                     uid: '${datasourceVar}', | ||||||
|  |                   }, | ||||||
|  |                   hidden: false, | ||||||
|  |                   query: { | ||||||
|  |                     kind: 'prometheus', | ||||||
|  |                     spec: { | ||||||
|  |                       editorMode: 'builder', | ||||||
|  |                       expr: 'go_goroutines{job="prometheus"}', | ||||||
|  |                       includeNullMetadata: true, | ||||||
|  |                       legendFormat: '__auto', | ||||||
|  |                       range: true, | ||||||
|  |                     }, | ||||||
|  |                   }, | ||||||
|  |                   refId: 'A', | ||||||
|  |                 }, | ||||||
|  |               }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const dashboard = await makeExportableV2(schemaCopy); | ||||||
|  |     if (typeof dashboard === 'object' && 'error' in dashboard) { | ||||||
|  |       throw dashboard.error; | ||||||
|  |     } | ||||||
|  |     return { dashboard, originalSchema: handyTestingSchema }; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   it('should replace datasource in a query variable', async () => { | ||||||
|  |     const { dashboard } = await setup(); | ||||||
|  |     const variable = dashboard.variables[0] as QueryVariableKind; | ||||||
|  |     expect(variable.spec.datasource?.uid).toBeUndefined(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('do not expose datasource name and id in datasource variable', async () => { | ||||||
|  |     const { dashboard } = await setup(); | ||||||
|  |     const variable = dashboard.variables[2] as DatasourceVariableKind; | ||||||
|  |     expect(variable.kind).toBe('DatasourceVariable'); | ||||||
|  |     expect(variable.spec.current).toEqual({ text: '', value: '' }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('should replace datasource in annotation query', async () => { | ||||||
|  |     const { dashboard } = await setup(); | ||||||
|  |     const annotationQuery = dashboard.annotations[0]; | ||||||
|  | 
 | ||||||
|  |     expect(annotationQuery.spec.datasource?.uid).toBeUndefined(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('should remove library panels from layout', async () => { | ||||||
|  |     const { dashboard, originalSchema } = await setup(); | ||||||
|  |     const elementRef = 'panel-2'; | ||||||
|  |     const libraryPanel = dashboard.elements[elementRef]; | ||||||
|  |     const origLibraryPanel = originalSchema.elements[elementRef]; | ||||||
|  |     expect(origLibraryPanel.kind).toBe('LibraryPanel'); | ||||||
|  |     expect(libraryPanel).toBeUndefined(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('should not remove datasource ref from panel that uses a datasource variable', async () => { | ||||||
|  |     const { dashboard } = await setup(); | ||||||
|  |     const panel = dashboard.elements['panel-using-datasource-var']; | ||||||
|  | 
 | ||||||
|  |     if (panel.kind !== 'Panel') { | ||||||
|  |       throw new Error('Panel should be a Panel'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     expect(panel.spec.data.spec.queries[0].spec.datasource).toEqual({ | ||||||
|  |       type: 'prometheus', | ||||||
|  |       uid: '${datasourceVar}', | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | function getStubInstanceSettings(v: string | DataSourceRef): DataSourceInstanceSettings { | ||||||
|  |   let key = (v as DataSourceRef)?.type ?? v; | ||||||
|  |   return stubs[(key as string) ?? 'gfdb'] ?? stubs['gfdb']; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Stub responses
 | ||||||
|  | const stubs: { [key: string]: DataSourceInstanceSettings } = {}; | ||||||
|  | stubs['gfdb'] = { | ||||||
|  |   name: 'gfdb', | ||||||
|  |   meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, | ||||||
|  | } as DataSourceInstanceSettings; | ||||||
|  | 
 | ||||||
|  | stubs['other'] = { | ||||||
|  |   name: 'other', | ||||||
|  |   meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, | ||||||
|  | } as DataSourceInstanceSettings; | ||||||
|  | 
 | ||||||
|  | stubs['other2'] = { | ||||||
|  |   name: 'other2', | ||||||
|  |   meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, | ||||||
|  | } as DataSourceInstanceSettings; | ||||||
|  | 
 | ||||||
|  | stubs['mixed'] = { | ||||||
|  |   name: 'mixed', | ||||||
|  |   meta: { | ||||||
|  |     id: 'mixed', | ||||||
|  |     info: { version: '1.2.1' }, | ||||||
|  |     name: 'Mixed', | ||||||
|  |     builtIn: true, | ||||||
|  |   }, | ||||||
|  | } as DataSourceInstanceSettings; | ||||||
|  | 
 | ||||||
|  | stubs['grafana'] = { | ||||||
|  |   name: '-- Grafana --', | ||||||
|  |   meta: { | ||||||
|  |     id: 'grafana', | ||||||
|  |     info: { version: '1.2.1' }, | ||||||
|  |     name: 'grafana', | ||||||
|  |     builtIn: true, | ||||||
|  |   }, | ||||||
|  | } as DataSourceInstanceSettings; | ||||||
|  | @ -0,0 +1,411 @@ | ||||||
|  | import { defaults, each, sortBy } from 'lodash'; | ||||||
|  | 
 | ||||||
|  | import { DataSourceRef, PanelPluginMeta, VariableOption, VariableRefresh } from '@grafana/data'; | ||||||
|  | import { getDataSourceSrv } from '@grafana/runtime'; | ||||||
|  | import { | ||||||
|  |   Spec as DashboardV2Spec, | ||||||
|  |   PanelKind, | ||||||
|  |   PanelQueryKind, | ||||||
|  |   AnnotationQueryKind, | ||||||
|  |   QueryVariableKind, | ||||||
|  |   LibraryPanelRef, | ||||||
|  | } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; | ||||||
|  | import config from 'app/core/config'; | ||||||
|  | import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; | ||||||
|  | import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel'; | ||||||
|  | import { getLibraryPanel } from 'app/features/library-panels/state/api'; | ||||||
|  | import { variableRegex } from 'app/features/variables/utils'; | ||||||
|  | 
 | ||||||
|  | import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; | ||||||
|  | import { LibraryElementKind } from '../../../library-panels/types'; | ||||||
|  | import { DashboardJson } from '../../../manage-dashboards/types'; | ||||||
|  | import { isConstant } from '../../../variables/guard'; | ||||||
|  | 
 | ||||||
|  | import { removePanelRefFromLayout } from './utils'; | ||||||
|  | 
 | ||||||
|  | export interface InputUsage { | ||||||
|  |   libraryPanels?: LibraryPanelRef[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface Input { | ||||||
|  |   name: string; | ||||||
|  |   type: string; | ||||||
|  |   label: string; | ||||||
|  |   value: any; | ||||||
|  |   description: string; | ||||||
|  |   usage?: InputUsage; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface Requires { | ||||||
|  |   [key: string]: { | ||||||
|  |     type: string; | ||||||
|  |     id: string; | ||||||
|  |     name: string; | ||||||
|  |     version: string; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ExternalDashboard { | ||||||
|  |   __inputs?: Input[]; | ||||||
|  |   __elements?: Record<string, LibraryElementExport>; | ||||||
|  |   __requires?: Array<Requires[string]>; | ||||||
|  |   panels: Array<PanelModel | PanelWithExportableLibraryPanel>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface PanelWithExportableLibraryPanel { | ||||||
|  |   gridPos: GridPos; | ||||||
|  |   id: number; | ||||||
|  |   libraryPanel: LibraryPanelRef; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function isExportableLibraryPanel( | ||||||
|  |   p: PanelModel | PanelWithExportableLibraryPanel | ||||||
|  | ): p is PanelWithExportableLibraryPanel { | ||||||
|  |   return Boolean(p.libraryPanel?.name && p.libraryPanel?.uid); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface DataSources { | ||||||
|  |   [key: string]: { | ||||||
|  |     name: string; | ||||||
|  |     label: string; | ||||||
|  |     description: string; | ||||||
|  |     type: string; | ||||||
|  |     pluginId: string; | ||||||
|  |     pluginName: string; | ||||||
|  |     usage?: InputUsage; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface LibraryElementExport { | ||||||
|  |   name: string; | ||||||
|  |   uid: string; | ||||||
|  |   model: any; | ||||||
|  |   kind: LibraryElementKind; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function makeExportableV1(dashboard: DashboardModel) { | ||||||
|  |   // clean up repeated rows and panels,
 | ||||||
|  |   // this is done on the live real dashboard instance, not on a clone
 | ||||||
|  |   // so we need to undo this
 | ||||||
|  |   // this is pretty hacky and needs to be changed
 | ||||||
|  |   dashboard.cleanUpRepeats(); | ||||||
|  | 
 | ||||||
|  |   const saveModel = dashboard.getSaveModelCloneOld(); | ||||||
|  |   saveModel.id = null; | ||||||
|  | 
 | ||||||
|  |   // undo repeat cleanup
 | ||||||
|  |   dashboard.processRepeats(); | ||||||
|  | 
 | ||||||
|  |   const inputs: Input[] = []; | ||||||
|  |   const requires: Requires = {}; | ||||||
|  |   const datasources: DataSources = {}; | ||||||
|  |   const variableLookup: { [key: string]: any } = {}; | ||||||
|  |   const libraryPanels: Map<string, LibraryElementExport> = new Map<string, LibraryElementExport>(); | ||||||
|  | 
 | ||||||
|  |   for (const variable of saveModel.getVariables()) { | ||||||
|  |     variableLookup[variable.name] = variable; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const templateizeDatasourceUsage = (obj: any, fallback?: DataSourceRef) => { | ||||||
|  |     if (obj.datasource === undefined) { | ||||||
|  |       obj.datasource = fallback; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let datasource = obj.datasource; | ||||||
|  |     let datasourceVariable: any = null; | ||||||
|  | 
 | ||||||
|  |     const datasourceUid: string | undefined = datasource?.uid; | ||||||
|  |     const match = datasourceUid && variableRegex.exec(datasourceUid); | ||||||
|  | 
 | ||||||
|  |     // ignore data source properties that contain a variable
 | ||||||
|  |     if (match) { | ||||||
|  |       const varName = match[1] || match[2] || match[4]; | ||||||
|  |       datasourceVariable = variableLookup[varName]; | ||||||
|  |       if (datasourceVariable && datasourceVariable.current) { | ||||||
|  |         datasource = datasourceVariable.current.value; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return getDataSourceSrv() | ||||||
|  |       .get(datasource) | ||||||
|  |       .then((ds) => { | ||||||
|  |         if (ds.meta?.builtIn) { | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // add data source type to require list
 | ||||||
|  |         requires['datasource' + ds.meta?.id] = { | ||||||
|  |           type: 'datasource', | ||||||
|  |           id: ds.meta.id, | ||||||
|  |           name: ds.meta.name, | ||||||
|  |           version: ds.meta.info.version || '1.0.0', | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         // if used via variable we can skip templatizing usage
 | ||||||
|  |         if (datasourceVariable) { | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         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, | ||||||
|  |           description: '', | ||||||
|  |           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 + '}' }; | ||||||
|  |       }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const processPanel = async (panel: PanelModel) => { | ||||||
|  |     if (panel.type !== 'row') { | ||||||
|  |       await templateizeDatasourceUsage(panel); | ||||||
|  | 
 | ||||||
|  |       if (panel.targets) { | ||||||
|  |         for (const target of panel.targets) { | ||||||
|  |           await templateizeDatasourceUsage(target, panel.datasource!); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const panelDef: PanelPluginMeta = config.panels[panel.type]; | ||||||
|  |       if (panelDef) { | ||||||
|  |         requires['panel' + panelDef.id] = { | ||||||
|  |           type: 'panel', | ||||||
|  |           id: panelDef.id, | ||||||
|  |           name: panelDef.name, | ||||||
|  |           version: panelDef.info.version, | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const processLibraryPanels = async (panel: PanelModel) => { | ||||||
|  |     if (isPanelModelLibraryPanel(panel)) { | ||||||
|  |       const { name, uid } = panel.libraryPanel; | ||||||
|  |       let model = panel.libraryPanel.model; | ||||||
|  |       if (!model) { | ||||||
|  |         const libPanel = await getLibraryPanel(uid, true); | ||||||
|  |         model = libPanel.model; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       await templateizeDatasourceUsage(model); | ||||||
|  | 
 | ||||||
|  |       const { gridPos, id, ...rest } = model as any; | ||||||
|  |       if (!libraryPanels.has(uid)) { | ||||||
|  |         libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model: rest }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     // check up panel data sources
 | ||||||
|  |     for (const panel of saveModel.panels) { | ||||||
|  |       await processPanel(panel); | ||||||
|  | 
 | ||||||
|  |       // handle collapsed rows
 | ||||||
|  |       if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { | ||||||
|  |         for (const rowPanel of panel.panels) { | ||||||
|  |           await processPanel(rowPanel); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // templatize template vars
 | ||||||
|  |     for (const variable of saveModel.getVariables()) { | ||||||
|  |       if (variable.type === 'query') { | ||||||
|  |         await templateizeDatasourceUsage(variable); | ||||||
|  |         variable.options = []; | ||||||
|  |         variable.current = {} as unknown as VariableOption; | ||||||
|  |         variable.refresh = | ||||||
|  |           variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; | ||||||
|  |       } else if (variable.type === 'datasource') { | ||||||
|  |         variable.current = {}; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // templatize annotations vars
 | ||||||
|  |     for (const annotationDef of saveModel.annotations.list) { | ||||||
|  |       await templateizeDatasourceUsage(annotationDef); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // add grafana version
 | ||||||
|  |     requires['grafana'] = { | ||||||
|  |       type: 'grafana', | ||||||
|  |       id: 'grafana', | ||||||
|  |       name: 'Grafana', | ||||||
|  |       version: config.buildInfo.version, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // we need to process all panels again after all the promises are resolved
 | ||||||
|  |     // so all data sources, variables and targets have been templateized when we process library panels
 | ||||||
|  |     for (const panel of saveModel.panels) { | ||||||
|  |       await processLibraryPanels(panel); | ||||||
|  |       if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { | ||||||
|  |         for (const rowPanel of panel.panels) { | ||||||
|  |           await processLibraryPanels(rowPanel); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     each(datasources, (value: any) => { | ||||||
|  |       inputs.push(value); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     // templatize constants
 | ||||||
|  |     for (const variable of saveModel.getVariables()) { | ||||||
|  |       if (isConstant(variable)) { | ||||||
|  |         const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); | ||||||
|  |         inputs.push({ | ||||||
|  |           name: refName, | ||||||
|  |           type: 'constant', | ||||||
|  |           label: variable.label || variable.name, | ||||||
|  |           value: variable.query, | ||||||
|  |           description: '', | ||||||
|  |         }); | ||||||
|  |         // update current and option
 | ||||||
|  |         variable.query = '${' + refName + '}'; | ||||||
|  |         variable.current = { | ||||||
|  |           value: variable.query, | ||||||
|  |           text: variable.query, | ||||||
|  |           selected: false, | ||||||
|  |         }; | ||||||
|  |         variable.options = [variable.current]; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const __elements = [...libraryPanels.entries()].reduce<Record<string, LibraryElementExport>>( | ||||||
|  |       (prev, [curKey, curLibPanel]) => { | ||||||
|  |         prev[curKey] = curLibPanel; | ||||||
|  |         return prev; | ||||||
|  |       }, | ||||||
|  |       {} | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     // make inputs and requires a top thing
 | ||||||
|  |     const newObj: DashboardJson = defaults( | ||||||
|  |       { | ||||||
|  |         __inputs: inputs, | ||||||
|  |         __elements, | ||||||
|  |         __requires: sortBy(requires, ['id']), | ||||||
|  |       }, | ||||||
|  |       saveModel | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     // Remove extraneous props from library panels
 | ||||||
|  |     for (let i = 0; i < newObj.panels.length; i++) { | ||||||
|  |       const libPanel = newObj.panels[i]; | ||||||
|  |       if (isExportableLibraryPanel(libPanel)) { | ||||||
|  |         newObj.panels[i] = { | ||||||
|  |           gridPos: libPanel.gridPos, | ||||||
|  |           id: libPanel.id, | ||||||
|  |           libraryPanel: { uid: libPanel.libraryPanel.uid, name: libPanel.libraryPanel.name }, | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return newObj; | ||||||
|  |   } catch (err) { | ||||||
|  |     console.error('Export failed:', err); | ||||||
|  |     return { | ||||||
|  |       error: err, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function makeExportableV2(dashboard: DashboardV2Spec) { | ||||||
|  |   const variableLookup: { [key: string]: any } = {}; | ||||||
|  | 
 | ||||||
|  |   // get all datasource variables
 | ||||||
|  |   const datasourceVariables = dashboard.variables.filter((v) => v.kind === 'DatasourceVariable'); | ||||||
|  | 
 | ||||||
|  |   for (const variable of dashboard.variables) { | ||||||
|  |     variableLookup[variable.spec.name] = variable.spec; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const removeDataSourceRefs = ( | ||||||
|  |     obj: AnnotationQueryKind['spec'] | QueryVariableKind['spec'] | PanelQueryKind['spec'] | ||||||
|  |   ) => { | ||||||
|  |     const datasourceUid = obj.datasource?.uid; | ||||||
|  |     if (datasourceUid?.startsWith('${') && datasourceUid?.endsWith('}')) { | ||||||
|  |       const varName = datasourceUid.slice(2, -1); | ||||||
|  |       // if there's a match we don't want to remove the datasource ref
 | ||||||
|  |       const match = datasourceVariables.find((v) => v.spec.name === varName); | ||||||
|  |       if (match) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     obj.datasource = undefined; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const processPanel = (panel: PanelKind) => { | ||||||
|  |     if (panel.spec.data.spec.queries) { | ||||||
|  |       for (const query of panel.spec.data.spec.queries) { | ||||||
|  |         removeDataSourceRefs(query.spec); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const elements = dashboard.elements; | ||||||
|  |     const layout = dashboard.layout; | ||||||
|  | 
 | ||||||
|  |     // process elements
 | ||||||
|  |     for (const [key, element] of Object.entries(elements)) { | ||||||
|  |       if (element.kind === 'Panel') { | ||||||
|  |         processPanel(element); | ||||||
|  |       } else if (element.kind === 'LibraryPanel') { | ||||||
|  |         // just remove the library panel
 | ||||||
|  |         delete elements[key]; | ||||||
|  |         // remove reference from layout
 | ||||||
|  |         removePanelRefFromLayout(layout, key); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // process template variables
 | ||||||
|  |     for (const variable of dashboard.variables) { | ||||||
|  |       if (variable.kind === 'QueryVariable') { | ||||||
|  |         removeDataSourceRefs(variable.spec); | ||||||
|  |         variable.spec.options = []; | ||||||
|  |         variable.spec.current = { | ||||||
|  |           text: '', | ||||||
|  |           value: '', | ||||||
|  |         }; | ||||||
|  |       } else if (variable.kind === 'DatasourceVariable') { | ||||||
|  |         variable.spec.current = { | ||||||
|  |           text: '', | ||||||
|  |           value: '', | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // process annotations vars
 | ||||||
|  |     for (const annotation of dashboard.annotations) { | ||||||
|  |       removeDataSourceRefs(annotation.spec); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return dashboard; | ||||||
|  |   } catch (err) { | ||||||
|  |     console.error('Export failed:', err); | ||||||
|  |     return { | ||||||
|  |       error: err, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,94 @@ | ||||||
|  | import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Removes a panel reference from a layout. | ||||||
|  |  * | ||||||
|  |  * @param layout - The layout to remove the panel reference from. | ||||||
|  |  * @param elementName - The name of the element to remove the panel reference from. This is the key of the element in | ||||||
|  |  * the elements object. | ||||||
|  |  */ | ||||||
|  | export function removePanelRefFromLayout(layout: DashboardV2Spec['layout'], elementName: string) { | ||||||
|  |   switch (layout.kind) { | ||||||
|  |     case 'GridLayout': { | ||||||
|  |       const items = layout.spec.items || []; | ||||||
|  |       layout.spec.items = items.filter((item) => { | ||||||
|  |         if (item.kind === 'GridLayoutItem') { | ||||||
|  |           return item.spec.element.name !== elementName; | ||||||
|  |         } else if (item.kind === 'GridLayoutRow') { | ||||||
|  |           item.spec.elements = item.spec.elements.filter((el) => el.spec.element.name !== elementName); | ||||||
|  |           // Keep the row if it still has elements left
 | ||||||
|  |           return item.spec.elements.length > 0; | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |       }); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'AutoGridLayout': { | ||||||
|  |       const items = layout.spec.items || []; | ||||||
|  |       layout.spec.items = items.filter((i) => i.kind === 'AutoGridLayoutItem' && i.spec.element.name !== elementName); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'RowsLayout': { | ||||||
|  |       // Each row has a nested layout, which we must process recursively
 | ||||||
|  |       const rows = layout.spec.rows || []; | ||||||
|  |       layout.spec.rows = rows.filter((row) => { | ||||||
|  |         removePanelRefFromLayout(row.spec.layout, elementName); | ||||||
|  |         return !isLayoutEmpty(row.spec.layout); | ||||||
|  |       }); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'TabsLayout': { | ||||||
|  |       // Each tab also has a nested layout, so we process it recursively
 | ||||||
|  |       const tabs = layout.spec.tabs || []; | ||||||
|  |       layout.spec.tabs = tabs.filter((tab) => { | ||||||
|  |         removePanelRefFromLayout(tab.spec.layout, elementName); | ||||||
|  |         return !isLayoutEmpty(tab.spec.layout); | ||||||
|  |       }); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function isLayoutEmpty(layout: DashboardV2Spec['layout']) { | ||||||
|  |   if (!layout || !layout.spec) { | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   switch (layout.kind) { | ||||||
|  |     case 'GridLayout': { | ||||||
|  |       const items = layout.spec.items || []; | ||||||
|  |       return ( | ||||||
|  |         items.length === 0 || | ||||||
|  |         items.every((item) => { | ||||||
|  |           if (item.kind === 'GridLayoutItem') { | ||||||
|  |             return false; | ||||||
|  |           } else if (item.kind === 'GridLayoutRow') { | ||||||
|  |             return item.spec.elements.length === 0; | ||||||
|  |           } | ||||||
|  |           return false; | ||||||
|  |         }) | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'AutoGridLayout': { | ||||||
|  |       const items = layout.spec.items || []; | ||||||
|  |       return items.length === 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'RowsLayout': { | ||||||
|  |       const rows = layout.spec.rows || []; | ||||||
|  |       return rows.length === 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     case 'TabsLayout': { | ||||||
|  |       const tabs = layout.spec.tabs || []; | ||||||
|  |       return tabs.length === 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     default: | ||||||
|  |       return true; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -5,16 +5,20 @@ import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; | ||||||
| import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; | import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; | ||||||
| import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; | import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; | ||||||
| import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; | import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; | ||||||
|  | import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; | ||||||
| import { | import { | ||||||
|   getPanelPluginCounts, |   getPanelPluginCounts, | ||||||
|   getV1SchemaVariables, |   getV1SchemaVariables, | ||||||
|   getV2SchemaVariables, |   getV2SchemaVariables, | ||||||
| } from 'app/features/dashboard/utils/tracking'; | } from 'app/features/dashboard/utils/tracking'; | ||||||
|  | import { DashboardJson } from 'app/features/manage-dashboards/types'; | ||||||
| import { DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; | import { DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; | ||||||
| 
 | 
 | ||||||
| import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges'; | import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges'; | ||||||
| import { DashboardChangeInfo } from '../saving/shared'; | import { DashboardChangeInfo } from '../saving/shared'; | ||||||
| import { DashboardScene } from '../scene/DashboardScene'; | import { DashboardScene } from '../scene/DashboardScene'; | ||||||
|  | import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters'; | ||||||
|  | import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; | ||||||
| import { getVizPanelKeyForPanelId } from '../utils/utils'; | import { getVizPanelKeyForPanelId } from '../utils/utils'; | ||||||
| 
 | 
 | ||||||
| import { transformSceneToSaveModel } from './transformSceneToSaveModel'; | import { transformSceneToSaveModel } from './transformSceneToSaveModel'; | ||||||
|  | @ -25,7 +29,7 @@ import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSc | ||||||
|  * M is the type of the metadata |  * M is the type of the metadata | ||||||
|  * I is the type of the initial save model. By default it's the same as T. |  * I is the type of the initial save model. By default it's the same as T. | ||||||
|  */ |  */ | ||||||
| export interface DashboardSceneSerializerLike<T, M, I = T> { | export interface DashboardSceneSerializerLike<T, M, I = T, E = T | { error: unknown }> { | ||||||
|   /** |   /** | ||||||
|    * The save model which the dashboard scene was originally created from |    * The save model which the dashboard scene was originally created from | ||||||
|    */ |    */ | ||||||
|  | @ -50,6 +54,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T> { | ||||||
|   getElementIdForPanel: (panelId: number) => string | undefined; |   getElementIdForPanel: (panelId: number) => string | undefined; | ||||||
|   getElementPanelMapping: () => Map<string, number>; |   getElementPanelMapping: () => Map<string, number>; | ||||||
|   getDSReferencesMapping: () => DSReferencesMapping; |   getDSReferencesMapping: () => DSReferencesMapping; | ||||||
|  |   makeExportableExternally: (s: DashboardScene) => Promise<E | { error: unknown }>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface DashboardTrackingInfo { | interface DashboardTrackingInfo { | ||||||
|  | @ -67,7 +72,9 @@ export interface DSReferencesMapping { | ||||||
|   annotations: Set<string>; |   annotations: Set<string>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashboard, DashboardMeta> { | export class V1DashboardSerializer | ||||||
|  |   implements DashboardSceneSerializerLike<Dashboard, DashboardMeta, Dashboard, DashboardJson> | ||||||
|  | { | ||||||
|   initialSaveModel?: Dashboard; |   initialSaveModel?: Dashboard; | ||||||
|   metadata?: DashboardMeta; |   metadata?: DashboardMeta; | ||||||
|   protected elementPanelMap = new Map<string, number>(); |   protected elementPanelMap = new Map<string, number>(); | ||||||
|  | @ -195,6 +202,16 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb | ||||||
|   getSnapshotUrl() { |   getSnapshotUrl() { | ||||||
|     return this.initialSaveModel?.snapshot?.originalUrl; |     return this.initialSaveModel?.snapshot?.originalUrl; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   async makeExportableExternally(s: DashboardScene) { | ||||||
|  |     const saveModel = this.getSaveModel(s); | ||||||
|  |     const oldModel = new DashboardModel(saveModel, undefined, { | ||||||
|  |       getVariablesFromState: () => { | ||||||
|  |         return getVariablesCompatibility(window.__grafanaSceneContext); | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     return await makeExportableV1(oldModel); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export class V2DashboardSerializer | export class V2DashboardSerializer | ||||||
|  | @ -390,18 +407,36 @@ export class V2DashboardSerializer | ||||||
|   getSnapshotUrl() { |   getSnapshotUrl() { | ||||||
|     return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl]; |     return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl]; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   async makeExportableExternally(s: DashboardScene) { | ||||||
|  |     return await makeExportableV2(this.getSaveModel(s)); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard, DashboardMeta>; | export function getDashboardSceneSerializer(): DashboardSceneSerializerLike< | ||||||
| export function getDashboardSceneSerializer(version: 'v1'): DashboardSceneSerializerLike<Dashboard, DashboardMeta>; |   Dashboard, | ||||||
|  |   DashboardMeta, | ||||||
|  |   Dashboard, | ||||||
|  |   DashboardJson | ||||||
|  | >; | ||||||
|  | export function getDashboardSceneSerializer( | ||||||
|  |   version: 'v1' | ||||||
|  | ): DashboardSceneSerializerLike<Dashboard, DashboardMeta, Dashboard, DashboardJson>; | ||||||
| export function getDashboardSceneSerializer( | export function getDashboardSceneSerializer( | ||||||
|   version: 'v2' |   version: 'v2' | ||||||
| ): DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>; | ): DashboardSceneSerializerLike< | ||||||
|  |   DashboardV2Spec, | ||||||
|  |   DashboardWithAccessInfo<DashboardV2Spec>['metadata'], | ||||||
|  |   DashboardV2Spec, | ||||||
|  |   DashboardV2Spec | ||||||
|  | >; | ||||||
| export function getDashboardSceneSerializer( | export function getDashboardSceneSerializer( | ||||||
|   version?: 'v1' | 'v2' |   version?: 'v1' | 'v2' | ||||||
| ): DashboardSceneSerializerLike< | ): DashboardSceneSerializerLike< | ||||||
|   Dashboard | DashboardV2Spec, |   Dashboard | DashboardV2Spec, | ||||||
|   DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'] |   DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'], | ||||||
|  |   Dashboard | DashboardV2Spec, | ||||||
|  |   DashboardJson | DashboardV2Spec | ||||||
| > { | > { | ||||||
|   if (version === 'v2') { |   if (version === 'v2') { | ||||||
|     return new V2DashboardSerializer(); |     return new V2DashboardSerializer(); | ||||||
|  |  | ||||||
|  | @ -97,7 +97,17 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model | ||||||
|         }, |         }, | ||||||
|         "description": "Test Description 2", |         "description": "Test Description 2", | ||||||
|         "id": 2, |         "id": 2, | ||||||
|         "links": [], |         "links": [ | ||||||
|  |           { | ||||||
|  |             "targetBlank": true, | ||||||
|  |             "title": "Test Link 1", | ||||||
|  |             "url": "http://test1.com", | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "title": "Test Link 2", | ||||||
|  |             "url": "http://test2.com", | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|         "title": "Test Panel 2", |         "title": "Test Panel 2", | ||||||
|         "vizConfig": { |         "vizConfig": { | ||||||
|           "kind": "graph", |           "kind": "graph", | ||||||
|  |  | ||||||
|  | @ -72,6 +72,15 @@ jest.mock('../utils/dashboardSceneGraph', () => { | ||||||
|         // Return the panel key if it exists, otherwise use panel-1 as default
 |         // Return the panel key if it exists, otherwise use panel-1 as default
 | ||||||
|         return panel?.state?.key || 'panel-1'; |         return panel?.state?.key || 'panel-1'; | ||||||
|       }), |       }), | ||||||
|  |       getPanelLinks: jest.fn().mockImplementation(() => { | ||||||
|  |         return new VizPanelLinks({ | ||||||
|  |           rawLinks: [ | ||||||
|  |             { title: 'Test Link 1', url: 'http://test1.com', targetBlank: true }, | ||||||
|  |             { title: 'Test Link 2', url: 'http://test2.com' }, | ||||||
|  |           ], | ||||||
|  |           menu: new VizPanelLinksMenu({}), | ||||||
|  |         }); | ||||||
|  |       }), | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -5,7 +5,18 @@ import AutoSizer from 'react-virtualized-auto-sizer'; | ||||||
| import { GrafanaTheme2 } from '@grafana/data'; | import { GrafanaTheme2 } from '@grafana/data'; | ||||||
| import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; | import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; | ||||||
| import { SceneComponentProps } from '@grafana/scenes'; | import { SceneComponentProps } from '@grafana/scenes'; | ||||||
| import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; | import { | ||||||
|  |   Alert, | ||||||
|  |   Button, | ||||||
|  |   ClipboardButton, | ||||||
|  |   CodeEditor, | ||||||
|  |   Label, | ||||||
|  |   Spinner, | ||||||
|  |   Stack, | ||||||
|  |   Switch, | ||||||
|  |   TextLink, | ||||||
|  |   useStyles2, | ||||||
|  | } from '@grafana/ui'; | ||||||
| import { notifyApp } from 'app/core/actions'; | import { notifyApp } from 'app/core/actions'; | ||||||
| import { createSuccessNotification } from 'app/core/copy/appNotification'; | import { createSuccessNotification } from 'app/core/copy/appNotification'; | ||||||
| import { t, Trans } from 'app/core/internationalization'; | import { t, Trans } from 'app/core/internationalization'; | ||||||
|  | @ -26,21 +37,25 @@ export class ExportAsJson extends ShareExportTab { | ||||||
| 
 | 
 | ||||||
| function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) { | function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) { | ||||||
|   const styles = useStyles2(getStyles); |   const styles = useStyles2(getStyles); | ||||||
| 
 |  | ||||||
|   const { isSharingExternally } = model.useState(); |   const { isSharingExternally } = model.useState(); | ||||||
| 
 | 
 | ||||||
|   const dashboardJson = useAsync(async () => { |   const dashboardJson = useAsync(async () => { | ||||||
|     const json = await model.getExportableDashboardJson(); |     const json = await model.getExportableDashboardJson(); | ||||||
|     return JSON.stringify(json, null, 2); |     return json; | ||||||
|   }, [isSharingExternally]); |   }, [isSharingExternally]); | ||||||
| 
 | 
 | ||||||
|  |   const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2); | ||||||
|  |   const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels; | ||||||
|  |   const isV2Dashboard = dashboardJson.value?.json && 'elements' in dashboardJson.value.json; | ||||||
|  |   const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels; | ||||||
|  | 
 | ||||||
|   const onClickDownload = async () => { |   const onClickDownload = async () => { | ||||||
|     await model.onSaveAsFile(); |     await model.onSaveAsFile(); | ||||||
|     const message = t('export.json.download-successful_toast_message', 'Your JSON has been downloaded'); |     const message = t('export.json.download-successful_toast_message', 'Your JSON has been downloaded'); | ||||||
|     dispatch(notifyApp(createSuccessNotification(message))); |     dispatch(notifyApp(createSuccessNotification(message))); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const switchLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance'); |   const switchExportLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance'); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div data-testid={selector.container} className={styles.container}> |     <div data-testid={selector.container} className={styles.container}> | ||||||
|  | @ -49,24 +64,48 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) { | ||||||
|           Copy or download a JSON file containing the JSON of your dashboard |           Copy or download a JSON file containing the JSON of your dashboard | ||||||
|         </Trans> |         </Trans> | ||||||
|       </p> |       </p> | ||||||
|       <Stack gap={1} alignItems="center"> |       <Stack gap={2} direction="column"> | ||||||
|         <Switch |         <Stack gap={1}> | ||||||
|           label={switchLabel} |           <Switch | ||||||
|           data-testid={selector.exportExternallyToggle} |             label={switchExportLabel} | ||||||
|           id="export-externally-toggle" |             data-testid={selector.exportExternallyToggle} | ||||||
|           value={isSharingExternally} |             id="export-externally-toggle" | ||||||
|           onChange={model.onShareExternallyChange} |             value={isSharingExternally} | ||||||
|         /> |             onChange={model.onShareExternallyChange} | ||||||
|         <Label>{switchLabel}</Label> |           /> | ||||||
|  |           <Label>{switchExportLabel}</Label> | ||||||
|  |         </Stack> | ||||||
|  | 
 | ||||||
|  |         {showV2LibPanelAlert && ( | ||||||
|  |           <Alert | ||||||
|  |             title={t( | ||||||
|  |               'dashboard-scene.save-dashboard-form.schema-v2-library-panels-export-title', | ||||||
|  |               'Dashboard Schema V2 does not support exporting library panels to be used in another instance yet' | ||||||
|  |             )} | ||||||
|  |             severity="warning" | ||||||
|  |           > | ||||||
|  |             <Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export"> | ||||||
|  |               The dynamic dashboard functionality is experimental, and has not full feature parity with current | ||||||
|  |               dashboards behaviour. It is based on a new schema format, that does not support library panels. This means | ||||||
|  |               that when exporting the dashboard to use it in another instance, we will not include library panels. We | ||||||
|  |               intend to support them as we progress in the feature{' '} | ||||||
|  |               <TextLink external href="https://grafana.com/docs/release-life-cycle/"> | ||||||
|  |                 life cycle | ||||||
|  |               </TextLink> | ||||||
|  |               . | ||||||
|  |             </Trans> | ||||||
|  |           </Alert> | ||||||
|  |         )} | ||||||
|       </Stack> |       </Stack> | ||||||
|       <div className={styles.codeEditorBox}> |       <div className={styles.codeEditorBox}> | ||||||
|         <AutoSizer data-testid={selector.codeEditor}> |         <AutoSizer data-testid={selector.codeEditor}> | ||||||
|           {({ width, height }) => { |           {({ width, height }) => { | ||||||
|             if (dashboardJson.value) { |             if (stringifiedDashboardJson) { | ||||||
|               return ( |               return ( | ||||||
|                 <CodeEditor |                 <CodeEditor | ||||||
|                   value={dashboardJson.value} |                   value={stringifiedDashboardJson} | ||||||
|                   language="json" |                   language="json" | ||||||
|  |                   showLineNumbers={true} | ||||||
|                   showMiniMap={false} |                   showMiniMap={false} | ||||||
|                   height={height} |                   height={height} | ||||||
|                   width={width} |                   width={width} | ||||||
|  | @ -94,7 +133,7 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) { | ||||||
|             variant="secondary" |             variant="secondary" | ||||||
|             icon="copy" |             icon="copy" | ||||||
|             disabled={dashboardJson.loading} |             disabled={dashboardJson.loading} | ||||||
|             getText={() => dashboardJson.value ?? ''} |             getText={() => stringifiedDashboardJson ?? ''} | ||||||
|             onClipboardCopy={() => { |             onClipboardCopy={() => { | ||||||
|               DashboardInteractions.exportCopyJsonClicked(); |               DashboardInteractions.exportCopyJsonClicked(); | ||||||
|             }} |             }} | ||||||
|  |  | ||||||
|  | @ -3,14 +3,14 @@ import { useAsync } from 'react-use'; | ||||||
| import AutoSizer from 'react-virtualized-auto-sizer'; | import AutoSizer from 'react-virtualized-auto-sizer'; | ||||||
| 
 | 
 | ||||||
| import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; | import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; | ||||||
| import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui'; | import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; | ||||||
|  | import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; | ||||||
|  | import { Alert, Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch, TextLink } from '@grafana/ui'; | ||||||
| import { t, Trans } from 'app/core/internationalization'; | import { t, Trans } from 'app/core/internationalization'; | ||||||
| import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; | import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; | ||||||
| import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; | import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; | ||||||
| import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; | import { DashboardJson } from 'app/features/manage-dashboards/types'; | ||||||
| 
 | 
 | ||||||
| import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; |  | ||||||
| import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; |  | ||||||
| import { DashboardInteractions } from '../utils/interactions'; | import { DashboardInteractions } from '../utils/interactions'; | ||||||
| import { getDashboardSceneFor } from '../utils/utils'; | import { getDashboardSceneFor } from '../utils/utils'; | ||||||
| 
 | 
 | ||||||
|  | @ -25,8 +25,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme | ||||||
|   public tabId = shareDashboardType.export; |   public tabId = shareDashboardType.export; | ||||||
|   static Component = ShareExportTabRenderer; |   static Component = ShareExportTabRenderer; | ||||||
| 
 | 
 | ||||||
|   private _exporter = new DashboardExporter(); |  | ||||||
| 
 |  | ||||||
|   constructor(state: Omit<ShareExportTabState, 'panelRef'>) { |   constructor(state: Omit<ShareExportTabState, 'panelRef'>) { | ||||||
|     super({ |     super({ | ||||||
|       isSharingExternally: false, |       isSharingExternally: false, | ||||||
|  | @ -55,26 +53,33 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public getExportableDashboardJson = async () => { |   public getExportableDashboardJson = async (): Promise<{ | ||||||
|  |     json: Dashboard | DashboardJson | DashboardV2Spec | { error: unknown }; | ||||||
|  |     hasLibraryPanels?: boolean; | ||||||
|  |   }> => { | ||||||
|     const { isSharingExternally } = this.state; |     const { isSharingExternally } = this.state; | ||||||
|     const saveModel = transformSceneToSaveModel(getDashboardSceneFor(this)); |  | ||||||
| 
 | 
 | ||||||
|     const exportable = isSharingExternally |     const scene = getDashboardSceneFor(this); | ||||||
|       ? await this._exporter.makeExportable( |     const exportableDashboard = await scene.serializer.makeExportableExternally(scene); | ||||||
|           new DashboardModel(saveModel, undefined, { |     const origDashboard = scene.serializer.getSaveModel(scene); | ||||||
|             getVariablesFromState: () => { |     const exportable = isSharingExternally ? exportableDashboard : origDashboard; | ||||||
|               return getVariablesCompatibility(window.__grafanaSceneContext); |  | ||||||
|             }, |  | ||||||
|           }) |  | ||||||
|         ) |  | ||||||
|       : saveModel; |  | ||||||
| 
 | 
 | ||||||
|     return exportable; |     if (isDashboardV2Spec(origDashboard)) { | ||||||
|  |       return { | ||||||
|  |         json: exportable, | ||||||
|  |         hasLibraryPanels: Object.values(origDashboard.elements).some((element) => element.kind === 'LibraryPanel'), | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |       json: exportable, | ||||||
|  |       hasLibraryPanels: undefined, | ||||||
|  |     }; | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   public onSaveAsFile = async () => { |   public onSaveAsFile = async () => { | ||||||
|     const dashboardJson = await this.getExportableDashboardJson(); |     const dashboard = await this.getExportableDashboardJson(); | ||||||
|     const dashboardJsonPretty = JSON.stringify(dashboardJson, null, 2); |     const dashboardJsonPretty = JSON.stringify(dashboard.json, null, 2); | ||||||
|     const { isSharingExternally } = this.state; |     const { isSharingExternally } = this.state; | ||||||
| 
 | 
 | ||||||
|     const blob = new Blob([dashboardJsonPretty], { |     const blob = new Blob([dashboardJsonPretty], { | ||||||
|  | @ -83,8 +88,8 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme | ||||||
| 
 | 
 | ||||||
|     const time = new Date().getTime(); |     const time = new Date().getTime(); | ||||||
|     let title = 'dashboard'; |     let title = 'dashboard'; | ||||||
|     if ('title' in dashboardJson && dashboardJson.title) { |     if ('title' in dashboard.json && dashboard.json.title) { | ||||||
|       title = dashboardJson.title; |       title = dashboard.json.title; | ||||||
|     } |     } | ||||||
|     saveAs(blob, `${title}-${time}.json`); |     saveAs(blob, `${title}-${time}.json`); | ||||||
|     DashboardInteractions.exportDownloadJsonClicked({ |     DashboardInteractions.exportDownloadJsonClicked({ | ||||||
|  | @ -95,14 +100,17 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme | ||||||
| 
 | 
 | ||||||
| function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) { | function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) { | ||||||
|   const { isSharingExternally, isViewingJSON, modalRef } = model.useState(); |   const { isSharingExternally, isViewingJSON, modalRef } = model.useState(); | ||||||
|   const dashboardJson = useAsync(async () => { |  | ||||||
|     if (isViewingJSON) { |  | ||||||
|       const json = await model.getExportableDashboardJson(); |  | ||||||
|       return JSON.stringify(json, null, 2); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     return ''; |   const dashboardJson = useAsync(async () => { | ||||||
|   }, [isViewingJSON]); |     const json = await model.getExportableDashboardJson(); | ||||||
|  |     return json; | ||||||
|  |   }, [isViewingJSON, isSharingExternally]); | ||||||
|  | 
 | ||||||
|  |   const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2); | ||||||
|  |   const hasLibraryPanels = dashboardJson.value?.hasLibraryPanels; | ||||||
|  | 
 | ||||||
|  |   const isV2Dashboard = dashboardJson.value?.json && 'elements' in dashboardJson.value.json; | ||||||
|  |   const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels; | ||||||
| 
 | 
 | ||||||
|   const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`); |   const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`); | ||||||
| 
 | 
 | ||||||
|  | @ -121,6 +129,26 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) | ||||||
|                 onChange={model.onShareExternallyChange} |                 onChange={model.onShareExternallyChange} | ||||||
|               /> |               /> | ||||||
|             </Field> |             </Field> | ||||||
|  |             {showV2LibPanelAlert && ( | ||||||
|  |               <Alert | ||||||
|  |                 title={t( | ||||||
|  |                   'dashboard-scene.save-dashboard-form.schema-v2-library-panels-export-title', | ||||||
|  |                   'Dashboard Schema V2 does not support exporting library panels to be used in another instance yet' | ||||||
|  |                 )} | ||||||
|  |                 severity="warning" | ||||||
|  |               > | ||||||
|  |                 <Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export"> | ||||||
|  |                   The dynamic dashboard functionality is experimental, and has not full feature parity with current | ||||||
|  |                   dashboards behaviour. It is based on a new schema format, that does not support library panels. This | ||||||
|  |                   means that when exporting the dashboard to use it in another instance, we will not include library | ||||||
|  |                   panels. We intend to support them as we progress in the feature{' '} | ||||||
|  |                   <TextLink external href="https://grafana.com/docs/release-life-cycle/"> | ||||||
|  |                     life cycle | ||||||
|  |                   </TextLink> | ||||||
|  |                   . | ||||||
|  |                 </Trans> | ||||||
|  |               </Alert> | ||||||
|  |             )} | ||||||
|           </Stack> |           </Stack> | ||||||
| 
 | 
 | ||||||
|           <Modal.ButtonRow> |           <Modal.ButtonRow> | ||||||
|  | @ -149,7 +177,8 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) | ||||||
|               if (dashboardJson.value) { |               if (dashboardJson.value) { | ||||||
|                 return ( |                 return ( | ||||||
|                   <CodeEditor |                   <CodeEditor | ||||||
|                     value={dashboardJson.value ?? ''} |                     value={stringifiedDashboardJson} | ||||||
|  |                     showLineNumbers={true} | ||||||
|                     language="json" |                     language="json" | ||||||
|                     showMiniMap={false} |                     showMiniMap={false} | ||||||
|                     height="500px" |                     height="500px" | ||||||
|  | @ -179,7 +208,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) | ||||||
|               variant="secondary" |               variant="secondary" | ||||||
|               icon="copy" |               icon="copy" | ||||||
|               disabled={dashboardJson.loading} |               disabled={dashboardJson.loading} | ||||||
|               getText={() => dashboardJson.value ?? ''} |               getText={() => stringifiedDashboardJson ?? ''} | ||||||
|             > |             > | ||||||
|               <Trans i18nKey="share-modal.view-json.copy-button">Copy to Clipboard</Trans> |               <Trans i18nKey="share-modal.view-json.copy-button">Copy to Clipboard</Trans> | ||||||
|             </ClipboardButton> |             </ClipboardButton> | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import { Button, Field, Modal, Switch } from '@grafana/ui'; | ||||||
| import { appEvents } from 'app/core/core'; | import { appEvents } from 'app/core/core'; | ||||||
| import { t, Trans } from 'app/core/internationalization'; | import { t, Trans } from 'app/core/internationalization'; | ||||||
| import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; | import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; | ||||||
|  | import { makeExportableV1 } from 'app/features/dashboard-scene/scene/export/exporters'; | ||||||
| import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; | import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; | ||||||
| import { ShowModalReactEvent } from 'app/types/events'; | import { ShowModalReactEvent } from 'app/types/events'; | ||||||
| 
 | 
 | ||||||
|  | @ -46,7 +47,7 @@ export class ShareExport extends PureComponent<Props, State> { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     if (shareExternally) { |     if (shareExternally) { | ||||||
|       this.exporter.makeExportable(dashboard).then((dashboardJson) => { |       makeExportableV1(dashboard).then((dashboardJson) => { | ||||||
|         this.openSaveAsDialog(dashboardJson); |         this.openSaveAsDialog(dashboardJson); | ||||||
|       }); |       }); | ||||||
|     } else { |     } else { | ||||||
|  |  | ||||||
|  | @ -60,7 +60,6 @@ export const AddLibraryPanelContents = ({ | ||||||
|     } |     } | ||||||
|   }, [debouncedPanelName, folderUid]); |   }, [debouncedPanelName, folderUid]); | ||||||
| 
 | 
 | ||||||
|   console.log('isValidName:', isValidName); |  | ||||||
|   const invalidInput = |   const invalidInput = | ||||||
|     !isValidName?.value && isValidName.value !== undefined && panelName === debouncedPanelName && !waiting; |     !isValidName?.value && isValidName.value !== undefined && panelName === debouncedPanelName && !waiting; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3618,6 +3618,8 @@ | ||||||
|         "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", |         "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", | ||||||
|         "would-still-dashboard": "Would you still like to save this dashboard?" |         "would-still-dashboard": "Would you still like to save this dashboard?" | ||||||
|       }, |       }, | ||||||
|  |       "schema-v2-library-panels-export": "The dynamic dashboard functionality is experimental, and has not full feature parity with current dashboards behaviour. It is based on a new schema format, that does not support library panels. This means that when exporting the dashboard to use it in another instance, we will not include library panels. We intend to support them as we progress in the feature <2>life cycle</2>.", | ||||||
|  |       "schema-v2-library-panels-export-title": "Dashboard Schema V2 does not support exporting library panels to be used in another instance yet", | ||||||
|       "title-dashboard-drastically-changed": "Dashboard drastically changed" |       "title-dashboard-drastically-changed": "Dashboard drastically changed" | ||||||
|     }, |     }, | ||||||
|     "save-dashboard-form-common-options": { |     "save-dashboard-form-common-options": { | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue