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
This commit is contained in:
Oscar Kilhed 2025-01-30 14:24:37 +01:00 committed by GitHub
parent 1e3783cc11
commit 800c9fa3e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 717 additions and 181 deletions

View File

@ -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: {

View File

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

View File

@ -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 => ({

View File

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

View File

@ -2,3 +2,5 @@ export const GRAFANA_DATASOURCE_REF = {
name: 'grafana',
uid: 'grafana',
};
export const GRID_ROW_HEIGHT = 1;

View File

@ -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<DashboardV2Spec> = {
export const defaultDashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
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);
});

View File

@ -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<D
return dashboardScene;
}
function buildGridItem(gridItem: GridLayoutItemSpec, panel: PanelKind, yOverride?: number): DashboardGridItem {
const vizPanel = buildVizPanel(panel);
return new DashboardGridItem({
key: `grid-item-${panel.spec.id}`,
x: gridItem.x,
y: yOverride ?? gridItem.y,
width: gridItem.repeat?.direction === 'h' ? 24 : gridItem.width,
height: gridItem.height,
itemHeight: gridItem.height,
body: vizPanel,
variableName: gridItem.repeat?.value,
repeatDirection: gridItem.repeat?.direction,
maxPerRow: gridItem.repeat?.maxPerRow,
});
}
function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridItemLike[] {
const gridElements = dashboard.layout.spec.items;
@ -240,20 +261,7 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
}
if (panel.kind === 'Panel') {
const vizPanel = buildVizPanel(panel);
return new DashboardGridItem({
key: `grid-item-${panel.spec.id}`,
x: element.spec.x,
y: element.spec.y,
width: element.spec.repeat?.direction === 'h' ? 24 : element.spec.width,
height: element.spec.height,
itemHeight: element.spec.height,
body: vizPanel,
variableName: element.spec.repeat?.value,
repeatDirection: element.spec.repeat?.direction,
maxPerRow: element.spec.repeat?.maxPerRow,
});
return buildGridItem(element.spec, panel);
} else if (panel.kind === 'LibraryPanel') {
const libraryPanel = buildLibraryPanel(panel);
@ -269,7 +277,30 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
} else {
throw new Error(`Unknown element kind: ${element.kind}`);
}
} else if (element.kind === 'GridLayoutRow') {
const children = element.spec.elements.map((gridElement) => {
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}`);
}
});

View File

@ -194,6 +194,7 @@ export function vizPanelToPanel(
name: libPanel!.state.name,
uid: libPanel!.state.uid,
},
type: 'library-panel-ref',
} as Panel;
return panel;

View File

@ -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',
}),
}),
}),
],
}),
],
}),
}),

View File

@ -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<GridLayoutItemKind | GridLayoutRowKind> {
const body = state.body;
let elements: GridLayoutItemKind[] = [];
let elements: Array<GridLayoutItemKind | GridLayoutRowKind> = [];
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() ?? [];

View File

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

View File

@ -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<Panel | RowPanel>
): [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<DataLink>((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<DataLink>((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<Panel, 'libraryPanel' | 'id' | 'title' | 'gridPos'> {}
interface LibraryPanelDTO extends Pick<Panel, 'libraryPanel' | 'id' | 'title' | 'gridPos' | 'type'> {}
function getPanelsV1(
panels: DashboardV2Spec['elements'],
layout: DashboardV2Spec['layout']
): Array<Panel | LibraryPanelDTO> {
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<DashboardLink>((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<Panel | LibraryPanelDTO | RowPanel> = [];
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<DashboardLink>((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 {

View File

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