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:
Haris Rozajac 2025-04-11 13:35:39 -06:00 committed by GitHub
parent bba85c1128
commit 5011907dae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1367 additions and 58 deletions

View File

@ -1569,6 +1569,27 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [
[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": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -29,6 +29,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
@ -181,7 +182,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
public serializer: DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
Dashboard | DashboardV2Spec,
DashboardJson | DashboardV2Spec
>;
private _layoutRestorer = new LayoutRestorer();

View File

@ -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;

View File

@ -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,
};
}
}

View File

@ -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;
}
}

View File

@ -5,16 +5,20 @@ import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import {
getPanelPluginCounts,
getV1SchemaVariables,
getV2SchemaVariables,
} from 'app/features/dashboard/utils/tracking';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges';
import { DashboardChangeInfo } from '../saving/shared';
import { DashboardScene } from '../scene/DashboardScene';
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
import { getVizPanelKeyForPanelId } from '../utils/utils';
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
@ -25,7 +29,7 @@ import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSc
* M is the type of the metadata
* 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
*/
@ -50,6 +54,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T> {
getElementIdForPanel: (panelId: number) => string | undefined;
getElementPanelMapping: () => Map<string, number>;
getDSReferencesMapping: () => DSReferencesMapping;
makeExportableExternally: (s: DashboardScene) => Promise<E | { error: unknown }>;
}
interface DashboardTrackingInfo {
@ -67,7 +72,9 @@ export interface DSReferencesMapping {
annotations: Set<string>;
}
export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashboard, DashboardMeta> {
export class V1DashboardSerializer
implements DashboardSceneSerializerLike<Dashboard, DashboardMeta, Dashboard, DashboardJson>
{
initialSaveModel?: Dashboard;
metadata?: DashboardMeta;
protected elementPanelMap = new Map<string, number>();
@ -195,6 +202,16 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
getSnapshotUrl() {
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
@ -390,18 +407,36 @@ export class V2DashboardSerializer
getSnapshotUrl() {
return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl];
}
async makeExportableExternally(s: DashboardScene) {
return await makeExportableV2(this.getSaveModel(s));
}
}
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
export function getDashboardSceneSerializer(version: 'v1'): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<
Dashboard,
DashboardMeta,
Dashboard,
DashboardJson
>;
export function getDashboardSceneSerializer(
version: 'v1'
): DashboardSceneSerializerLike<Dashboard, DashboardMeta, Dashboard, DashboardJson>;
export function getDashboardSceneSerializer(
version: 'v2'
): DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>;
): DashboardSceneSerializerLike<
DashboardV2Spec,
DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
DashboardV2Spec,
DashboardV2Spec
>;
export function getDashboardSceneSerializer(
version?: 'v1' | 'v2'
): DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
Dashboard | DashboardV2Spec,
DashboardJson | DashboardV2Spec
> {
if (version === 'v2') {
return new V2DashboardSerializer();

View File

@ -97,7 +97,17 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
},
"description": "Test Description 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",
"vizConfig": {
"kind": "graph",

View File

@ -72,6 +72,15 @@ jest.mock('../utils/dashboardSceneGraph', () => {
// Return the panel key if it exists, otherwise use panel-1 as default
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({}),
});
}),
},
};
});

View File

