Dashboards: support merging in new panels and options (#37905)

This commit is contained in:
Ryan McKinley 2021-09-01 08:03:56 -07:00 committed by GitHub
parent 643c7fa0cb
commit 7af2d1a629
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 301 additions and 11 deletions

View File

@ -29,7 +29,7 @@ export interface State {
}
export class DashboardGrid extends PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
}
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<Props, State> {
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(
<GrafanaGridItem
key={itemKey}
key={panel.key}
className={panelClasses}
data-panelid={itemKey}
data-panelid={panel.id}
gridPos={panel.gridPos}
gridWidth={gridWidth}
windowHeight={this.windowHeight}
@ -176,7 +177,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
isViewing={panel.isViewing}
>
{(width: number, height: number) => {
return this.renderPanel(panel, width, height, itemKey);
return this.renderPanel(panel, width, height, panel.key);
}}
</GrafanaGridItem>
);

View File

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

View File

@ -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<string, PanelOptionsCache> = {};
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 */

View File

@ -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 [],
}
`);
});
});
});

View File

@ -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<string, number[]>;
}
// Values that are safe to change without a full panel unmount/remount
// TODO: options and fieldConfig should also be supported
const mutableKeys = new Set<keyof PanelModel>(['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<number, IPanelModel>();
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;
}