import { VariableRefresh } from '@grafana/data'; import { config } from '@grafana/runtime'; import { AdHocFiltersVariable, behaviors, ConstantVariable, CustomVariable, DataSourceVariable, GroupByVariable, IntervalVariable, QueryVariable, SceneCSSGridLayout, SceneGridLayout, SceneGridRow, SceneRefreshPicker, SceneTimePicker, SceneTimeRange, SceneVariableSet, TextBoxVariable, VizPanel, } from '@grafana/scenes'; import { DashboardCursorSync as DashboardCursorSyncV1, VariableHide as VariableHideV1, VariableSort as VariableSortV1, } from '@grafana/schema/dist/esm/index.gen'; import { GridLayoutSpec, ResponsiveGridLayoutSpec, RowsLayoutSpec, TabsLayoutSpec, } from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0'; 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 { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem'; import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; 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 { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; function setupDashboardScene(state: Partial): 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' }, 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 }), 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: {}, 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, }), new SceneGridRow({ key: 'panel-4', title: 'Test Row', y: 10, $behaviors: [new RowRepeaterBehavior({ variableName: 'customVar' })], children: [ new DashboardGridItem({ y: 11, body: new VizPanel({ key: 'panel-2', pluginId: 'graph', title: 'Test Panel 2', description: 'Test Description 2', fieldConfig: { defaults: {}, overrides: [] }, displayMode: 'transparent', pluginVersion: '7.0.0', $timeRange: new SceneTimeRange({ timeZone: 'UTC', from: 'now-3h', to: 'now', }), }), }), ], }), ], }), }), 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(3); // check annotation layer 3 with no datasource has the default datasource defined as type expect(result.annotations?.[2].spec.datasource?.type).toBe('loki'); }); }); function getMinimalSceneState(body: DashboardLayoutManager): Partial { 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 ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: [ new ResponsiveGridItem({ 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('ResponsiveGridLayout'); const layout1 = rowsLayout.rows[0].spec.layout.spec as ResponsiveGridLayoutSpec; expect(layout1.items[0].kind).toBe('ResponsiveGridLayoutItem'); 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 responsive grid layout to schema v2', () => { const scene = setupDashboardScene( getMinimalSceneState( new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ autoRows: 'rowString', templateColumns: 'colString', children: [ new ResponsiveGridItem({ body: new VizPanel({}), }), new ResponsiveGridItem({ body: new VizPanel({}), }), ], }), }) ) ); const result = transformSceneToSaveModelSchemaV2(scene); expect(result.layout.kind).toBe('ResponsiveGridLayout'); const respGridLayout = result.layout.spec as ResponsiveGridLayoutSpec; expect(respGridLayout.col).toBe('colString'); expect(respGridLayout.row).toBe('rowString'); expect(respGridLayout.items.length).toBe(2); expect(respGridLayout.items[0].kind).toBe('ResponsiveGridLayoutItem'); }); 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'); }); }); const annotationLayer1 = new DashboardAnnotationsDataLayer({ key: 'layer1', query: { datasource: { type: 'grafana', uid: '-- Grafana --', }, name: 'query1', enable: true, iconColor: 'red', }, name: 'layer1', isEnabled: true, isHidden: false, }); const annotationLayer2 = new DashboardAnnotationsDataLayer({ key: 'layer2', query: { datasource: { type: 'prometheus', uid: 'abcdef', }, name: 'query2', enable: true, iconColor: 'blue', }, name: 'layer2', isEnabled: true, isHidden: true, }); // this could happen if a dahboard was created from code and the datasource was not defined const annotationLayer3NoDsDefined = new DashboardAnnotationsDataLayer({ key: 'layer3', query: { name: 'query3', enable: true, iconColor: 'green', }, name: 'layer3', isEnabled: true, isHidden: true, }); const annotationLayers = [annotationLayer1, annotationLayer2, annotationLayer3NoDsDefined];