Dashboard Schema V2: Improve diffing (#100022)

* improve diffing

* define dash spec props a-z

* Fix

* sort deep initialSaveModel

* update tests

* Fix test, description, and query ds issues

* Fix seralizer test

* response transformers

* skip panelMerge tests
This commit is contained in:
Haris Rozajac 2025-02-13 07:20:17 -07:00 committed by GitHub
parent afe8b08a48
commit 9ad6653871
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 97 additions and 64 deletions

View File

@ -1324,8 +1324,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/core/utils/richHistory.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@ -6,10 +6,7 @@ import (
DashboardV2Spec: {
// Title of dashboard.
title: string
// Description of dashboard.
description?: string
annotations: [...AnnotationQueryKind]
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
@ -17,6 +14,19 @@ DashboardV2Spec: {
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync
// Description of dashboard.
description?: string
// Whether a dashboard is editable or not.
editable?: bool | *true
elements: [ElementReference.name]: Element
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
// Links with references to other dashboards or external websites.
links: [...DashboardLink]
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
@ -25,30 +35,20 @@ DashboardV2Spec: {
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool
// Whether a dashboard is editable or not.
editable?: bool | *true
// Links with references to other dashboards or external websites.
links: [...DashboardLink]
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16
// Tags associated with dashboard.
tags: [...string]
timeSettings: TimeSettingsSpec
// Title of dashboard.
title: string
// Configured template variables.
variables: [...VariableKind]
elements: [ElementReference.name]: Element
annotations: [...AnnotationQueryKind]
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16
}
// Supported dashboard elements

View File

@ -5,49 +5,50 @@ import * as common from '@grafana/schema';
export interface DashboardV2Spec {
// Title of dashboard.
title: string;
// Description of dashboard.
description?: string;
annotations: AnnotationQueryKind[];
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync;
// Description of dashboard.
description?: string;
// Whether a dashboard is editable or not.
editable?: boolean;
elements: Record<string, Element>;
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Links with references to other dashboards or external websites.
links: DashboardLink[];
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
liveNow?: boolean;
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: boolean;
// Whether a dashboard is editable or not.
editable?: boolean;
// Links with references to other dashboards or external websites.
links: DashboardLink[];
// Tags associated with dashboard.
tags: string[];
timeSettings: TimeSettingsSpec;
// Configured template variables.
variables: VariableKind[];
elements: Record<string, Element>;
annotations: AnnotationQueryKind[];
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: number;
// Tags associated with dashboard.
tags: string[];
timeSettings: TimeSettingsSpec;
// Title of dashboard.
title: string;
// Configured template variables.
variables: VariableKind[];
}
export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
title: "",
annotations: [],
cursorSync: "Off",
preload: false,
editable: true,
elements: {},
layout: defaultGridLayoutKind(),
links: [],
preload: false,
tags: [],
timeSettings: defaultTimeSettingsSpec(),
title: "",
variables: [],
elements: {},
annotations: [],
layout: defaultGridLayoutKind(),
});
// Supported dashboard elements

View File

@ -7,6 +7,7 @@ describe('objects', () => {
deeper: 10,
foo: null,
arr: [null, 1, 'hello'],
value: -Infinity,
},
bar: undefined,
simple: 'A',

View File

@ -1,16 +1,17 @@
import { isArray, isPlainObject } from 'lodash';
/** @returns a deep clone of the object, but with any null value removed */
export function sortedDeepCloneWithoutNulls<T extends {}>(value: T): T {
export function sortedDeepCloneWithoutNulls<T>(value: T): T {
if (isArray(value)) {
return value.map(sortedDeepCloneWithoutNulls) as unknown as T;
}
if (isPlainObject(value)) {
return Object.keys(value)
return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
const v = (value as any)[key];
if (v != null) {
// Remove null values and also -Infinity which is not a valid JSON value
if (v != null && v !== -Infinity) {
acc[key] = sortedDeepCloneWithoutNulls(v);
}
return acc;

View File

@ -30,6 +30,7 @@ import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel';
import store from 'app/core/store';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@ -674,7 +675,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
saveModel?: Dashboard | DashboardV2Spec,
meta?: DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
): void {
this._serializer.initialSaveModel = saveModel;
this._serializer.initialSaveModel = sortedDeepCloneWithoutNulls(saveModel);
this._serializer.metadata = meta;
}

View File

@ -36,6 +36,26 @@ jest.mock('@grafana/runtime', () => ({
getInstanceSettings: jest.fn(),
};
},
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: {
settings: {
defaultDatasource: '-- Grafana --',
datasources: {
'-- Grafana --': {
name: 'Grafana',
meta: { id: 'grafana' },
type: 'datasource',
},
prometheus: {
name: 'prometheus',
meta: { id: 'prometheus' },
type: 'datasource',
},
},
},
},
},
}));
describe('DashboardSceneSerializer', () => {

View File

@ -293,7 +293,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
});
it('should set panel ds as undefined if it is not mixed DS', () => {
it('should set ds if it is not mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
kind: 'PanelQuery',
@ -317,10 +317,13 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(3);
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
expect(getQueryRunnerFor(vizPanels[0])?.state.queries[0].datasource).toEqual({
type: 'prometheus',
uid: 'datasource1',
});
});
it('should set panel ds as mixed if one ds is undefined', () => {
it('should set panel ds as mixed if no panels have ds defined', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({

View File

@ -234,7 +234,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
}
});
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource;
}
function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery {

View File

@ -11,6 +11,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import {
DashboardV2Spec,
@ -73,7 +74,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
const dashboardSchemaV2: DeepPartial<DashboardV2Spec> = {
//dashboard settings
title: sceneDash.title,
description: sceneDash.description ?? '',
description: sceneDash.description,
cursorSync: getCursorSync(sceneDash),
liveNow: getLiveNow(sceneDash),
preload: sceneDash.preload,
@ -116,7 +117,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
try {
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
return dashboardSchemaV2;
return sortedDeepCloneWithoutNulls(dashboardSchemaV2);
}
// should never reach this point, validation should throw an error
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);
@ -241,7 +242,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
const queries: PanelQueryKind[] = [];
const queryRunner = getQueryRunnerFor(vizPanel);
const vizPanelQueries = queryRunner?.state.queries;
const datasource = queryRunner?.state.datasource;
const datasource = queryRunner?.state.datasource ?? getDefaultDataSourceRef();
if (vizPanelQueries) {
vizPanelQueries.forEach((query) => {
@ -250,7 +251,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
spec: omit(query, 'datasource', 'refId', 'hide'),
};
const querySpec: PanelQuerySpec = {
datasource: datasource ?? getDefaultDataSourceRef(),
datasource: query.datasource ?? datasource,
query: dataQuery,
refId: query.refId,
hidden: Boolean(query.hide),
@ -446,7 +447,7 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec {
if ('title' in dash && typeof dash.title !== 'string') {
throw new Error('Title is not a string');
}
if ('description' in dash && typeof dash.description !== 'string') {
if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') {
throw new Error('Description is not a string');
}
if ('cursorSync' in dash && typeof dash.cursorSync !== 'string') {

View File

@ -487,7 +487,7 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[0].spec).toEqual({
element: {
kind: 'ElementReference',
name: '1',
name: 'panel-1',
},
x: 0,
y: 0,
@ -495,7 +495,7 @@ describe('ResponseTransformers', () => {
height: 8,
repeat: { value: 'var1', direction: 'h', mode: 'variable', maxPerRow: undefined },
});
expect(spec.elements['1']).toEqual({
expect(spec.elements['panel-1']).toEqual({
kind: 'Panel',
spec: {
title: 'Panel Title',
@ -550,14 +550,14 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[1].spec).toEqual({
element: {
kind: 'ElementReference',
name: '2',
name: 'panel-2',
},
x: 0,
y: 8,
width: 12,
height: 8,
});
expect(spec.elements['2']).toEqual({
expect(spec.elements['panel-2']).toEqual({
kind: 'LibraryPanel',
spec: {
libraryPanel: {
@ -580,7 +580,7 @@ describe('ResponseTransformers', () => {
expect(panelInRow).toEqual({
element: {
kind: 'ElementReference',
name: '4',
name: 'panel-4',
},
x: 0,
y: 0,
@ -598,7 +598,7 @@ describe('ResponseTransformers', () => {
expect(panelInCollapsedRow).toEqual({
element: {
kind: 'ElementReference',
name: '5',
name: 'panel-5',
},
x: 0,
y: 0,

View File

@ -383,7 +383,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
return [panelKind, p.id!.toString()];
return [panelKind, `panel-${p.id}`];
} else {
// PanelKind
@ -433,7 +433,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
return [panelKind, p.id!.toString()];
return [panelKind, `panel-${p.id}`];
}
}

View File

@ -286,6 +286,8 @@ export class DashboardModel implements TimeModel {
*
* @internal and experimental
*/
// TODO: remove this as it's not being used anymore
// Also remove public/app/features/dashboard/utils/panelMerge.ts
updatePanels(panels: IPanelModel[]): PanelMergeInfo {
const info = mergePanels(this.panels, panels ?? []);
if (info.changed) {

View File

@ -4,7 +4,8 @@ import { FieldColorModeId, ThresholdsMode } from '@grafana/schema/src';
import { DashboardModel } from '../state/DashboardModel';
import { createDashboardModelFixture, createPanelSaveModel } from '../state/__fixtures__/dashboardFixtures';
describe('Merge dashboard panels', () => {
// skipping these tests because panelMerge is not used
describe.skip('Merge dashboard panels', () => {
describe('simple changes', () => {
let dashboard: DashboardModel;
let rawPanels: PanelModel[];