From 800c9fa3e6c2d448a91111f923132bc5d972a4cb Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 30 Jan 2025 14:24:37 +0100 Subject: [PATCH] SchemaV2: Rows in dashboard schema v2 (#99239) * Testing out rows in schemav2 * update schema * loading sort of works * descibe position in relation to row * add row repeats by variable * explain ts-expect-error * Save repeats as well * Update tests for repeat behavior of rows * Don't add the clones of the repeated rows * Add row support for response transformer for V2 * Add row actions * fix panel name * fix merge issue * fix tests * Implement ensureV1 * set key of GridRow * fix lint issue * When going from V2 to V1 rows should be assigned unique ids following max panel id * remove old comment * Add panel repeats in V2 -> V1 transform --- .../dashboard/v2alpha0/dashboard.schema.cue | 20 +- .../src/schema/dashboard/v2alpha0/examples.ts | 86 +++- .../schema/dashboard/v2alpha0/types.gen.ts | 37 +- ...sformSceneToSaveModelSchemaV2.test.ts.snap | 57 ++- .../dashboard-scene/serialization/const.ts | 2 + .../transformSaveModelSchemaV2ToScene.test.ts | 21 +- .../transformSaveModelSchemaV2ToScene.ts | 59 ++- .../transformSceneToSaveModel.ts | 1 + .../transformSceneToSaveModelSchemaV2.test.ts | 29 ++ .../transformSceneToSaveModelSchemaV2.ts | 66 ++- .../api/ResponseTransformers.test.ts | 119 +++++- .../dashboard/api/ResponseTransformers.ts | 399 ++++++++++++------ public/app/features/dashboard/api/utils.ts | 2 + 13 files changed, 717 insertions(+), 181 deletions(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 5f164f1aded..170094040c1 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -483,6 +483,11 @@ RepeatOptions: { maxPerRow?: int } +RowRepeatOptions: { + mode: RepeatMode, + value: string +} + GridLayoutItemSpec: { x: int y: int @@ -497,8 +502,21 @@ GridLayoutItemKind: { spec: GridLayoutItemSpec } +GridLayoutRowKind: { + kind: "GridLayoutRow" + spec: GridLayoutRowSpec +} + +GridLayoutRowSpec: { + y: int + collapsed: bool + title: string + elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard. + repeat?: RowRepeatOptions +} + GridLayoutSpec: { - items: [...GridLayoutItemKind] + items: [...GridLayoutItemKind | GridLayoutRowKind] } GridLayoutKind: { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts index ed513da75c4..340ffeeef12 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts @@ -191,6 +191,60 @@ export const handyTestingSchema: DashboardV2Spec = { }, }, }, + 'panel-3': { + kind: 'Panel', + spec: { + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'datasource1', + }, + query: { + kind: 'prometheus', + spec: { + expr: 'test-query', + }, + }, + hidden: false, + }, + }, + ], + queryOptions: { + timeFrom: '1h', + maxDataPoints: 100, + timeShift: '1h', + queryCachingTTL: 60, + interval: '1m', + cacheTimeout: '1m', + hideTimeOverride: false, + }, + transformations: [], + }, + }, + description: 'Test Description', + links: [], + title: 'Test Panel 3', + id: 3, + vizConfig: { + kind: 'timeseries', + spec: { + fieldConfig: { + defaults: {}, + overrides: [], + }, + options: {}, + pluginVersion: '7.0.0', + }, + }, + }, + }, }, layout: { kind: 'GridLayout', @@ -203,8 +257,8 @@ export const handyTestingSchema: DashboardV2Spec = { kind: 'ElementReference', name: 'panel-1', }, - height: 100, - width: 200, + height: 10, + width: 10, x: 0, y: 0, repeat: { @@ -221,18 +275,42 @@ export const handyTestingSchema: DashboardV2Spec = { kind: 'ElementReference', name: 'panel-2', }, - height: 100, + height: 10, width: 200, x: 0, y: 2, }, }, + { + kind: 'GridLayoutRow', + spec: { + y: 20, + collapsed: false, + title: 'Row 1', + repeat: { value: 'customVar', mode: 'variable' }, + elements: [ + { + kind: 'GridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: 'panel-3', + }, + height: 10, + width: 10, + x: 0, + y: 0, + }, + }, + ], + }, + }, ], }, }, links: [ { - asDropdown: false, + asDropdown: true, icon: '', includeVars: false, keepTime: false, diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index 7b7ef1cda07..0e41bb7fedc 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -700,6 +700,16 @@ export const defaultRepeatOptions = (): RepeatOptions => ({ value: "", }); +export interface RowRepeatOptions { + mode: "variable"; + value: string; +} + +export const defaultRowRepeatOptions = (): RowRepeatOptions => ({ + mode: RepeatMode, + value: "", +}); + export interface GridLayoutItemSpec { x: number; y: number; @@ -728,8 +738,33 @@ export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({ spec: defaultGridLayoutItemSpec(), }); +export interface GridLayoutRowKind { + kind: "GridLayoutRow"; + spec: GridLayoutRowSpec; +} + +export const defaultGridLayoutRowKind = (): GridLayoutRowKind => ({ + kind: "GridLayoutRow", + spec: defaultGridLayoutRowSpec(), +}); + +export interface GridLayoutRowSpec { + y: number; + collapsed: boolean; + title: string; + elements: GridLayoutItemKind[]; + repeat?: RowRepeatOptions; +} + +export const defaultGridLayoutRowSpec = (): GridLayoutRowSpec => ({ + y: 0, + collapsed: false, + title: "", + elements: [], +}); + export interface GridLayoutSpec { - items: GridLayoutItemKind[]; + items: (GridLayoutItemKind | GridLayoutRowKind)[]; } export const defaultGridLayoutSpec = (): GridLayoutSpec => ({ diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 3ba7caea2c7..a62ba3c1daf 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -88,6 +88,34 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, }, }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [], + "queryOptions": {}, + "transformations": [], + }, + }, + "description": "Test Description 2", + "id": 2, + "links": [], + "title": "Test Panel 2", + "vizConfig": { + "kind": "graph", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [], + }, + "options": {}, + "pluginVersion": "7.0.0", + }, + }, + }, + }, }, "layout": { "kind": "GridLayout", @@ -100,12 +128,39 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "kind": "ElementReference", "name": "panel-1", }, - "height": 0, + "height": 10, "width": 0, "x": 0, "y": 0, }, }, + { + "kind": "GridLayoutRow", + "spec": { + "collapsed": false, + "elements": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2", + }, + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + }, + ], + "repeat": { + "mode": "variable", + "value": "customVar", + }, + "title": "Test Row", + "y": 10, + }, + }, ], }, }, diff --git a/public/app/features/dashboard-scene/serialization/const.ts b/public/app/features/dashboard-scene/serialization/const.ts index f7e03e4328b..96ba9a094d3 100644 --- a/public/app/features/dashboard-scene/serialization/const.ts +++ b/public/app/features/dashboard-scene/serialization/const.ts @@ -2,3 +2,5 @@ export const GRAFANA_DATASOURCE_REF = { name: 'grafana', uid: 'grafana', }; + +export const GRID_ROW_HEIGHT = 1; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 0b8fc1ee77d..64df731435d 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -13,6 +13,7 @@ import { GroupByVariable, AdHocFiltersVariable, SceneDataTransformer, + SceneGridRow, } from '@grafana/scenes'; import { AdhocVariableKind, @@ -20,6 +21,7 @@ import { CustomVariableKind, DashboardV2Spec, DatasourceVariableKind, + GridLayoutItemSpec, GroupByVariableKind, IntervalVariableKind, QueryVariableKind, @@ -44,7 +46,7 @@ import { } from './transformSaveModelSchemaV2ToScene'; import { transformCursorSynctoEnum } from './transformToV2TypesUtils'; -const defaultDashboard: DashboardWithAccessInfo = { +export const defaultDashboard: DashboardWithAccessInfo = { kind: 'DashboardWithAccessInfo', metadata: { name: 'dashboard-uid', @@ -220,16 +222,16 @@ describe('transformSaveModelSchemaV2ToScene', () => { // VizPanel const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); - expect(vizPanels).toHaveLength(2); + expect(vizPanels).toHaveLength(3); // Layout const layout = scene.state.body as DefaultGridLayoutManager; // Panel const panel = getPanelElement(dash, 'panel-1')!; - expect(layout.state.grid.state.children.length).toBe(2); + expect(layout.state.grid.state.children.length).toBe(3); expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${panel.spec.id}`); - const gridLayoutItemSpec = dash.layout.spec.items[0].spec; + const gridLayoutItemSpec = dash.layout.spec.items[0].spec as GridLayoutItemSpec; expect(layout.state.grid.state.children[0].state.width).toBe(gridLayoutItemSpec.width); expect(layout.state.grid.state.children[0].state.height).toBe(gridLayoutItemSpec.height); expect(layout.state.grid.state.children[0].state.x).toBe(gridLayoutItemSpec.x); @@ -240,7 +242,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Library Panel const libraryPanel = getLibraryPanelElement(dash, 'panel-2')!; expect(layout.state.grid.state.children[1].state.key).toBe(`grid-item-${libraryPanel.spec.id}`); - const libraryGridLayoutItemSpec = dash.layout.spec.items[1].spec; + const libraryGridLayoutItemSpec = dash.layout.spec.items[1].spec as GridLayoutItemSpec; expect(layout.state.grid.state.children[1].state.width).toBe(libraryGridLayoutItemSpec.width); expect(layout.state.grid.state.children[1].state.height).toBe(libraryGridLayoutItemSpec.height); expect(layout.state.grid.state.children[1].state.x).toBe(libraryGridLayoutItemSpec.x); @@ -248,6 +250,9 @@ describe('transformSaveModelSchemaV2ToScene', () => { const vizLibraryPanel = vizPanels.find((p) => p.state.key === 'panel-2')!; validateVizPanel(vizLibraryPanel, dash); + expect((layout.state.grid.state.children[2] as SceneGridRow).state.isCollapsed).toBe(false); + expect((layout.state.grid.state.children[2] as SceneGridRow).state.y).toBe(20); + // Transformations const panelWithTransformations = vizPanels.find((p) => p.state.key === 'panel-1')!; expect((panelWithTransformations.state.$data as SceneDataTransformer)?.state.transformations[0]).toEqual( @@ -278,7 +283,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { const scene = transformSaveModelSchemaV2ToScene(dashboard); const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); - expect(vizPanels.length).toBe(2); + expect(vizPanels.length).toBe(3); expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.type).toBe('mixed'); expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME); }); @@ -306,7 +311,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { const scene = transformSaveModelSchemaV2ToScene(dashboard); const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); - expect(vizPanels.length).toBe(2); + expect(vizPanels.length).toBe(3); expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined(); }); @@ -330,7 +335,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { const scene = transformSaveModelSchemaV2ToScene(dashboard); const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); - expect(vizPanels.length).toBe(2); + expect(vizPanels.length).toBe(3); expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.type).toBe('mixed'); expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME); }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 579f74b44d3..4a7b005805d 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -16,6 +16,7 @@ import { SceneDataTransformer, SceneGridItemLike, SceneGridLayout, + SceneGridRow, SceneObject, SceneQueryRunner, SceneRefreshPicker, @@ -44,6 +45,7 @@ import { defaultIntervalVariableKind, defaultQueryVariableKind, defaultTextVariableKind, + GridLayoutItemSpec, GroupByVariableKind, IntervalVariableKind, LibraryPanelKind, @@ -79,13 +81,16 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; import { PanelTimeRange } from '../scene/PanelTimeRange'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { AngularDeprecation } from '../scene/angular/AngularDeprecation'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; +import { RowActions } from '../scene/row-actions/RowActions'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState'; import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils'; +import { GRID_ROW_HEIGHT } from './const'; import { SnapshotVariable } from './custom-variables/SnapshotVariable'; import { registerPanelInteractionsReporter } from './transformSaveModelToScene'; import { @@ -228,6 +233,22 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo { + const panel = dashboard.elements[gridElement.spec.element.name]; + if (panel.kind === 'Panel') { + return buildGridItem(gridElement.spec, panel, element.spec.y + GRID_ROW_HEIGHT + gridElement.spec.y); + } else { + throw new Error(`Unknown element kind: ${gridElement.kind}`); + } + }); + let behaviors: SceneObject[] | undefined; + if (element.spec.repeat) { + behaviors = [new RowRepeaterBehavior({ variableName: element.spec.repeat.value })]; + } + return new SceneGridRow({ + y: element.spec.y, + isCollapsed: element.spec.collapsed, + title: element.spec.title, + $behaviors: behaviors, + actions: new RowActions({}), + children, + }); } else { + // If this has been validated by the schema we should never reach this point, which is why TS is telling us this is an error. + //@ts-expect-error throw new Error(`Unknown layout element kind: ${element.kind}`); } }); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index c1b6c96ec07..c5a945c86fc 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -194,6 +194,7 @@ export function vizPanelToPanel( name: libPanel!.state.name, uid: libPanel!.state.uid, }, + type: 'library-panel-ref', } as Panel; return panel; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index ce5a898e5ed..de87184820a 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -10,6 +10,7 @@ import { IntervalVariable, QueryVariable, SceneGridLayout, + SceneGridRow, SceneRefreshPicker, SceneTimePicker, SceneTimeRange, @@ -29,6 +30,7 @@ import { DashboardControls } from '../scene/DashboardControls'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; @@ -140,6 +142,8 @@ describe('transformSceneToSaveModelSchemaV2', () => { isLazy: false, children: [ new DashboardGridItem({ + y: 0, + height: 10, body: new VizPanel({ key: 'panel-1', pluginId: 'timeseries', @@ -172,6 +176,31 @@ describe('transformSceneToSaveModelSchemaV2', () => { // 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', + }), + }), + }), + ], + }), ], }), }), diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index fc1a1845d59..6253cf9a187 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -7,6 +7,7 @@ import { dataLayers, SceneDataQuery, SceneDataTransformer, + SceneGridRow, SceneVariableSet, VizPanel, } from '@grafana/scenes'; @@ -38,6 +39,7 @@ import { LibraryPanelKind, Element, RepeatOptions, + GridLayoutRowKind, DashboardCursorSync, FieldConfig, FieldColor, @@ -45,6 +47,7 @@ import { import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; @@ -57,6 +60,7 @@ import { calculateGridItemDimensions, } from '../utils/utils'; +import { GRID_ROW_HEIGHT } from './const'; import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; import { colorIdEnumToColorIdV2, transformCursorSynctoEnum } from './transformToV2TypesUtils'; @@ -154,9 +158,12 @@ function getLiveNow(state: DashboardSceneState) { return Boolean(liveNow); } -function getGridLayoutItems(state: DashboardSceneState, isSnapshot?: boolean): GridLayoutItemKind[] { +function getGridLayoutItems( + state: DashboardSceneState, + isSnapshot?: boolean +): Array { const body = state.body; - let elements: GridLayoutItemKind[] = []; + let elements: Array = []; if (body instanceof DefaultGridLayoutManager) { for (const child of body.state.grid.state.children) { if (child instanceof DashboardGridItem) { @@ -166,23 +173,24 @@ function getGridLayoutItems(state: DashboardSceneState, isSnapshot?: boolean): G } else { elements.push(gridItemToGridLayoutItemKind(child, isSnapshot)); } + } else if (child instanceof SceneGridRow) { + if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { + // Skip repeat rows + continue; + } + elements.push(gridRowToLayoutRowKind(child, isSnapshot)); } - - // TODO: OLD transformer code - // if (child instanceof SceneGridRow) { - // // Skip repeat clones or when generating a snapshot - // if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { - // continue; - // } - // gridRowToSaveModel(child, panels, isSnapshot); - // } } } return elements; } -export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, isSnapshot = false): GridLayoutItemKind { +export function gridItemToGridLayoutItemKind( + gridItem: DashboardGridItem, + isSnapshot = false, + yOverride?: number +): GridLayoutItemKind { let elementGridItem: GridLayoutItemKind | undefined; let x = 0, y = 0, @@ -208,7 +216,7 @@ export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, isSnap kind: 'GridLayoutItem', spec: { x, - y, + y: yOverride ?? y, width: width, height: height, element: { @@ -242,6 +250,38 @@ export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, isSnap return elementGridItem; } +function getRowRepeat(row: SceneGridRow): RepeatOptions | undefined { + if (row.state.$behaviors) { + for (const behavior of row.state.$behaviors) { + if (behavior instanceof RowRepeaterBehavior) { + return { value: behavior.state.variableName, mode: 'variable' }; + } + } + } + return undefined; +} + +function gridRowToLayoutRowKind(row: SceneGridRow, isSnapshot = false): GridLayoutRowKind { + const children = row.state.children.map((child) => { + if (!(child instanceof DashboardGridItem)) { + throw new Error('Unsupported row child type'); + } + const y = (child.state.y ?? 0) - (row.state.y ?? 0) - GRID_ROW_HEIGHT; + return gridItemToGridLayoutItemKind(child, isSnapshot, y); + }); + + return { + kind: 'GridLayoutRow', + spec: { + title: row.state.title, + y: row.state.y ?? 0, + collapsed: Boolean(row.state.isCollapsed), + elements: children, + repeat: getRowRepeat(row), + }, + }; +} + function getElements(state: DashboardSceneState) { const panels = state.body.getVizPanels() ?? []; diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 95034c757ac..3a677bf658e 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -1,5 +1,12 @@ import { AnnotationQuery, DataQuery, VariableModel, VariableRefresh, Panel } from '@grafana/schema'; -import { DashboardV2Spec, PanelKind, VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { + DashboardV2Spec, + GridLayoutItemKind, + GridLayoutItemSpec, + GridLayoutRowSpec, + PanelKind, + VariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples'; import { AnnoKeyCreatedBy, @@ -310,6 +317,8 @@ describe('ResponseTransformers', () => { transparent: false, links: [], transformations: [], + repeat: 'var1', + repeatDirection: 'h', }, { id: 2, @@ -321,6 +330,69 @@ describe('ResponseTransformers', () => { }, gridPos: { x: 0, y: 8, w: 12, h: 8 }, }, + { + id: 3, + type: 'row', + title: 'Row test title', + gridPos: { x: 0, y: 16, w: 12, h: 1 }, + panels: [], + collapsed: false, + }, + { + id: 4, + type: 'timeseries', + title: 'Panel in row', + gridPos: { x: 0, y: 17, w: 16, h: 8 }, + targets: [ + { + refId: 'A', + datasource: 'datasource1', + expr: 'test-query', + hide: false, + }, + ], + datasource: { + type: 'prometheus', + uid: 'datasource1', + }, + fieldConfig: { defaults: {}, overrides: [] }, + options: {}, + transparent: false, + links: [], + transformations: [], + }, + { + id: 5, + type: 'row', + title: 'Collapsed row title', + gridPos: { x: 0, y: 25, w: 12, h: 1 }, + panels: [ + { + id: 5, + type: 'timeseries', + title: 'Panel in collapsed row', + gridPos: { x: 0, y: 26, w: 16, h: 8 }, + targets: [ + { + refId: 'A', + datasource: 'datasource1', + expr: 'test-query', + hide: false, + }, + ], + datasource: { + type: 'prometheus', + uid: 'datasource1', + }, + fieldConfig: { defaults: {}, overrides: [] }, + options: {}, + transparent: false, + links: [], + transformations: [], + }, + ], + collapsed: true, + }, ], }; @@ -396,7 +468,7 @@ describe('ResponseTransformers', () => { expect(spec.annotations).toEqual([]); // Panel - expect(spec.layout.spec.items).toHaveLength(2); + expect(spec.layout.spec.items).toHaveLength(4); expect(spec.layout.spec.items[0].spec).toEqual({ element: { kind: 'ElementReference', @@ -406,6 +478,7 @@ describe('ResponseTransformers', () => { y: 0, width: 12, height: 8, + repeat: { value: 'var1', direction: 'h', mode: 'variable', maxPerRow: undefined }, }); expect(spec.elements['1']).toEqual({ kind: 'Panel', @@ -481,6 +554,43 @@ describe('ResponseTransformers', () => { }, }); + const rowSpec = spec.layout.spec.items[2].spec as GridLayoutRowSpec; + + expect(rowSpec.collapsed).toBe(false); + expect(rowSpec.title).toBe('Row test title'); + expect(rowSpec.repeat).toBeUndefined(); + + const panelInRow = rowSpec.elements[0].spec as GridLayoutItemSpec; + + expect(panelInRow).toEqual({ + element: { + kind: 'ElementReference', + name: '4', + }, + x: 0, + y: 0, + width: 16, + height: 8, + }); + + const collapsedRowSpec = spec.layout.spec.items[3].spec as GridLayoutRowSpec; + expect(collapsedRowSpec.collapsed).toBe(true); + expect(collapsedRowSpec.title).toBe('Collapsed row title'); + expect(collapsedRowSpec.repeat).toBeUndefined(); + + const panelInCollapsedRow = collapsedRowSpec.elements[0].spec as GridLayoutItemSpec; + + expect(panelInCollapsedRow).toEqual({ + element: { + kind: 'ElementReference', + name: '5', + }, + x: 0, + y: 0, + width: 16, + height: 8, + }); + // Variables validateVariablesV1ToV2(spec.variables[0], dashboardV1.templating?.list?.[0]); validateVariablesV1ToV2(spec.variables[1], dashboardV1.templating?.list?.[1]); @@ -645,6 +755,9 @@ describe('ResponseTransformers', () => { uid: 'uid-for-library-panel', name: 'Library Panel', }); + expect(dashboard.panels![2].type).toBe('row'); + expect(dashboard.panels![2].id).toBe(4); // Row id should be assigned to unique number following the highest id of panels. + expect(dashboard.panels![3].type).toBe('timeseries'); }); describe('getPanelQueries', () => { @@ -756,7 +869,7 @@ describe('ResponseTransformers', () => { expect(v1.transformations).toEqual(v2Spec.data.spec.transformations.map((t) => t.spec)); const layoutElement = layoutV2.spec.items.find( (item) => item.kind === 'GridLayoutItem' && item.spec.element.name === panelKey - ); + ) as GridLayoutItemKind; expect(v1.gridPos?.x).toEqual(layoutElement?.spec.x); expect(v1.gridPos?.y).toEqual(layoutElement?.spec.y); expect(v1.gridPos?.w).toEqual(layoutElement?.spec.width); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index fa352b029c7..1831507291b 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -5,6 +5,7 @@ import { DataQuery, DataSourceRef, Panel, + RowPanel, VariableModel, VariableType, FieldConfigSource as FieldConfigSourceV1, @@ -34,6 +35,10 @@ import { IntervalVariableKind, TextVariableKind, GroupByVariableKind, + LibraryPanelKind, + PanelKind, + GridLayoutRowKind, + GridLayoutItemKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DashboardLink, DataTransformerConfig } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen'; import { @@ -47,6 +52,7 @@ import { AnnoKeyUpdatedTimestamp, DeprecatedInternalId, } from 'app/features/apiserver/types'; +import { GRID_ROW_HEIGHT } from 'app/features/dashboard-scene/serialization/const'; import { TypedVariableModelV2 } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene'; import { getDefaultDataSourceRef } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2'; import { @@ -268,8 +274,9 @@ export const ResponseTransformers = { ensureV1Response, }; -// TODO[schema v2]: handle rows -function getElementsFromPanels(panels: Panel[]): [DashboardV2Spec['elements'], DashboardV2Spec['layout']] { +function getElementsFromPanels( + panels: Array +): [DashboardV2Spec['elements'], DashboardV2Spec['layout']] { const elements: DashboardV2Spec['elements'] = {}; const layout: DashboardV2Spec['layout'] = { kind: 'GridLayout', @@ -282,94 +289,156 @@ function getElementsFromPanels(panels: Panel[]): [DashboardV2Spec['elements'], D return [elements, layout]; } + let currentRow: GridLayoutRowKind | null = null; + // iterate over panels for (const p of panels) { - const elementName = p.id!.toString(); - - // LibraryPanelKind - if (p.libraryPanel) { - elements[elementName] = { - kind: 'LibraryPanel', - spec: { - libraryPanel: { - uid: p.libraryPanel.uid, - name: p.libraryPanel.name, - }, - id: p.id!, - title: p.title ?? '', - }, - }; - // PanelKind - } else { - // FIXME: for now we should skip row panels - if (p.type === 'row') { - continue; + if (isRowPanel(p)) { + if (currentRow) { + // Flush current row to layout before we create a new one + layout.spec.items.push(currentRow); } - const queries = getPanelQueries( - (p.targets as unknown as DataQuery[]) || [], - p.datasource || getDefaultDatasource() - ); - const transformations = getPanelTransformations(p.transformations || []); + const rowElements = []; - elements[elementName] = { - kind: 'Panel', - spec: { - title: p.title || '', - description: p.description || '', - vizConfig: { - kind: p.type, - spec: { - fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(), - options: p.options as any, - pluginVersion: p.pluginVersion!, - }, - }, - links: - p.links?.map((l) => ({ - title: l.title, - url: l.url || '', - targetBlank: l.targetBlank, - })) || [], - id: p.id!, - data: { - kind: 'QueryGroup', - spec: { - queries, - transformations, // TODO[schema v2]: handle transformations - queryOptions: { - cacheTimeout: p.cacheTimeout, - maxDataPoints: p.maxDataPoints, - interval: p.interval, - hideTimeOverride: p.hideTimeOverride, - queryCachingTTL: p.queryCachingTTL, - timeFrom: p.timeFrom, - timeShift: p.timeShift, - }, - }, - }, - }, - }; + for (const panel of p.panels) { + const [element, name] = buildElement(panel); + elements[name] = element; + rowElements.push(buildGridItemKind(panel, name, yOffsetInRows(panel, p.gridPos!.y))); + } + + currentRow = buildRowKind(p, rowElements); + } else { + const [element, elementName] = buildElement(p); + + elements[elementName] = element; + + if (currentRow) { + // Collect panels to current layout row + currentRow.spec.elements.push(buildGridItemKind(p, elementName, yOffsetInRows(p, currentRow.spec.y))); + } else { + layout.spec.items.push(buildGridItemKind(p, elementName)); + } } + } - layout.spec.items.push({ - kind: 'GridLayoutItem', - spec: { - x: p.gridPos!.x, - y: p.gridPos!.y, - width: p.gridPos!.w, - height: p.gridPos!.h, - element: { - kind: 'ElementReference', - name: elementName, - }, - }, - }); + if (currentRow) { + // Flush last row to layout + layout.spec.items.push(currentRow); } return [elements, layout]; } +function isRowPanel(panel: Panel | RowPanel): panel is RowPanel { + return panel.type === 'row'; +} + +function buildRowKind(p: RowPanel, elements: GridLayoutItemKind[]): GridLayoutRowKind { + return { + kind: 'GridLayoutRow', + spec: { + collapsed: p.collapsed, + title: p.title ?? '', + repeat: p.repeat ? { value: p.repeat, mode: 'variable' } : undefined, + y: p.gridPos?.y ?? 0, + elements, + }, + }; +} + +function buildGridItemKind(p: Panel, elementName: string, yOverride?: number): GridLayoutItemKind { + return { + kind: 'GridLayoutItem', + spec: { + x: p.gridPos!.x, + y: yOverride ?? p.gridPos!.y, + width: p.gridPos!.w, + height: p.gridPos!.h, + repeat: p.repeat + ? { value: p.repeat, mode: 'variable', direction: p.repeatDirection, maxPerRow: p.maxPerRow } + : undefined, + element: { + kind: 'ElementReference', + name: elementName!, + }, + }, + }; +} + +function yOffsetInRows(p: Panel, rowY: number): number { + return p.gridPos!.y - rowY - GRID_ROW_HEIGHT; +} + +function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] { + if (p.libraryPanel) { + // LibraryPanelKind + const panelKind: LibraryPanelKind = { + kind: 'LibraryPanel', + spec: { + libraryPanel: { + uid: p.libraryPanel.uid, + name: p.libraryPanel.name, + }, + id: p.id!, + title: p.title ?? '', + }, + }; + + return [panelKind, p.id!.toString()]; + } else { + // PanelKind + + const queries = getPanelQueries( + (p.targets as unknown as DataQuery[]) || [], + p.datasource || getDefaultDatasource() + ); + + const transformations = getPanelTransformations(p.transformations || []); + + const panelKind: PanelKind = { + kind: 'Panel', + spec: { + title: p.title || '', + description: p.description || '', + vizConfig: { + kind: p.type, + spec: { + fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(), + options: p.options as any, + pluginVersion: p.pluginVersion!, + }, + }, + links: + p.links?.map((l) => ({ + title: l.title, + url: l.url || '', + targetBlank: l.targetBlank, + })) || [], + id: p.id!, + data: { + kind: 'QueryGroup', + spec: { + queries, + transformations, + queryOptions: { + cacheTimeout: p.cacheTimeout, + maxDataPoints: p.maxDataPoints, + interval: p.interval, + hideTimeOverride: p.hideTimeOverride, + queryCachingTTL: p.queryCachingTTL, + timeFrom: p.timeFrom, + timeShift: p.timeShift, + }, + }, + }, + }, + }; + + return [panelKind, p.id!.toString()]; + } +} + function getDefaultDatasourceType() { // if there is no default datasource, return 'grafana' as default return getDefaultDataSourceRef()?.type ?? 'grafana'; @@ -778,72 +847,130 @@ function getAnnotationsV1(annotations: DashboardV2Spec['annotations']): Annotati }); } -interface LibraryPanelDTO extends Pick {} +interface LibraryPanelDTO extends Pick {} function getPanelsV1( panels: DashboardV2Spec['elements'], layout: DashboardV2Spec['layout'] ): Array { - return Object.entries(panels).map(([key, p]) => { - const layoutElement = layout.spec.items.find( - (item) => item.kind === 'GridLayoutItem' && item.spec.element.name === key - ); - const { x, y, width, height, repeat } = layoutElement?.spec || { x: 0, y: 0, width: 0, height: 0 }; - const gridPos = { x, y, w: width, h: height }; - if (p.kind === 'Panel') { - const panel = p.spec; - return { - id: panel.id, - type: panel.vizConfig.kind, - title: panel.title, - description: panel.description, - fieldConfig: transformMappingsToV1(panel.vizConfig.spec.fieldConfig), - options: panel.vizConfig.spec.options, - pluginVersion: panel.vizConfig.spec.pluginVersion, - links: - // @ts-expect-error - Panel link is wrongly typed as DashboardLink - panel.links?.map((l) => ({ - title: l.title, - url: l.url, - ...(l.targetBlank && { targetBlank: l.targetBlank }), - })) || [], - targets: panel.data.spec.queries.map((q) => { - return { - refId: q.spec.refId, - hide: q.spec.hidden, - datasource: q.spec.datasource, - ...q.spec.query.spec, - }; - }), - transformations: panel.data.spec.transformations.map((t) => t.spec), - gridPos, - cacheTimeout: panel.data.spec.queryOptions.cacheTimeout, - maxDataPoints: panel.data.spec.queryOptions.maxDataPoints, - interval: panel.data.spec.queryOptions.interval, - hideTimeOverride: panel.data.spec.queryOptions.hideTimeOverride, - queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL, - timeFrom: panel.data.spec.queryOptions.timeFrom, - timeShift: panel.data.spec.queryOptions.timeShift, - transparent: panel.transparent, - ...(repeat?.value && { repeat: repeat.value }), - ...(repeat?.direction && { repeatDirection: repeat.direction }), - ...(repeat?.maxPerRow && { maxPerRow: repeat.maxPerRow }), - }; - } else if (p.kind === 'LibraryPanel') { - const panel = p.spec; - return { - id: panel.id, - title: panel.title, - gridPos, - libraryPanel: { - uid: panel.libraryPanel.uid, - name: panel.libraryPanel.name, + const panelsV1: Array = []; + + let maxPanelId = 0; + + for (const item of layout.spec.items) { + if (item.kind === 'GridLayoutItem') { + const panel = panels[item.spec.element.name]; + const v1Panel = transformV2PanelToV1Panel(panel, item); + panelsV1.push(v1Panel); + if (v1Panel.id ?? 0 > maxPanelId) { + maxPanelId = v1Panel.id ?? 0; + } + } else if (item.kind === 'GridLayoutRow') { + const row: RowPanel = { + id: -1, // Temporarily set to -1, updated later to be unique + type: 'row', + title: item.spec.title, + collapsed: item.spec.collapsed, + repeat: item.spec.repeat ? item.spec.repeat.value : undefined, + gridPos: { + x: 0, + y: item.spec.y, + w: 24, + h: GRID_ROW_HEIGHT, }, + panels: [], }; - } else { - throw new Error(`Unknown element kind: ${p}`); + + const rowPanels = []; + for (const panel of item.spec.elements) { + const panelElement = panels[panel.spec.element.name]; + const v1Panel = transformV2PanelToV1Panel(panelElement, panel, item.spec.y + GRID_ROW_HEIGHT + panel.spec.y); + rowPanels.push(v1Panel); + if (v1Panel.id ?? 0 > maxPanelId) { + maxPanelId = v1Panel.id ?? 0; + } + } + if (item.spec.collapsed) { + // When a row is collapsed, panels inside it are stored in the panels property. + row.panels = rowPanels; + panelsV1.push(row); + } else { + panelsV1.push(row); + panelsV1.push(...rowPanels); + } } - }); + } + + // Update row panel ids to be unique + for (const panel of panelsV1) { + if (panel.type === 'row' && panel.id === -1) { + panel.id = ++maxPanelId; + } + } + return panelsV1; +} + +function transformV2PanelToV1Panel( + p: PanelKind | LibraryPanelKind, + layoutElement: GridLayoutItemKind, + yOverride?: number +): Panel | LibraryPanelDTO { + const { x, y, width, height, repeat } = layoutElement?.spec || { x: 0, y: 0, width: 0, height: 0 }; + const gridPos = { x, y: yOverride ?? y, w: width, h: height }; + if (p.kind === 'Panel') { + const panel = p.spec; + return { + id: panel.id, + type: panel.vizConfig.kind, + title: panel.title, + description: panel.description, + fieldConfig: transformMappingsToV1(panel.vizConfig.spec.fieldConfig), + options: panel.vizConfig.spec.options, + pluginVersion: panel.vizConfig.spec.pluginVersion, + links: + // @ts-expect-error - Panel link is wrongly typed as DashboardLink + panel.links?.map((l) => ({ + title: l.title, + url: l.url, + ...(l.targetBlank && { targetBlank: l.targetBlank }), + })) || [], + targets: panel.data.spec.queries.map((q) => { + return { + refId: q.spec.refId, + hide: q.spec.hidden, + datasource: q.spec.datasource, + ...q.spec.query.spec, + }; + }), + transformations: panel.data.spec.transformations.map((t) => t.spec), + gridPos, + cacheTimeout: panel.data.spec.queryOptions.cacheTimeout, + maxDataPoints: panel.data.spec.queryOptions.maxDataPoints, + interval: panel.data.spec.queryOptions.interval, + hideTimeOverride: panel.data.spec.queryOptions.hideTimeOverride, + queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL, + timeFrom: panel.data.spec.queryOptions.timeFrom, + timeShift: panel.data.spec.queryOptions.timeShift, + transparent: panel.transparent, + ...(repeat?.value && { repeat: repeat.value }), + ...(repeat?.direction && { repeatDirection: repeat.direction }), + ...(repeat?.maxPerRow && { maxPerRow: repeat.maxPerRow }), + }; + } else if (p.kind === 'LibraryPanel') { + const panel = p.spec; + return { + id: panel.id, + title: panel.title, + gridPos, + libraryPanel: { + uid: panel.libraryPanel.uid, + name: panel.libraryPanel.name, + }, + type: 'library-panel-ref', + }; + } else { + throw new Error(`Unknown element kind: ${p}`); + } } export function transformMappingsToV1(fieldConfig: FieldConfigSource): FieldConfigSourceV1 { diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index 1b23e9a086e..89d0c9219d3 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -7,6 +7,8 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardWithAccessInfo } from './types'; +export const GRID_ROW_HEIGHT = 1; + export function getDashboardsApiVersion() { const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false';