@ -5,7 +5,18 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
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 { createSuccessNotification } from 'app/core/copy/appNotification';
import { t, Trans } from 'app/core/internationalization';
@ -26,21 +37,25 @@ export class ExportAsJson extends ShareExportTab {
function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) {
const styles = useStyles2(getStyles);
const { isSharingExternally } = model.useState();
const dashboardJson = useAsync(async () => {
const json = await model.getExportableDashboardJson();
return JSON.stringify(json, null, 2);
return json;
}, [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 () => {
await model.onSaveAsFile();
const message = t('export.json.download-successful_toast_message', 'Your JSON has been downloaded');
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 (
<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
</Trans>
</p>
<Stack gap={1} alignItems="center">
<Switch
label={switchLabel}
data-testid={selector.exportExternallyToggle}
id="export-externally-toggle"
value={isSharingExternally}
onChange={model.onShareExternallyChange}
/>
<Label>{switchLabel}</Label>
<Stack gap={2} direction="column">
<Stack gap={1}>
<Switch
label={switchExportLabel}
data-testid={selector.exportExternallyToggle}
id="export-externally-toggle"
value={isSharingExternally}
onChange={model.onShareExternallyChange}
/>
<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>
<div className={styles.codeEditorBox}>
<AutoSizer data-testid={selector.codeEditor}>
{({ width, height }) => {
if (dashboardJson.value) {
if (stringifiedDashboardJson) {
return (
<CodeEditor
value={dashboardJson.value}
value={stringifiedDashboardJson}
language="json"
showLineNumbers={true}
showMiniMap={false}
height={height}
width={width}
@ -94,7 +133,7 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps<ExportAsJson>) {
variant="secondary"
icon="copy"
disabled={dashboardJson.loading}
getText={() => dashboardJson.value ?? ''}
getText={() => stringifiedDashboardJson ?? ''}
onClipboardCopy={() => {
DashboardInteractions.exportCopyJsonClicked();
}}

View File

@ -3,14 +3,14 @@ import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
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 { 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 { 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 { getDashboardSceneFor } from '../utils/utils';
@ -25,8 +25,6 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
public tabId = shareDashboardType.export;
static Component = ShareExportTabRenderer;
private _exporter = new DashboardExporter();
constructor(state: Omit<ShareExportTabState, 'panelRef'>) {
super({
isSharingExternally: false,
@ -55,26 +53,33 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
return;
}
public getExportableDashboardJson = async () => {
public getExportableDashboardJson = async (): Promise<{
json: Dashboard | DashboardJson | DashboardV2Spec | { error: unknown };
hasLibraryPanels?: boolean;
}> => {
const { isSharingExternally } = this.state;
const saveModel = transformSceneToSaveModel(getDashboardSceneFor(this));
const exportable = isSharingExternally
? await this._exporter.makeExportable(
new DashboardModel(saveModel, undefined, {
getVariablesFromState: () => {
return getVariablesCompatibility(window.__grafanaSceneContext);
},
})
)
: saveModel;
const scene = getDashboardSceneFor(this);
const exportableDashboard = await scene.serializer.makeExportableExternally(scene);
const origDashboard = scene.serializer.getSaveModel(scene);
const exportable = isSharingExternally ? exportableDashboard : origDashboard;
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 () => {
const dashboardJson = await this.getExportableDashboardJson();
const dashboardJsonPretty = JSON.stringify(dashboardJson, null, 2);
const dashboard = await this.getExportableDashboardJson();
const dashboardJsonPretty = JSON.stringify(dashboard.json, null, 2);
const { isSharingExternally } = this.state;
const blob = new Blob([dashboardJsonPretty], {
@ -83,8 +88,8 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
const time = new Date().getTime();
let title = 'dashboard';
if ('title' in dashboardJson && dashboardJson.title) {
title = dashboardJson.title;
if ('title' in dashboard.json && dashboard.json.title) {
title = dashboard.json.title;
}
saveAs(blob, `${title}-${time}.json`);
DashboardInteractions.exportDownloadJsonClicked({
@ -95,14 +100,17 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) {
const { isSharingExternally, isViewingJSON, modalRef } = model.useState();
const dashboardJson = useAsync(async () => {
if (isViewingJSON) {
const json = await model.getExportableDashboardJson();
return JSON.stringify(json, null, 2);
}
return '';
}, [isViewingJSON]);
const dashboardJson = useAsync(async () => {
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`);
@ -121,6 +129,26 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
onChange={model.onShareExternallyChange}
/>
</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>
<Modal.ButtonRow>
@ -149,7 +177,8 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
if (dashboardJson.value) {
return (
<CodeEditor
value={dashboardJson.value ?? ''}
value={stringifiedDashboardJson}
showLineNumbers={true}
language="json"
showMiniMap={false}
height="500px"
@ -179,7 +208,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
variant="secondary"
icon="copy"
disabled={dashboardJson.loading}
getText={() => dashboardJson.value ?? ''}
getText={() => stringifiedDashboardJson ?? ''}
>
<Trans i18nKey="share-modal.view-json.copy-button">Copy to Clipboard</Trans>
</ClipboardButton>

View File

@ -5,6 +5,7 @@ import { Button, Field, Modal, Switch } from '@grafana/ui';
import { appEvents } from 'app/core/core';
import { t, Trans } from 'app/core/internationalization';
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 { ShowModalReactEvent } from 'app/types/events';
@ -46,7 +47,7 @@ export class ShareExport extends PureComponent<Props, State> {
});
if (shareExternally) {
this.exporter.makeExportable(dashboard).then((dashboardJson) => {
makeExportableV1(dashboard).then((dashboardJson) => {
this.openSaveAsDialog(dashboardJson);
});
} else {

View File

@ -60,7 +60,6 @@ export const AddLibraryPanelContents = ({
}
}, [debouncedPanelName, folderUid]);
console.log('isValidName:', isValidName);
const invalidInput =
!isValidName?.value && isValidName.value !== undefined && panelName === debouncedPanelName && !waiting;

View File

@ -3618,6 +3618,8 @@
"title-someone-else-has-updated-this-dashboard": "Someone else has updated 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"
},
"save-dashboard-form-common-options": {