From 7af2d1a6292681081bc48efe2f52543588ef564e Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 1 Sep 2021 08:03:56 -0700 Subject: [PATCH] Dashboards: support merging in new panels and options (#37905) --- .../dashboard/dashgrid/DashboardGrid.tsx | 17 +-- .../dashboard/state/DashboardModel.ts | 29 +++- .../features/dashboard/state/PanelModel.ts | 3 + .../dashboard/utils/panelMerge.test.ts | 132 ++++++++++++++++++ .../features/dashboard/utils/panelMerge.ts | 131 +++++++++++++++++ 5 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 public/app/features/dashboard/utils/panelMerge.test.ts create mode 100644 public/app/features/dashboard/utils/panelMerge.ts diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 7edaf0a0540..f9144f20ad6 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -29,7 +29,7 @@ export interface State { } export class DashboardGrid extends PureComponent { - private panelMap: { [id: string]: PanelModel } = {}; + private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; private windowWidth = 1920; @@ -57,8 +57,10 @@ export class DashboardGrid extends PureComponent { this.panelMap = {}; for (const panel of this.props.dashboard.panels) { - const stringId = panel.id.toString(); - this.panelMap[stringId] = panel; + if (!panel.key) { + panel.key = `panel-${panel.id}-${Date.now()}`; + } + this.panelMap[panel.key] = panel; if (!panel.gridPos) { console.log('panel without gridpos'); @@ -66,7 +68,7 @@ export class DashboardGrid extends PureComponent { } const panelPos: any = { - i: stringId, + i: panel.key, x: panel.gridPos.x, y: panel.gridPos.y, w: panel.gridPos.w, @@ -159,16 +161,15 @@ export class DashboardGrid extends PureComponent { for (const panel of this.props.dashboard.panels) { const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing }); - const itemKey = panel.id.toString(); // Update is in view state panel.isInView = this.isInView(panel); panelElements.push( { isViewing={panel.isViewing} > {(width: number, height: number) => { - return this.renderPanel(panel, width, height, itemKey); + return this.renderPanel(panel, width, height, panel.key); }} ); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 207fde267b9..b96ccb19665 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -34,6 +34,7 @@ import { TimeRange, TimeZone, UrlQueryValue, + PanelModel as IPanelModel, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KioskMode } from 'app/types'; import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; @@ -43,6 +44,7 @@ import { dispatch } from '../../../store/store'; import { isAllVariable } from '../../variables/utils'; import { DashboardPanelsChangedEvent, RefreshEvent, RenderEvent, TimeRangeUpdatedEvent } from 'app/types/events'; import { getTimeSrv } from '../services/TimeSrv'; +import { mergePanels, PanelMergeInfo } from '../utils/panelMerge'; export interface CloneOptions { saveVariables?: boolean; @@ -241,6 +243,26 @@ export class DashboardModel { return copy; } + /** + * This will load a new dashboard, but keep existing panels unchanged + * + * This function can be used to implement: + * 1. potentially faster loading dashboard loading + * 2. dynamic dashboard behavior + * 3. "live" dashboard editing + * + * @internal and experimental + */ + updatePanels(panels: IPanelModel[]): PanelMergeInfo { + const info = mergePanels(this.panels, panels ?? []); + if (info.changed) { + this.panels = info.panels ?? []; + this.sortPanelsByGridPos(); + this.events.publish(new DashboardPanelsChangedEvent()); + } + return info; + } + private getPanelSaveModels() { return this.panels .filter((panel: PanelModel) => { @@ -562,9 +584,9 @@ export class DashboardModel { return sourcePanel; } - const clone = new PanelModel(sourcePanel.getSaveModel()); - - clone.id = this.getNextPanelId(); + const m = sourcePanel.getSaveModel(); + m.id = this.getNextPanelId(); + const clone = new PanelModel(m); // insert after source panel + value index this.panels.splice(sourcePanelIndex + valueIndex, 0, clone); @@ -726,6 +748,7 @@ export class DashboardModel { updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) { panel.repeatPanelId = panel.id; panel.id = this.getNextPanelId(); + panel.key = `${panel.id}`; panel.repeatIteration = this.iteration; if (repeatedByRow) { panel.repeatedByRow = true; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 05f2768a659..8fdf904069b 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -65,6 +65,7 @@ const notPersistedProperties: { [str: string]: boolean } = { configRev: true, getDisplayTitle: true, dataSupport: true, + key: true, }; // For angular panels we need to clean up properties when changing type @@ -177,6 +178,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { cachedPluginOptions: Record = {}; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; plugin?: PanelPlugin; + key: string; // unique in dashboard, changes will force a react reload /** * The PanelModel event bus only used for internal and legacy angular support. @@ -190,6 +192,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { this.events = new EventBusSrv(); this.restoreModel(model); this.replaceVariables = this.replaceVariables.bind(this); + this.key = this.id ? `${this.id}` : `panel-${Math.floor(Math.random() * 100000)}`; } /** Given a persistened PanelModel restores property values */ diff --git a/public/app/features/dashboard/utils/panelMerge.test.ts b/public/app/features/dashboard/utils/panelMerge.test.ts new file mode 100644 index 00000000000..d0117f532ad --- /dev/null +++ b/public/app/features/dashboard/utils/panelMerge.test.ts @@ -0,0 +1,132 @@ +import { PanelModel } from '@grafana/data'; +import { DashboardModel } from '../state/DashboardModel'; + +describe('Merge dashbaord panels', () => { + describe('simple changes', () => { + let dashboard: DashboardModel; + let rawPanels: PanelModel[]; + + beforeEach(() => { + dashboard = new DashboardModel({ + title: 'simple title', + panels: [ + { + id: 1, + type: 'timeseries', + }, + { + id: 2, + type: 'timeseries', + }, + { + id: 3, + type: 'table', + fieldConfig: { + defaults: { + thresholds: { + mode: 'absolute', + steps: [ + { color: 'green', value: -Infinity }, // save model has this as null + { color: 'red', value: 80 }, + ], + }, + mappings: [], + color: { mode: 'thresholds' }, + }, + overrides: [], + }, + }, + ], + }); + rawPanels = dashboard.getSaveModelClone().panels; + }); + + it('should load and support noop', () => { + expect(dashboard.title).toBe('simple title'); + expect(dashboard.panels.length).toEqual(rawPanels.length); + + const info = dashboard.updatePanels(rawPanels); + expect(info.changed).toBeFalsy(); + expect(info.actions).toMatchInlineSnapshot(` + Object { + "add": Array [], + "noop": Array [ + 1, + 2, + 3, + ], + "remove": Array [], + "replace": Array [], + "update": Array [], + } + `); + }); + + it('should identify an add', () => { + rawPanels.push({ + id: 7, + type: 'canvas', + } as any); + + const info = dashboard.updatePanels(rawPanels); + expect(info.changed).toBeTruthy(); + expect(info.actions['add']).toEqual([7]); + }); + + it('should identify a remove', () => { + rawPanels.shift(); + + const info = dashboard.updatePanels(rawPanels); + expect(info.changed).toBeTruthy(); + expect(info.actions['remove']).toEqual([1]); + }); + + it('should allow change in key order for nested elements', () => { + (rawPanels[2] as any).fieldConfig = { + defaults: { + color: { mode: 'thresholds' }, + mappings: [], + thresholds: { + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + mode: 'absolute', + }, + }, + overrides: [], + }; + + // Same config, different order + const js0 = JSON.stringify(dashboard.panels[2].fieldConfig); + const js1 = JSON.stringify(rawPanels[2].fieldConfig); + expect(js1).not.toEqual(js0); + expect(js1.length).toEqual(js0.length); + + // no real changes here + const info = dashboard.updatePanels(rawPanels); + expect(info.changed).toBeFalsy(); + }); + + it('should replace a type change', () => { + (rawPanels[1] as any).type = 'canvas'; + + const info = dashboard.updatePanels(rawPanels); + expect(info.changed).toBeTruthy(); + expect(info.actions).toMatchInlineSnapshot(` + Object { + "add": Array [], + "noop": Array [ + 1, + 3, + ], + "remove": Array [], + "replace": Array [ + 2, + ], + "update": Array [], + } + `); + }); + }); +}); diff --git a/public/app/features/dashboard/utils/panelMerge.ts b/public/app/features/dashboard/utils/panelMerge.ts new file mode 100644 index 00000000000..a0de905acbd --- /dev/null +++ b/public/app/features/dashboard/utils/panelMerge.ts @@ -0,0 +1,131 @@ +import { PanelModel as IPanelModel } from '@grafana/data'; +import { isEqualWith } from 'lodash'; +import { PanelModel } from '../state'; + +export interface PanelMergeInfo { + changed: boolean; + panels: PanelModel[]; + actions: Record; +} + +// Values that are safe to change without a full panel unmount/remount +// TODO: options and fieldConfig should also be supported +const mutableKeys = new Set(['gridPos', 'title', 'description', 'transparent']); + +export function mergePanels(current: PanelModel[], data: IPanelModel[]): PanelMergeInfo { + const panels: PanelModel[] = []; + const info = { + changed: false, + actions: { + add: [] as number[], + remove: [] as number[], + replace: [] as number[], + update: [] as number[], + noop: [] as number[], + }, + panels, + }; + + let nextId = 0; + const inputPanels = new Map(); + for (let p of data) { + let { id } = p; + if (!id) { + if (!nextId) { + nextId = findNextPanelID([current, data]); + } + id = nextId++; + p = { ...p, id }; // clone with new ID + } + inputPanels.set(id, p); + } + + for (const panel of current) { + const target = inputPanels.get(panel.id) as PanelModel; + if (!target) { + info.changed = true; + info.actions.remove.push(panel.id); + panel.destroy(); + continue; + } + inputPanels.delete(panel.id); + + // Fast comparison when working with the same panel objects + if (target === panel) { + panels.push(panel); + info.actions.noop.push(panel.id); + continue; + } + + // Check if it is the same type + if (panel.type === target.type) { + const save = panel.getSaveModel(); + let isNoop = true; + let doUpdate = false; + for (const [key, value] of Object.entries(target)) { + if (!isEqualWith(value, save[key], infinityEqualsNull)) { + info.changed = true; + isNoop = false; + if (mutableKeys.has(key as any)) { + (panel as any)[key] = value; + doUpdate = true; + } else { + doUpdate = false; + break; // needs full replace + } + } + } + + if (isNoop) { + panels.push(panel); + info.actions.noop.push(panel.id); + continue; + } + + if (doUpdate) { + panels.push(panel); + info.actions.update.push(panel.id); + continue; + } + } + panel.destroy(); + + const next = new PanelModel(target); + next.key = `${next.id}-update-${Date.now()}`; // force react invalidate + panels.push(next); + info.changed = true; + info.actions.replace.push(panel.id); + } + + // Add the new panels + for (const t of inputPanels.values()) { + panels.push(new PanelModel(t)); + info.changed = true; + info.actions.add.push(t.id); + } + + return info; +} + +// Since +- Infinity are saved as null in JSON, we need to make them equal here also +function infinityEqualsNull(a: any, b: any) { + if (a == null && (b === Infinity || b === -Infinity || b == null)) { + return true; + } + if (b == null && (a === Infinity || a === -Infinity || a == null)) { + return true; + } + return undefined; // use default comparison +} + +function findNextPanelID(args: IPanelModel[][]): number { + let max = 0; + for (const panels of args) { + for (const panel of panels) { + if (panel.id > max) { + max = panel.id; + } + } + } + return max + 1; +}