mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
Dashboards: support merging in new panels and options (#37905)
This commit is contained in:
parent
643c7fa0cb
commit
7af2d1a629
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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 */
|
||||
|
132
public/app/features/dashboard/utils/panelMerge.test.ts
Normal file
132
public/app/features/dashboard/utils/panelMerge.test.ts
Normal 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 [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
131
public/app/features/dashboard/utils/panelMerge.ts
Normal file
131
public/app/features/dashboard/utils/panelMerge.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user