The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.te...

1323 lines
41 KiB

import { config } from '@grafana/runtime';
import {
AdHocFiltersVariable,
GroupByVariable,
MultiValueVariable,
sceneGraph,
SceneRefreshPicker,
} from '@grafana/scenes';
import { Dashboard, VariableModel } from '@grafana/schema';
import {
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
defaultPanelSpec,
defaultTimeSettingsSpec,
GridLayoutKind,
PanelSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { findVizPanelByKey } from '../utils/utils';
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
import { getPanelElement, transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
return {
getInstanceSettings: jest.fn(),
};
},
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: {
settings: {
defaultDatasource: '-- Grafana --',
datasources: {
'-- Grafana --': {
name: 'Grafana',
meta: { id: 'grafana' },
type: 'datasource',
},
prometheus: {
name: 'prometheus',
meta: { id: 'prometheus' },
type: 'datasource',
},
},
},
},
},
}));
describe('DashboardSceneSerializer', () => {
describe('v1 schema', () => {
it('Can detect no changes', () => {
const dashboard = setup();
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can detect time changed', () => {
const dashboard = setup();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasTimeChanges).toBe(true);
});
it('Can save time change', () => {
const dashboard = setup();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
const result = dashboard.getDashboardChanges(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect folder change', () => {
const dashboard = setup();
dashboard.state.meta.folderUid = 'folder-2';
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
expect(result.hasFolderChanges).toBe(true);
});
it('Can detect refresh changed', () => {
const dashboard = setup();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '5s' });
}
const result = dashboard.getDashboardChanges(false, false, false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasRefreshChange).toBe(true);
});
it('Can save refresh change', () => {
const dashboard = setup();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '5s' });
}
const result = dashboard.getDashboardChanges(false, false, true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
describe('variable changes', () => {
it('Can detect variable change', () => {
const dashboard = setup();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can save variable value change', () => {
const dashboard = setup();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(2);
});
describe('Experimental variables', () => {
beforeAll(() => {
config.featureToggles.groupByVariable = true;
});
afterAll(() => {
config.featureToggles.groupByVariable = false;
});
it('Can detect group by static options change', () => {
const dashboard = transformSaveModelToScene({
dashboard: {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{
id: 1,
title: 'Panel 1',
type: 'text',
},
],
version: 10,
templating: {
list: [
{
type: 'groupby',
datasource: {
type: 'ds',
uid: 'ds-uid',
},
name: 'GroupBy',
options: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
},
],
},
},
meta: {},
});
const initialSaveModel = transformSceneToSaveModel(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect adhoc filter static options change', () => {
const adhocVar = {
id: 'adhoc',
name: 'adhoc',
label: 'Adhoc Label',
description: 'Adhoc Description',
type: 'adhoc',
datasource: {
uid: 'gdev-prometheus',
type: 'prometheus',
},
filters: [],
baseFilters: [],
defaultKeys: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
} as VariableModel;
const dashboard = transformSaveModelToScene({
dashboard: {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{
id: 1,
title: 'Panel 1',
type: 'text',
},
],
version: 10,
templating: {
list: [adhocVar],
},
},
meta: {},
});
const initialSaveModel = transformSceneToSaveModel(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
});
});
describe('Saving from panel edit', () => {
it('Should commit panel edit changes', () => {
const dashboard = setup();
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
const editScene = buildPanelEditScene(panel);
dashboard.onEnterEditMode();
dashboard.setState({ editPanel: editScene });
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
const result = dashboard.getDashboardChanges(false, true);
const panelSaveModel = (result.changedSaveModel as Dashboard).panels![0];
expect(panelSaveModel.title).toBe('changed title');
});
});
describe('tracking information', () => {
it('provides dashboard tracking information with no initial save model', () => {
const serializer = new V1DashboardSerializer();
expect(serializer.getTrackingInformation()).toBe(undefined);
});
it('provides dashboard tracking information with from initial save model', () => {
const dashboard = setup({
schemaVersion: 30,
version: 10,
uid: 'my-uid',
title: 'hello',
liveNow: true,
panels: [
{
type: 'text',
},
{
type: 'text',
},
{
type: 'timeseries',
},
],
templating: {
list: [
{
type: 'query',
name: 'server',
},
{
type: 'query',
name: 'host',
},
{
type: 'textbox',
name: 'search',
},
],
},
});
expect(dashboard.getTrackingInformation()).toEqual({
uid: 'my-uid',
title: 'hello',
schemaVersion: DASHBOARD_SCHEMA_VERSION,
panels_count: 3,
panel_type_text_count: 2,
panel_type_timeseries_count: 1,
variable_type_query_count: 2,
variable_type_textbox_count: 1,
settings_nowdelay: undefined,
settings_livenow: true,
});
});
});
it('should allow retrieving snapshot url', () => {
const initialSaveModel: Dashboard = {
snapshot: {
originalUrl: 'originalUrl/snapshot',
created: '2023-01-01T00:00:00Z',
expires: '2023-12-31T23:59:59Z',
external: false,
externalUrl: '',
id: 1,
key: 'snapshot-key',
name: 'snapshot-name',
orgId: 1,
updated: '2023-01-01T00:00:00Z',
userId: 1,
},
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
version: 10,
};
const serializer = new V1DashboardSerializer();
serializer.initialSaveModel = initialSaveModel;
expect(serializer.getSnapshotUrl()).toBe('originalUrl/snapshot');
});
describe('panel mapping methods', () => {
let serializer: V1DashboardSerializer;
beforeEach(() => {
serializer = new V1DashboardSerializer();
});
it('should initialize panel mapping correctly', () => {
const saveModel: Dashboard = {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{ id: 1, title: 'Panel 1', type: 'text' },
{ id: 2, title: 'Panel 2', type: 'text' },
],
};
serializer.initializeElementMapping(saveModel);
const mapping = serializer.getElementPanelMapping();
expect(mapping.size).toBe(2);
expect(mapping.get('panel-1')).toBe(1);
expect(mapping.get('panel-2')).toBe(2);
});
it('should handle empty or undefined panels in initializeMapping', () => {
serializer.initializeElementMapping(undefined);
expect(serializer.getElementPanelMapping().size).toBe(0);
serializer.initializeElementMapping({
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: undefined,
});
expect(serializer.getElementPanelMapping().size).toBe(0);
});
it('should get panel id for element correctly', () => {
const saveModel: Dashboard = {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{ id: 1, title: 'Panel 1', type: 'text' },
{ id: 2, title: 'Panel 2', type: 'text' },
],
};
serializer.initializeElementMapping(saveModel);
expect(serializer.getPanelIdForElement('panel-1')).toBe(1);
expect(serializer.getPanelIdForElement('panel-2')).toBe(2);
expect(serializer.getPanelIdForElement('non-existent')).toBeUndefined();
});
it('should get element id for panel correctly', () => {
const saveModel: Dashboard = {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{ id: 1, title: 'Panel 1', type: 'text' },
{ id: 2, title: 'Panel 2', type: 'text' },
],
};
serializer.initializeElementMapping(saveModel);
expect(serializer.getElementIdForPanel(1)).toBe('panel-1');
expect(serializer.getElementIdForPanel(2)).toBe('panel-2');
// Should return default panel key for non-existent panel
expect(serializer.getElementIdForPanel(3)).toBe('panel-3');
});
});
});
describe('v2 schema', () => {
it('Can detect no changes', () => {
const dashboard = setupV2();
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can detect time changed', () => {
const dashboard = setupV2();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasTimeChanges).toBe(true);
});
it('Can save time change', () => {
const dashboard = setupV2();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
const result = dashboard.getDashboardChanges(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect folder change', () => {
const dashboard = setupV2();
dashboard.state.meta.folderUid = 'folder-2';
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
expect(result.hasFolderChanges).toBe(true);
});
it('Can detect refresh changed', () => {
const dashboard = setupV2();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '10m' });
}
const result = dashboard.getDashboardChanges(false, false, false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasRefreshChange).toBe(true);
});
it('Can save refresh change', () => {
const dashboard = setupV2();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '10m' });
}
const result = dashboard.getDashboardChanges(false, false, true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
describe('variable changes', () => {
it('Can detect variable change', () => {
const dashboard = setupV2();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can save variable value change', () => {
const dashboard = setupV2();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(2);
});
describe('Experimental variables', () => {
beforeAll(() => {
config.featureToggles.groupByVariable = true;
});
afterAll(() => {
config.featureToggles.groupByVariable = false;
});
it('Can detect group by static options change', () => {
const dashboard = setupV2({
variables: [
{
kind: 'GroupByVariable',
spec: {
current: {
text: 'Host',
value: 'host',
},
datasource: {
type: 'ds',
uid: 'ds-uid',
},
name: 'GroupBy',
options: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
multi: false,
hide: 'dontHide',
skipUrlSync: false,
},
},
],
});
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect adhoc filter static options change', () => {
const dashboard = setupV2({
variables: [
{
kind: 'AdhocVariable',
spec: {
name: 'adhoc',
label: 'Adhoc Label',
description: 'Adhoc Description',
datasource: {
uid: 'gdev-prometheus',
type: 'prometheus',
},
hide: 'dontHide',
skipUrlSync: false,
filters: [],
baseFilters: [],
defaultKeys: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
allowCustomValue: true,
},
},
],
});
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
});
});
describe('Saving from panel edit', () => {
it('Should commit panel edit changes', () => {
const dashboard = setupV2();
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
const editScene = buildPanelEditScene(panel);
dashboard.onEnterEditMode();
dashboard.setState({ editPanel: editScene });
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
const result = dashboard.getDashboardChanges(false, true);
const panelSaveModel = getPanelElement(result.changedSaveModel as DashboardV2Spec, 'panel-1')!;
expect(panelSaveModel.spec.title).toBe('changed title');
});
});
describe('tracking information', () => {
it('provides dashboard tracking information with no initial save model', () => {
const dashboard = setupV2();
const serializer = new V2DashboardSerializer();
expect(serializer.getTrackingInformation(dashboard)).toBe(undefined);
});
it('provides dashboard tracking information with from initial save model', () => {
const dashboard = setupV2({
timeSettings: {
nowDelay: '10s',
from: '',
to: '',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
timezone: '',
},
liveNow: true,
});
expect(dashboard.getTrackingInformation()).toEqual({
uid: 'dashboard-test',
title: 'hello',
panels_count: 1,
panel_type__count: 1,
variable_type_custom_count: 1,
settings_nowdelay: undefined,
settings_livenow: true,
schemaVersion: DASHBOARD_SCHEMA_VERSION,
});
});
});
describe('getSaveAsModel', () => {
let serializer: V2DashboardSerializer;
let dashboard: DashboardScene;
let baseOptions: SaveDashboardAsOptions;
beforeEach(() => {
serializer = new V2DashboardSerializer();
dashboard = setupV2();
baseOptions = {
title: 'I am a new dashboard',
description: 'description goes here',
isNew: true,
copyTags: true,
};
});
it('should set basic dashboard properties correctly', () => {
const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions);
expect(saveAsModel).toMatchObject({
title: baseOptions.title,
description: baseOptions.description,
editable: true,
annotations: [
{
kind: 'AnnotationQuery',
spec: {
builtIn: true,
name: 'Annotations & Alerts',
datasource: {
uid: '-- Grafana --',
type: 'grafana',
},
enable: true,
hide: true,
iconColor: DEFAULT_ANNOTATION_COLOR,
},
},
],
cursorSync: 'Off',
liveNow: false,
preload: false,
tags: [],
});
});
it('should handle time settings correctly', () => {
const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions);
expect(saveAsModel.timeSettings).toEqual({
autoRefresh: '10s',
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
fiscalYearStartMonth: 0,
from: 'now-1h',
hideTimepicker: false,
nowDelay: undefined,
timezone: 'browser',
to: 'now',
});
});
it('should correctly serialize panel elements', () => {
const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions);
expect(saveAsModel.elements['panel-1']).toMatchObject({
kind: 'Panel',
spec: {
data: {
kind: 'QueryGroup',
spec: {
queries: [],
queryOptions: {},
transformations: [],
},
},
description: '',
id: 1,
links: [],
title: 'Panel 1',
},
});
});
it('should correctly serialize layout configuration', () => {
const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions);
expect(saveAsModel.layout).toEqual({
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
element: {
kind: 'ElementReference',
name: 'panel-1',
},
height: 8,
width: 12,
x: 0,
y: 0,
},
},
],
},
});
});
it('should correctly serialize variables', () => {
const saveAsModel = serializer.getSaveAsModel(dashboard, baseOptions);
expect(saveAsModel.variables).toEqual([
{
kind: 'CustomVariable',
spec: {
allValue: undefined,
current: {
text: 'app1',
value: 'app1',
},
description: 'A query variable',
hide: 'dontHide',
includeAll: false,
label: 'Query Variable',
multi: false,
name: 'app',
options: [],
query: 'app1',
skipUrlSync: false,
allowCustomValue: true,
},
},
]);
});
it('should handle empty dashboard state', () => {
const emptyDashboard = setupV2({
elements: {},
layout: { kind: 'GridLayout', spec: { items: [] } },
variables: [],
});
const saveAsModel = serializer.getSaveAsModel(emptyDashboard, baseOptions);
expect(saveAsModel.elements).toEqual({});
expect(saveAsModel.layout.kind).toBe('GridLayout');
expect((saveAsModel.layout as GridLayoutKind).spec.items).toEqual([]);
expect(saveAsModel.variables).toEqual([]);
});
it('should preserve visualization config', () => {
const dashboardWithVizConfig = setupV2({
elements: {
'panel-1': {
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Panel 1',
vizConfig: {
kind: 'graph',
spec: {
fieldConfig: {
defaults: { custom: { lineWidth: 2 } },
overrides: [],
},
options: { legend: { show: true } },
pluginVersion: '1.0.0',
},
},
},
},
},
});
const saveAsModel = serializer.getSaveAsModel(dashboardWithVizConfig, baseOptions);
const panelSpec = saveAsModel.elements['panel-1'].spec as PanelSpec;
expect(panelSpec.vizConfig).toMatchObject({
kind: 'graph',
spec: {
fieldConfig: {
defaults: { custom: { lineWidth: 2 } },
overrides: [],
},
options: { legend: { show: true } },
pluginVersion: '1.0.0',
},
});
});
});
describe('panel mapping methods', () => {
let serializer: V2DashboardSerializer;
let saveModel: DashboardV2Spec;
beforeEach(() => {
serializer = new V2DashboardSerializer();
saveModel = {
...defaultDashboardV2Spec(),
elements: {
'element-panel-a': {
kind: 'Panel',
spec: { ...defaultPanelSpec(), id: 1, title: 'Panel A' },
},
'element-panel-b': {
kind: 'Panel',
spec: { ...defaultPanelSpec(), id: 2, title: 'Panel B' },
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'element-panel-a',
},
},
},
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'element-panel-b',
},
},
},
],
},
},
};
});
it('should initialize panel mapping correctly', () => {
serializer.initializeElementMapping(saveModel);
const mapping = serializer.getElementPanelMapping();
expect(mapping.size).toBe(2);
expect(mapping.get('element-panel-a')).toBe(1);
expect(mapping.get('element-panel-b')).toBe(2);
});
it('should handle empty or undefined elements in initializeMapping', () => {
serializer.initializeElementMapping({} as DashboardV2Spec);
expect(serializer.getElementPanelMapping().size).toBe(0);
serializer.initializeElementMapping({ elements: {} } as DashboardV2Spec);
expect(serializer.getElementPanelMapping().size).toBe(0);
});
it('should get panel id for element correctly', () => {
serializer.initializeElementMapping(saveModel);
expect(serializer.getPanelIdForElement('element-panel-a')).toBe(1);
expect(serializer.getPanelIdForElement('element-panel-b')).toBe(2);
expect(serializer.getPanelIdForElement('non-existent')).toBeUndefined();
});
it('should get element id for panel correctly', () => {
serializer.initializeElementMapping(saveModel);
expect(serializer.getElementIdForPanel(1)).toBe('element-panel-a');
expect(serializer.getElementIdForPanel(2)).toBe('element-panel-b');
// Should return default panel key for non-existent panel
expect(serializer.getElementIdForPanel(3)).toBe('panel-3');
});
});
});
describe('Datasource References Mapping', () => {
describe('V2DashboardSerializer', () => {
let serializer: V2DashboardSerializer;
beforeEach(() => {
serializer = new V2DashboardSerializer();
});
it('should initialize datasource references mapping correctly for panels with undefined datasources', () => {
const saveModel: DashboardV2Spec = {
...defaultDashboardV2Spec(),
title: 'Test Dashboard',
elements: {
'panel-1': {
kind: 'Panel',
spec: {
id: 1,
title: 'Panel 1',
description: '',
links: [],
vizConfig: {
kind: 'timeseries',
spec: {
pluginVersion: '1.0.0',
options: {},
fieldConfig: { defaults: {}, overrides: [] },
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
// No datasource defined
query: { kind: 'sql', spec: {} },
},
},
{
kind: 'PanelQuery',
spec: {
refId: 'B',
hidden: false,
datasource: { uid: 'datasource-1', type: 'prometheus' },
query: { kind: 'prometheus', spec: {} },
},
},
],
queryOptions: {},
transformations: [],
},
},
},
},
'panel-2': {
kind: 'Panel',
spec: {
id: 2,
title: 'Panel 2',
description: '',
links: [],
vizConfig: {
kind: 'timeseries',
spec: {
pluginVersion: '1.0.0',
options: {},
fieldConfig: { defaults: {}, overrides: [] },
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'C',
hidden: false,
// No datasource defined
query: { kind: 'sql', spec: {} },
},
},
],
queryOptions: {},
transformations: [],
},
},
},
},
},
};
serializer.initializeElementMapping(saveModel);
serializer.initializeDSReferencesMapping(saveModel);
const dsReferencesMap = serializer.getDSReferencesMapping();
// Panel 1 should have refId A in the map (no datasource)
expect(dsReferencesMap.panels.has('panel-1')).toBe(true);
expect(dsReferencesMap.panels.get('panel-1')?.has('A')).toBe(true);
expect(dsReferencesMap.panels.get('panel-1')?.has('B')).toBe(false); // Has datasource defined
// Panel 2 should have refId C in the map
expect(dsReferencesMap.panels.has('panel-2')).toBe(true);
expect(dsReferencesMap.panels.get('panel-2')?.has('C')).toBe(true);
});
it('should handle empty or undefined elements in initializeDSReferencesMapping', () => {
serializer.initializeDSReferencesMapping(undefined);
expect(serializer.getDSReferencesMapping().panels.size).toBe(0);
serializer.initializeDSReferencesMapping({} as DashboardV2Spec);
expect(serializer.getDSReferencesMapping().panels.size).toBe(0);
serializer.initializeDSReferencesMapping({ elements: {} } as DashboardV2Spec);
expect(serializer.getDSReferencesMapping().panels.size).toBe(0);
});
it('should initialize datasource references mapping when annotations dont have datasources', () => {
const saveModel: DashboardV2Spec = {
...defaultDashboardV2Spec(),
title: 'Dashboard with annotations without datasource',
annotations: [
{
kind: 'AnnotationQuery',
spec: {
name: 'Annotation 1',
query: { kind: 'prometheus', spec: {} },
enable: true,
hide: false,
iconColor: 'red',
},
},
],
};
serializer.initializeDSReferencesMapping(saveModel);
const dsReferencesMap = serializer.getDSReferencesMapping();
// Annotation 1 should have no datasource
expect(dsReferencesMap.annotations.has('Annotation 1')).toBe(true);
});
it('should return early if the saveModel is not a V2 dashboard', () => {
const v1SaveModel: Dashboard = {
title: 'Test Dashboard',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{ id: 1, title: 'Panel 1', type: 'text' },
{ id: 2, title: 'Panel 2', type: 'text' },
],
};
serializer.initializeDSReferencesMapping(v1SaveModel as unknown as DashboardV2Spec);
expect(serializer.getDSReferencesMapping()).toEqual({
panels: new Map(),
variables: new Set(),
annotations: new Set(),
});
expect(serializer.getDSReferencesMapping().panels.size).toBe(0);
});
});
describe('V1DashboardSerializer', () => {
let serializer: V1DashboardSerializer;
beforeEach(() => {
serializer = new V1DashboardSerializer();
});
it('should return empty mapping object for V1 serializer', () => {
serializer.initializeDSReferencesMapping(undefined);
expect(serializer.getDSReferencesMapping()).toEqual({
panels: expect.any(Map),
variables: expect.any(Set),
annotations: expect.any(Set),
});
expect(serializer.getDSReferencesMapping().panels.size).toBe(0);
});
});
});
describe('onSaveComplete', () => {
it('should set the initialSaveModel correctly', () => {
const serializer = new V2DashboardSerializer();
const saveModel = defaultDashboardV2Spec();
const response = {
id: 1,
uid: 'aa',
slug: 'slug',
url: 'url',
version: 2,
status: 'status',
};
serializer.onSaveComplete(saveModel, response);
expect(serializer.initialSaveModel).toEqual({
...saveModel,
});
});
it('should allow retrieving snapshot url', () => {
const serializer = new V2DashboardSerializer();
serializer.metadata = {
name: 'dashboard-test',
resourceVersion: '1',
creationTimestamp: '2023-01-01T00:00:00Z',
annotations: {
[AnnoKeyDashboardSnapshotOriginalUrl]: 'originalUrl/snapshot',
},
};
expect(serializer.getSnapshotUrl()).toBe('originalUrl/snapshot');
});
});
});
function setup(override: Partial<Dashboard> = {}) {
const dashboard = transformSaveModelToScene({
dashboard: {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [
{
id: 1,
title: 'Panel 1',
type: 'text',
},
],
version: 10,
templating: {
list: [
{
name: 'app',
type: 'custom',
current: {
text: 'app1',
value: 'app1',
},
},
],
},
...override,
},
meta: {},
});
const initialSaveModel = transformSceneToSaveModel(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
return dashboard;
}
function setupV2(spec?: Partial<DashboardV2Spec>) {
const dashboard = transformSaveModelSchemaV2ToScene({
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'hello',
timeSettings: {
...defaultTimeSettingsSpec(),
autoRefresh: '10s',
from: 'now-1h',
to: 'now',
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Panel 1',
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
},
],
},
},
variables: [
{
kind: 'CustomVariable',
spec: {
name: 'app',
label: 'Query Variable',
description: 'A query variable',
skipUrlSync: false,
hide: 'dontHide',
options: [],
multi: false,
current: {
text: 'app1',
value: 'app1',
},
query: 'app1',
allValue: '',
includeAll: false,
allowCustomValue: true,
},
},
],
...spec,
},
apiVersion: 'v1',
metadata: {
name: 'dashboard-test',
resourceVersion: '1',
creationTimestamp: '2023-01-01T00:00:00Z',
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
});
const initialSaveModel = transformSceneToSaveModelSchemaV2(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
return dashboard;
}