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/transformSceneToSaveModelSc...

1291 lines
42 KiB

import { VariableRefresh } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
AdHocFiltersVariable,
behaviors,
ConstantVariable,
CustomVariable,
DataSourceVariable,
GroupByVariable,
IntervalVariable,
QueryVariable,
SceneGridLayout,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariableSet,
TextBoxVariable,
VizPanel,
SceneDataQuery,
SceneQueryRunner,
sceneUtils,
dataLayers,
} from '@grafana/scenes';
import {
DashboardCursorSync as DashboardCursorSyncV1,
VariableHide as VariableHideV1,
VariableSort as VariableSortV1,
} from '@grafana/schema/dist/esm/index.gen';
import {
GridLayoutSpec,
AutoGridLayoutSpec,
RowsLayoutSpec,
TabsLayoutSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from '../scene/layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../scene/layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import {
getPersistedDSFor,
getElementDatasource,
transformSceneToSaveModelSchemaV2,
validateDashboardSchemaV2,
getDataQueryKind,
getAutoAssignedDSRef,
getVizPanelQueries,
} from './transformSceneToSaveModelSchemaV2';
// Mock dependencies
jest.mock('../utils/dashboardSceneGraph', () => {
const original = jest.requireActual('../utils/dashboardSceneGraph');
return {
...original,
dashboardSceneGraph: {
...original.dashboardSceneGraph,
getElementIdentifierForVizPanel: jest.fn().mockImplementation((panel) => {
// 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({}),
});
}),
},
};
});
jest.mock('../utils/utils', () => {
const original = jest.requireActual('../utils/utils');
return {
...original,
getDashboardSceneFor: jest.fn().mockImplementation(() => ({
serializer: {
getDSReferencesMapping: jest.fn().mockReturnValue({
panels: new Map([['panel-1', new Set(['A'])]]),
variables: new Set(),
annotations: new Set(),
}),
},
})),
};
});
function setupDashboardScene(state: Partial<DashboardSceneState>): DashboardScene {
return new DashboardScene(state);
}
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: {
settings: {
defaultDatasource: 'loki',
datasources: {
Prometheus: {
name: 'Prometheus',
meta: { id: 'prometheus' },
type: 'datasource',
},
'-- Grafana --': {
name: 'Grafana',
meta: { id: 'grafana' },
type: 'datasource',
},
loki: {
name: 'Loki',
meta: {
id: 'loki',
name: 'Loki',
type: 'datasource',
info: { version: '1.0.0' },
module: 'app/plugins/datasource/loki/module',
baseUrl: '/plugins/loki',
},
type: 'datasource',
},
},
},
},
},
}));
describe('transformSceneToSaveModelSchemaV2', () => {
let dashboardScene: DashboardScene;
let prevFeatureToggleValue: boolean;
beforeAll(() => {
prevFeatureToggleValue = !!config.featureToggles.groupByVariable;
config.featureToggles.groupByVariable = true;
});
afterAll(() => {
config.featureToggles.groupByVariable = prevFeatureToggleValue;
});
beforeEach(() => {
// The intention is to have a complete dashboard scene
// with all the possible properties set
dashboardScene = setupDashboardScene({
$data: new DashboardDataLayerSet({ annotationLayers: createAnnotationLayers() }),
id: 1,
title: 'Test Dashboard',
description: 'Test Description',
preload: true,
tags: ['tag1', 'tag2'],
uid: 'test-uid',
version: 1,
$timeRange: new SceneTimeRange({
timeZone: 'UTC',
from: 'now-1h',
to: 'now',
weekStart: 'monday',
fiscalYearStartMonth: 1,
UNSAFE_nowDelay: '1m',
refreshOnActivate: {
afterMs: 10,
percent: 0.1,
},
}),
controls: new DashboardControls({
refreshPicker: new SceneRefreshPicker({
refresh: '5s',
intervals: ['5s', '10s', '30s'],
autoEnabled: true,
autoMinInterval: '5s',
autoValue: '5s',
isOnCanvas: true,
primary: true,
withText: true,
minRefreshInterval: '5s',
}),
timePicker: new SceneTimePicker({
isOnCanvas: true,
hidePicker: true,
}),
}),
links: [
{
title: 'Test Link',
url: 'http://test.com',
asDropdown: false,
icon: '',
includeVars: false,
keepTime: false,
tags: [],
targetBlank: false,
tooltip: '',
type: 'link',
},
],
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
isLazy: false,
children: [
new DashboardGridItem({
y: 0,
height: 10,
body: new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
title: 'Test Panel',
titleItems: [
new VizPanelLinks({
rawLinks: [
{ title: 'Test Link 1', url: 'http://test1.com', targetBlank: true },
{ title: 'Test Link 2', url: 'http://test2.com' },
],
menu: new VizPanelLinksMenu({}),
}),
],
description: 'Test Description',
hoverHeader: true,
hoverHeaderOffset: 10,
fieldConfig: {
defaults: {
mappings: [],
max: undefined,
},
overrides: [],
},
displayMode: 'transparent',
pluginVersion: '7.0.0',
$timeRange: new SceneTimeRange({
timeZone: 'UTC',
from: 'now-3h',
to: 'now',
}),
}),
// Props related to repeatable panels
// repeatedPanels?: VizPanel[],
// variableName?: string,
// itemHeight?: number,
// repeatDirection?: RepeatDirection,
// maxPerRow?: number,
}),
],
}),
}),
meta: {},
editPane: new DashboardEditPane(),
$behaviors: [
new behaviors.CursorSync({
sync: DashboardCursorSyncV1.Crosshair,
}),
new behaviors.LiveNowTimer({
enabled: true,
}),
],
$variables: new SceneVariableSet({
// Test each of the variables
variables: [
new QueryVariable({
name: 'queryVar',
label: 'Query Variable',
description: 'A query variable',
skipUrlSync: false,
hide: VariableHideV1.hideLabel,
value: 'value1',
text: 'text1',
query: {
expr: 'label_values(node_boot_time_seconds)',
refId: 'A',
},
definition: 'definition1',
datasource: { uid: 'datasource1', type: 'prometheus' },
sort: VariableSortV1.alphabeticalDesc,
refresh: VariableRefresh.onDashboardLoad,
regex: 'regex1',
allValue: '*',
includeAll: true,
isMulti: true,
}),
new CustomVariable({
name: 'customVar',
label: 'Custom Variable',
description: 'A custom variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: 'option1',
text: 'option1',
query: 'option1, option2',
options: [
{ label: 'option1', value: 'option1' },
{ label: 'option2', value: 'option2' },
],
isMulti: true,
allValue: 'All',
includeAll: true,
}),
new DataSourceVariable({
name: 'datasourceVar',
label: 'Datasource Variable',
description: 'A datasource variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: 'value1',
text: 'text1',
regex: 'regex1',
pluginId: 'datasource1',
defaultOptionEnabled: true,
}),
new ConstantVariable({
name: 'constantVar',
label: 'Constant Variable',
description: 'A constant variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: 'value4',
}),
new IntervalVariable({
name: 'intervalVar',
label: 'Interval Variable',
description: 'An interval variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: '1m',
intervals: ['1m', '5m', '10m'],
autoEnabled: false,
autoMinInterval: '1m',
autoStepCount: 10,
}),
new TextBoxVariable({
name: 'textVar',
label: 'Text Variable',
description: 'A text variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: 'value6',
}),
new GroupByVariable({
name: 'groupByVar',
label: 'Group By Variable',
description: 'A group by variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
value: 'value7',
text: 'text7',
datasource: { uid: 'datasource2', type: 'prometheus' },
defaultOptions: [
{ text: 'option1', value: 'option1' },
{ text: 'option2', value: 'option2' },
],
isMulti: false,
includeAll: false,
}),
new AdHocFiltersVariable({
name: 'adhocVar',
label: 'Adhoc Variable',
description: 'An adhoc variable',
skipUrlSync: false,
hide: VariableHideV1.dontHide,
datasource: { uid: 'datasource3', type: 'prometheus' },
baseFilters: [
{
key: 'key1',
operator: '=',
value: 'value1',
condition: 'AND',
},
{
key: 'key2',
operator: '=',
value: 'value2',
condition: 'OR',
},
],
filters: [
{
key: 'key3',
operator: '=',
value: 'value3',
condition: 'AND',
},
],
defaultKeys: [
{
text: 'defaultKey1',
value: 'defaultKey1',
group: 'defaultGroup1',
expandable: true,
},
],
}),
],
}),
});
});
it('should transform scene to save model schema v2', () => {
const result = transformSceneToSaveModelSchemaV2(dashboardScene);
expect(result).toMatchSnapshot();
// Check that the annotation layers are correctly transformed
expect(result.annotations).toHaveLength(2);
});
it('should transform the minimum scene to save model schema v2', () => {
const minimalScene = new DashboardScene({});
expect(() => {
transformSceneToSaveModelSchemaV2(minimalScene);
}).not.toThrow();
});
describe('getPersistedDSFor query', () => {
it('should respect datasource reference mapping when determining query datasource', () => {
// Setup test data
const queryWithoutDS: SceneDataQuery = {
refId: 'A',
// No datasource defined originally
};
const queryWithDS: SceneDataQuery = {
refId: 'B',
datasource: { uid: 'prometheus', type: 'prometheus' },
};
// Mock query runner with runtime-resolved datasource
const queryRunner = new SceneQueryRunner({
queries: [queryWithoutDS, queryWithDS],
datasource: { uid: 'default-ds', type: 'default' },
});
// Get a reference to the DS references mapping
const dsReferencesMap = new Set(['A']);
// Test the query without DS originally - should return undefined
const resultA = getPersistedDSFor(queryWithoutDS, dsReferencesMap, 'query', queryRunner);
expect(resultA).toBeUndefined();
// Test the query with DS originally - should return the original datasource
const resultB = getPersistedDSFor(queryWithDS, dsReferencesMap, 'query', queryRunner);
expect(resultB).toEqual({ uid: 'prometheus', type: 'prometheus' });
// Test a query with no DS originally but not in the mapping - should get the runner's datasource
const queryNotInMapping: SceneDataQuery = {
refId: 'C',
// No datasource, but not in mapping
};
const resultC = getPersistedDSFor(queryNotInMapping, dsReferencesMap, 'query', queryRunner);
expect(resultC).toEqual({ uid: 'default-ds', type: 'default' });
});
});
describe('getPersistedDSFor variable', () => {
it('should respect datasource reference mapping when determining variable datasource', () => {
// Setup test data - variable without datasource
const variableWithoutDS = new QueryVariable({
name: 'A',
// No datasource defined originally
});
// Variable with datasource
const variableWithDS = new QueryVariable({
name: 'B',
datasource: { uid: 'prometheus', type: 'prometheus' },
});
// Get a reference to the DS references mapping
const dsReferencesMap = new Set(['A']);
// Test the variable without DS originally - should return undefined
const resultA = getPersistedDSFor(variableWithoutDS, dsReferencesMap, 'variable');
expect(resultA).toBeUndefined();
// Test the variable with DS originally - should return the original datasource
const resultB = getPersistedDSFor(variableWithDS, dsReferencesMap, 'variable');
expect(resultB).toEqual({ uid: 'prometheus', type: 'prometheus' });
// Test a variable with no DS originally but not in the mapping - should get empty object
const variableNotInMapping = new QueryVariable({
name: 'C',
// No datasource, but not in mapping
});
const resultC = getPersistedDSFor(variableNotInMapping, dsReferencesMap, 'variable');
expect(resultC).toEqual({});
});
});
describe('getDataQueryKind', () => {
it('should preserve original query datasource type when available', () => {
// 1. Test with a query that has its own datasource type
const queryWithDS: SceneDataQuery = {
refId: 'A',
datasource: { uid: 'prometheus-1', type: 'prometheus' },
};
// Create a query runner with a different datasource type
const queryRunner = new SceneQueryRunner({
datasource: { uid: 'default-ds', type: 'loki' },
queries: [],
});
// Should use the query's own datasource type (prometheus)
expect(getDataQueryKind(queryWithDS, queryRunner)).toBe('prometheus');
});
it('should use queryRunner datasource type as fallback when query has no datasource', () => {
// 2. Test with a query that has no datasource
const queryWithoutDS: SceneDataQuery = {
refId: 'A',
};
// Create a query runner with a datasource
const queryRunner = new SceneQueryRunner({
datasource: { uid: 'influxdb-1', type: 'influxdb' },
queries: [],
});
// Should fall back to queryRunner's datasource type
expect(getDataQueryKind(queryWithoutDS, queryRunner)).toBe('influxdb');
});
it('should fall back to default datasource when neither query nor queryRunner has datasource type', () => {
// 3. Test with neither query nor queryRunner having a datasource type
const queryWithoutDS: SceneDataQuery = {
refId: 'A',
};
// Create a query runner with no datasource
const queryRunner = new SceneQueryRunner({
queries: [],
});
expect(getDataQueryKind(queryWithoutDS, queryRunner)).toBe('loki');
// Also verify the function's behavior by checking the args
expect(queryWithoutDS.datasource?.type).toBeUndefined(); // No query datasource
expect(queryRunner.state.datasource?.type).toBeUndefined(); // No queryRunner datasource
});
});
it('should test annotation with legacyOptions field', () => {
// Create a scene with an annotation layer that has options
const annotationWithOptions = new DashboardAnnotationsDataLayer({
key: 'layerWithLegacyOptions',
query: {
datasource: {
type: 'prometheus',
uid: 'abc123',
},
name: 'annotation-with-options',
enable: true,
iconColor: 'red',
legacyOptions: {
expr: 'rate(http_requests_total[5m])',
queryType: 'range',
legendFormat: '{{method}} {{endpoint}}',
useValueAsTime: true,
},
// Some other properties that aren't in the annotation spec
// and should be moved to options
customProp1: 'value1',
customProp2: 'value2',
},
name: 'layerWithOptions',
isEnabled: true,
isHidden: false,
});
const scene = setupDashboardScene({
$data: new DashboardDataLayerSet({
annotationLayers: [annotationWithOptions],
}),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({ children: [] }),
}),
});
const result = transformSceneToSaveModelSchemaV2(scene);
// Verify the annotation options are properly serialized
expect(result.annotations.length).toBe(1);
expect(result.annotations[0].spec.legacyOptions).toBeDefined();
expect(result.annotations[0].spec.legacyOptions).toEqual({
expr: 'rate(http_requests_total[5m])',
queryType: 'range',
legendFormat: '{{method}} {{endpoint}}',
useValueAsTime: true,
customProp1: 'value1',
customProp2: 'value2',
});
// Ensure these properties are not at the root level
expect(result).not.toHaveProperty('annotations[0].spec.expr');
expect(result).not.toHaveProperty('annotations[0].spec.queryType');
expect(result).not.toHaveProperty('annotations[0].spec.legendFormat');
expect(result).not.toHaveProperty('annotations[0].spec.useValueAsTime');
expect(result).not.toHaveProperty('annotations[0].spec.customProp1');
expect(result).not.toHaveProperty('annotations[0].spec.customProp2');
});
});
describe('getElementDatasource', () => {
it('should handle panel query datasources correctly', () => {
// Create test elements
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
});
const queryWithDS: SceneDataQuery = {
refId: 'B',
datasource: { uid: 'prometheus', type: 'prometheus' },
};
const queryWithoutDS: SceneDataQuery = {
refId: 'A',
};
// Mock query runner
const queryRunner = new SceneQueryRunner({
queries: [queryWithoutDS, queryWithDS],
datasource: { uid: 'default-ds', type: 'default' },
});
// Mock dsReferencesMapping
const dsReferencesMapping = {
panels: new Map(new Set([['panel-1', new Set<string>(['A'])]])),
variables: new Set<string>(),
annotations: new Set<string>(),
};
// Call the function with the panel and query with DS
const resultWithDS = getElementDatasource(vizPanel, queryWithDS, 'panel', queryRunner, dsReferencesMapping);
expect(resultWithDS).toEqual({ uid: 'prometheus', type: 'prometheus' });
// Call the function with the panel and query without DS
const resultWithoutDS = getElementDatasource(vizPanel, queryWithoutDS, 'panel', queryRunner, dsReferencesMapping);
expect(resultWithoutDS).toBeUndefined();
});
it('should handle variable datasources correctly', () => {
// Create a variable set
const variableSet = new SceneVariableSet({
variables: [
new QueryVariable({
name: 'A',
// No datasource
}),
new QueryVariable({
name: 'B',
datasource: { uid: 'prometheus', type: 'prometheus' },
}),
],
});
// Variable with DS
const variableWithDS = variableSet.getByName('B');
// Variable without DS
const variableWithoutDS = variableSet.getByName('A');
// Mock dsReferencesMapping
const dsReferencesMapping = {
panels: new Map(new Set([['panel-1', new Set<string>(['A'])]])),
variables: new Set<string>(['A']),
annotations: new Set<string>(),
};
// Call the function with variables
if (variableWithDS && sceneUtils.isQueryVariable(variableWithDS)) {
const resultWithDS = getElementDatasource(
variableSet,
variableWithDS,
'variable',
undefined,
dsReferencesMapping
);
expect(resultWithDS).toEqual({ uid: 'prometheus', type: 'prometheus' });
}
if (variableWithoutDS && sceneUtils.isQueryVariable(variableWithoutDS)) {
// Test with auto-assigned variable (in the mapping)
const resultWithoutDS = getElementDatasource(variableSet, variableWithoutDS, 'variable');
expect(resultWithoutDS).toEqual(undefined);
}
});
it('should return undefined for non-query variables', () => {
// Create a variable set with non-query variable
const variableSet = new SceneVariableSet({
variables: [
new ConstantVariable({
name: 'constant',
value: 'value',
}),
],
});
// Non-query variable
const constantVar = variableSet.getByName('constant');
// Call the function
// @ts-expect-error
const result = getElementDatasource(variableSet, constantVar, 'variable');
expect(result).toBeUndefined();
});
it('should return undefined for non-query variables', () => {
// Create a variable set with non-query variable types
const variableSet = new SceneVariableSet({
variables: [
// Use TextBoxVariable which is not a QueryVariable
new TextBoxVariable({
name: 'textVar',
value: 'text-value',
}),
],
});
// Non-query variable - this is safe because getElementDatasource checks if it's a query variable
const textVar = variableSet.getByName('textVar');
// Call the function
// @ts-expect-error
const result = getElementDatasource(variableSet, textVar, 'variable');
expect(result).toBeUndefined();
});
it('should handle annotation datasources correctly', () => {
// Use the dataLayers.AnnotationsDataLayer directly
const annotationLayer = new dataLayers.AnnotationsDataLayer({
key: 'annotation-1',
name: 'Test Annotation',
isEnabled: true,
isHidden: false,
query: {
name: 'Test Annotation',
enable: true,
hide: false,
iconColor: 'red',
datasource: { uid: 'prometheus', type: 'prometheus' },
},
});
// Create an annotation query without datasource
const annotationWithoutDS = {
name: 'No DS Annotation',
enable: true,
hide: false,
iconColor: 'blue',
};
// Mock dsReferencesMapping
const dsReferencesMapping = {
panels: new Map([['panel-1', new Set(['A'])]]),
variables: new Set<string>(),
annotations: new Set<string>(['No DS Annotation']),
};
// Test with annotation that has datasource defined
const resultWithDS = getElementDatasource(
annotationLayer,
annotationLayer.state.query,
'annotation',
undefined,
dsReferencesMapping
);
expect(resultWithDS).toEqual({ uid: 'prometheus', type: 'prometheus' });
// Test with annotation that has no datasource defined
const resultWithoutDS = getElementDatasource(
annotationLayer,
annotationWithoutDS,
'annotation',
undefined,
dsReferencesMapping
);
expect(resultWithoutDS).toBeUndefined();
});
it('should handle invalid input combinations', () => {
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
});
const variableSet = new SceneVariableSet({
variables: [
new QueryVariable({
name: 'A',
}),
],
});
const variable = variableSet.getByName('A');
const query: SceneDataQuery = { refId: 'A' };
if (variable && sceneUtils.isQueryVariable(variable)) {
// Panel with variable
expect(getElementDatasource(vizPanel, variable, 'panel')).toBeUndefined();
}
// Variable set with query
expect(getElementDatasource(variableSet, query, 'variable')).toBeUndefined();
});
it('should throw error when invalid type is passed to getAutoAssignedDSRef', () => {
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
});
const dsReferencesMapping = {
panels: new Map([['panel-1', new Set(['A'])]]),
variables: new Set<string>(),
annotations: new Set<string>(),
};
expect(() => {
// @ts-expect-error - intentionally passing invalid type to test error handling
getAutoAssignedDSRef(vizPanel, 'invalid-type', dsReferencesMapping);
}).toThrow('Invalid type invalid-type for getAutoAssignedDSRef');
});
});
describe('getVizPanelQueries', () => {
it('should handle panel query datasources correctly', () => {
const queryWithDS: SceneDataQuery = {
refId: 'B',
datasource: { uid: 'prometheus-uid', type: 'prometheus' },
};
const queryWithoutDS: SceneDataQuery = {
refId: 'A',
};
// Mock query runner
const queryRunner = new SceneQueryRunner({
queries: [queryWithoutDS, queryWithDS],
datasource: { uid: 'default-ds', type: 'default' },
});
// Create test elements
const vizPanel = new VizPanel({
key: 'panel-1',
pluginId: 'timeseries',
$data: queryRunner,
});
// Mock dsReferencesMapping
const dsReferencesMapping = {
panels: new Map(new Set([['panel-1', new Set<string>(['A'])]])),
variables: new Set<string>(),
annotations: new Set<string>(),
};
const result = getVizPanelQueries(vizPanel, dsReferencesMapping);
expect(result.length).toBe(2);
expect(result[0].spec.query.kind).toBe('DataQuery');
expect(result[0].spec.query.datasource).toBeUndefined(); // ignore datasource if it wasn't provided
expect(result[0].spec.query.group).toBe('default');
expect(result[0].spec.query.version).toBe('v0');
expect(result[1].spec.query.kind).toBe('DataQuery');
expect(result[1].spec.query.datasource?.name).toBe('prometheus-uid');
expect(result[1].spec.query.group).toBe('prometheus');
expect(result[1].spec.query.version).toBe('v0');
});
});
function getMinimalSceneState(body: DashboardLayoutManager): Partial<DashboardSceneState> {
return {
id: 1,
title: 'Test Dashboard',
description: 'Test Description',
preload: true,
tags: ['tag1', 'tag2'],
uid: 'test-uid',
version: 1,
controls: new DashboardControls({
refreshPicker: new SceneRefreshPicker({
refresh: '5s',
intervals: ['5s', '10s', '30s'],
autoEnabled: true,
autoMinInterval: '5s',
autoValue: '5s',
isOnCanvas: true,
primary: true,
withText: true,
minRefreshInterval: '5s',
}),
timePicker: new SceneTimePicker({
isOnCanvas: true,
hidePicker: true,
quickRanges: [
{
display: 'Last 6 hours',
from: 'now-6h',
to: 'now',
},
{
display: 'Last 3 days',
from: 'now-3d',
to: 'now',
},
],
}),
}),
$timeRange: new SceneTimeRange({
timeZone: 'UTC',
from: 'now-1h',
to: 'now',
weekStart: 'monday',
fiscalYearStartMonth: 1,
UNSAFE_nowDelay: '1m',
refreshOnActivate: {
afterMs: 10,
percent: 0.1,
},
}),
body,
};
}
describe('dynamic layouts', () => {
it('should transform scene with rows layout with default grids in rows to save model schema v2', () => {
const scene = setupDashboardScene(
getMinimalSceneState(
new RowsLayoutManager({
rows: [
new RowItem({
layout: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
y: 0,
height: 10,
body: new VizPanel({}),
}),
],
}),
}),
}),
],
})
)
);
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('RowsLayout');
const rowsLayout = result.layout.spec as RowsLayoutSpec;
expect(rowsLayout.rows.length).toBe(1);
expect(rowsLayout.rows[0].kind).toBe('RowsLayoutRow');
expect(rowsLayout.rows[0].spec.layout.kind).toBe('GridLayout');
});
it('should transform scene with rows layout with multiple rows with different grids to save model schema v2', () => {
const scene = setupDashboardScene(
getMinimalSceneState(
new RowsLayoutManager({
rows: [
new RowItem({
layout: new AutoGridLayoutManager({
layout: new AutoGridLayout({
children: [
new AutoGridItem({
body: new VizPanel({}),
}),
],
}),
}),
}),
new RowItem({
layout: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
y: 0,
height: 10,
body: new VizPanel({}),
}),
],
}),
}),
}),
],
})
)
);
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('RowsLayout');
const rowsLayout = result.layout.spec as RowsLayoutSpec;
expect(rowsLayout.rows.length).toBe(2);
expect(rowsLayout.rows[0].kind).toBe('RowsLayoutRow');
expect(rowsLayout.rows[0].spec.layout.kind).toBe('AutoGridLayout');
const layout1 = rowsLayout.rows[0].spec.layout.spec as AutoGridLayoutSpec;
expect(layout1.items[0].kind).toBe('AutoGridLayoutItem');
expect(rowsLayout.rows[1].spec.layout.kind).toBe('GridLayout');
const layout2 = rowsLayout.rows[1].spec.layout.spec as GridLayoutSpec;
expect(layout2.items[0].kind).toBe('GridLayoutItem');
});
it('should transform scene with auto grid layout to schema v2', () => {
const scene = setupDashboardScene(
getMinimalSceneState(
new AutoGridLayoutManager({
columnWidth: 100,
rowHeight: 'standard',
maxColumnCount: 4,
fillScreen: true,
layout: new AutoGridLayout({
children: [
new AutoGridItem({
body: new VizPanel({}),
}),
new AutoGridItem({
body: new VizPanel({}),
}),
],
}),
})
)
);
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('AutoGridLayout');
const respGridLayout = result.layout.spec as AutoGridLayoutSpec;
expect(respGridLayout.columnWidthMode).toBe('custom');
expect(respGridLayout.columnWidth).toBe(100);
expect(respGridLayout.rowHeightMode).toBe('standard');
expect(respGridLayout.rowHeight).toBeUndefined();
expect(respGridLayout.maxColumnCount).toBe(4);
expect(respGridLayout.fillScreen).toBe(true);
expect(respGridLayout.items.length).toBe(2);
expect(respGridLayout.items[0].kind).toBe('AutoGridLayoutItem');
});
it('should transform scene with tabs layout to schema v2', () => {
const tabs = [
new TabItem({
layout: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
y: 0,
height: 10,
body: new VizPanel({}),
}),
],
}),
}),
}),
];
const scene = setupDashboardScene(getMinimalSceneState(new TabsLayoutManager({ tabs })));
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('TabsLayout');
const tabsLayout = result.layout.spec as TabsLayoutSpec;
expect(tabsLayout.tabs.length).toBe(1);
expect(tabsLayout.tabs[0].kind).toBe('TabsLayoutTab');
expect(tabsLayout.tabs[0].spec.layout.kind).toBe('GridLayout');
});
});
// Instead of reusing annotation layer objects, create a factory function to generate new ones each time
function createAnnotationLayers() {
return [
new DashboardAnnotationsDataLayer({
key: 'layer1',
query: {
datasource: {
type: 'grafana',
uid: '-- Grafana --',
},
name: 'query1',
enable: true,
iconColor: 'red',
},
name: 'layer1',
isEnabled: true,
isHidden: false,
}),
new DashboardAnnotationsDataLayer({
key: 'layer2',
query: {
datasource: {
type: 'prometheus',
uid: 'abcdef',
},
name: 'query2',
enable: true,
iconColor: 'blue',
},
name: 'layer2',
isEnabled: true,
isHidden: true,
}),
];
}
describe('validateDashboardSchemaV2', () => {
const validDashboard = {
title: 'Test Dashboard',
timeSettings: {
from: 'now-1h',
to: 'now',
autoRefresh: '5s',
hideTimepicker: false,
timezone: 'UTC',
autoRefreshIntervals: ['5s', '10s', '30s'],
quickRanges: [],
weekStart: 'monday',
nowDelay: '1m',
fiscalYearStartMonth: 1,
},
variables: [],
elements: {},
annotations: [],
layout: {
kind: 'GridLayout',
spec: {
items: [],
},
},
};
it('should validate a valid dashboard', () => {
expect(validateDashboardSchemaV2(validDashboard)).toBe(true);
});
it('should throw error if dashboard is not an object', () => {
expect(() => validateDashboardSchemaV2(null)).toThrow('Dashboard is not an object or is null');
expect(() => validateDashboardSchemaV2(undefined)).toThrow('Dashboard is not an object or is null');
expect(() => validateDashboardSchemaV2('string')).toThrow('Dashboard is not an object or is null');
expect(() => validateDashboardSchemaV2(123)).toThrow('Dashboard is not an object or is null');
expect(() => validateDashboardSchemaV2(true)).toThrow('Dashboard is not an object or is null');
expect(() => validateDashboardSchemaV2([])).toThrow('Dashboard is not an object or is null');
});
it('should validate required properties', () => {
const requiredProps = {
title: 'Title is not a string',
timeSettings: 'TimeSettings is not an object or is null',
variables: 'Variables is not an array',
elements: 'Elements is not an object or is null',
annotations: 'Annotations is not an array',
layout: 'Layout is not an object or is null',
};
for (const [prop, message] of Object.entries(requiredProps)) {
const invalidDashboard = { ...validDashboard };
delete invalidDashboard[prop as keyof typeof invalidDashboard];
expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow(message);
}
});
it('should validate timeSettings required properties', () => {
const timeSettingsErrors = {
from: 'From is not a string',
to: 'To is not a string',
autoRefresh: 'AutoRefresh is not a string',
hideTimepicker: 'HideTimepicker is not a boolean',
} as const;
for (const [prop, message] of Object.entries(timeSettingsErrors)) {
const invalidDashboard = {
...validDashboard,
timeSettings: { ...validDashboard.timeSettings },
};
delete invalidDashboard.timeSettings[prop as keyof typeof invalidDashboard.timeSettings];
expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow(message);
}
});
it('should validate optional properties when present', () => {
const invalidDashboard = {
...validDashboard,
description: 123, // Should be string
cursorSync: 'Invalid', // Should be one of ['Off', 'Crosshair', 'Tooltip']
liveNow: 'true', // Should be boolean
preload: 'true', // Should be boolean
editable: 'true', // Should be boolean
links: 'not-an-array', // Should be array
tags: 'not-an-array', // Should be array
id: 'not-a-number', // Should be number
};
expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow('Description is not a string');
expect(() => validateDashboardSchemaV2({ ...validDashboard, cursorSync: 'Invalid' })).toThrow(
'CursorSync is not a valid value'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, liveNow: 'true' })).toThrow('LiveNow is not a boolean');
expect(() => validateDashboardSchemaV2({ ...validDashboard, preload: 'true' })).toThrow('Preload is not a boolean');
expect(() => validateDashboardSchemaV2({ ...validDashboard, editable: 'true' })).toThrow(
'Editable is not a boolean'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, links: 'not-an-array' })).toThrow(
'Links is not an array'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, tags: 'not-an-array' })).toThrow(
'Tags is not an array'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, id: 'not-a-number' })).toThrow('ID is not a number');
});
it('should validate optional timeSettings properties when present', () => {
const invalidTimeSettings = {
...validDashboard.timeSettings,
autoRefreshIntervals: 'not-an-array',
timezone: 123,
quickRanges: 'not-an-array',
weekStart: 'invalid-day',
nowDelay: 123,
fiscalYearStartMonth: 'not-a-number',
};
expect(() => validateDashboardSchemaV2({ ...validDashboard, timeSettings: invalidTimeSettings })).toThrow(
'AutoRefreshIntervals is not an array'
);
expect(() =>
validateDashboardSchemaV2({ ...validDashboard, timeSettings: { ...validDashboard.timeSettings, timezone: 123 } })
).toThrow('Timezone is not a string');
expect(() =>
validateDashboardSchemaV2({
...validDashboard,
timeSettings: { ...validDashboard.timeSettings, quickRanges: 'not-an-array' },
})
).toThrow('QuickRanges is not an array');
expect(() =>
validateDashboardSchemaV2({
...validDashboard,
timeSettings: { ...validDashboard.timeSettings, weekStart: 'invalid-day' },
})
).toThrow('WeekStart should be one of "saturday", "sunday" or "monday"');
expect(() =>
validateDashboardSchemaV2({ ...validDashboard, timeSettings: { ...validDashboard.timeSettings, nowDelay: 123 } })
).toThrow('NowDelay is not a string');
expect(() =>
validateDashboardSchemaV2({
...validDashboard,
timeSettings: { ...validDashboard.timeSettings, fiscalYearStartMonth: 'not-a-number' },
})
).toThrow('FiscalYearStartMonth is not a number');
});
it('should validate layout kind and structure', () => {
// Missing kind
expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { spec: { items: [] } } })).toThrow(
'Layout kind is required'
);
// Invalid GridLayout
expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout' } })).toThrow(
'Layout spec is not an object or is null'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout', spec: {} } })).toThrow(
'Layout spec items is not an array'
);
// Invalid RowsLayout
expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout' } })).toThrow(
'Layout spec is not an object or is null'
);
expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout', spec: {} } })).toThrow(
'Layout spec items is not an array'
);
// Valid GridLayout
expect(validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout', spec: { items: [] } } })).toBe(
true
);
// Valid RowsLayout
expect(validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout', spec: { rows: [] } } })).toBe(
true
);
});
});