mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboards: support merging in new panels and options (#37905)
This commit is contained in:
@@ -29,7 +29,7 @@ export interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DashboardGrid extends PureComponent<Props, State> {
|
export class DashboardGrid extends PureComponent<Props, State> {
|
||||||
private panelMap: { [id: string]: PanelModel } = {};
|
private panelMap: { [key: string]: PanelModel } = {};
|
||||||
private eventSubs = new Subscription();
|
private eventSubs = new Subscription();
|
||||||
private windowHeight = 1200;
|
private windowHeight = 1200;
|
||||||
private windowWidth = 1920;
|
private windowWidth = 1920;
|
||||||
@@ -57,8 +57,10 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
|||||||
this.panelMap = {};
|
this.panelMap = {};
|
||||||
|
|
||||||
for (const panel of this.props.dashboard.panels) {
|
for (const panel of this.props.dashboard.panels) {
|
||||||
const stringId = panel.id.toString();
|
if (!panel.key) {
|
||||||
this.panelMap[stringId] = panel;
|
panel.key = `panel-${panel.id}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
this.panelMap[panel.key] = panel;
|
||||||
|
|
||||||
if (!panel.gridPos) {
|
if (!panel.gridPos) {
|
||||||
console.log('panel without gridpos');
|
console.log('panel without gridpos');
|
||||||
@@ -66,7 +68,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panelPos: any = {
|
const panelPos: any = {
|
||||||
i: stringId,
|
i: panel.key,
|
||||||
x: panel.gridPos.x,
|
x: panel.gridPos.x,
|
||||||
y: panel.gridPos.y,
|
y: panel.gridPos.y,
|
||||||
w: panel.gridPos.w,
|
w: panel.gridPos.w,
|
||||||
@@ -159,16 +161,15 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
for (const panel of this.props.dashboard.panels) {
|
for (const panel of this.props.dashboard.panels) {
|
||||||
const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing });
|
const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing });
|
||||||
const itemKey = panel.id.toString();
|
|
||||||
|
|
||||||
// Update is in view state
|
// Update is in view state
|
||||||
panel.isInView = this.isInView(panel);
|
panel.isInView = this.isInView(panel);
|
||||||
|
|
||||||
panelElements.push(
|
panelElements.push(
|
||||||
<GrafanaGridItem
|
<GrafanaGridItem
|
||||||
key={itemKey}
|
key={panel.key}
|
||||||
className={panelClasses}
|
className={panelClasses}
|
||||||
data-panelid={itemKey}
|
data-panelid={panel.id}
|
||||||
gridPos={panel.gridPos}
|
gridPos={panel.gridPos}
|
||||||
gridWidth={gridWidth}
|
gridWidth={gridWidth}
|
||||||
windowHeight={this.windowHeight}
|
windowHeight={this.windowHeight}
|
||||||
@@ -176,7 +177,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
|||||||
isViewing={panel.isViewing}
|
isViewing={panel.isViewing}
|
||||||
>
|
>
|
||||||
{(width: number, height: number) => {
|
{(width: number, height: number) => {
|
||||||
return this.renderPanel(panel, width, height, itemKey);
|
return this.renderPanel(panel, width, height, panel.key);
|
||||||
}}
|
}}
|
||||||
</GrafanaGridItem>
|
</GrafanaGridItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
TimeRange,
|
TimeRange,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
UrlQueryValue,
|
UrlQueryValue,
|
||||||
|
PanelModel as IPanelModel,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { CoreEvents, DashboardMeta, KioskMode } from 'app/types';
|
import { CoreEvents, DashboardMeta, KioskMode } from 'app/types';
|
||||||
import { GetVariables, getVariables } from 'app/features/variables/state/selectors';
|
import { GetVariables, getVariables } from 'app/features/variables/state/selectors';
|
||||||
@@ -43,6 +44,7 @@ import { dispatch } from '../../../store/store';
|
|||||||
import { isAllVariable } from '../../variables/utils';
|
import { isAllVariable } from '../../variables/utils';
|
||||||
import { DashboardPanelsChangedEvent, RefreshEvent, RenderEvent, TimeRangeUpdatedEvent } from 'app/types/events';
|
import { DashboardPanelsChangedEvent, RefreshEvent, RenderEvent, TimeRangeUpdatedEvent } from 'app/types/events';
|
||||||
import { getTimeSrv } from '../services/TimeSrv';
|
import { getTimeSrv } from '../services/TimeSrv';
|
||||||
|
import { mergePanels, PanelMergeInfo } from '../utils/panelMerge';
|
||||||
|
|
||||||
export interface CloneOptions {
|
export interface CloneOptions {
|
||||||
saveVariables?: boolean;
|
saveVariables?: boolean;
|
||||||
@@ -241,6 +243,26 @@ export class DashboardModel {
|
|||||||
return copy;
|
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() {
|
private getPanelSaveModels() {
|
||||||
return this.panels
|
return this.panels
|
||||||
.filter((panel: PanelModel) => {
|
.filter((panel: PanelModel) => {
|
||||||
@@ -562,9 +584,9 @@ export class DashboardModel {
|
|||||||
return sourcePanel;
|
return sourcePanel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = new PanelModel(sourcePanel.getSaveModel());
|
const m = sourcePanel.getSaveModel();
|
||||||
|
m.id = this.getNextPanelId();
|
||||||
clone.id = this.getNextPanelId();
|
const clone = new PanelModel(m);
|
||||||
|
|
||||||
// insert after source panel + value index
|
// insert after source panel + value index
|
||||||
this.panels.splice(sourcePanelIndex + valueIndex, 0, clone);
|
this.panels.splice(sourcePanelIndex + valueIndex, 0, clone);
|
||||||
@@ -726,6 +748,7 @@ export class DashboardModel {
|
|||||||
updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) {
|
updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) {
|
||||||
panel.repeatPanelId = panel.id;
|
panel.repeatPanelId = panel.id;
|
||||||
panel.id = this.getNextPanelId();
|
panel.id = this.getNextPanelId();
|
||||||
|
panel.key = `${panel.id}`;
|
||||||
panel.repeatIteration = this.iteration;
|
panel.repeatIteration = this.iteration;
|
||||||
if (repeatedByRow) {
|
if (repeatedByRow) {
|
||||||
panel.repeatedByRow = true;
|
panel.repeatedByRow = true;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const notPersistedProperties: { [str: string]: boolean } = {
|
|||||||
configRev: true,
|
configRev: true,
|
||||||
getDisplayTitle: true,
|
getDisplayTitle: true,
|
||||||
dataSupport: true,
|
dataSupport: true,
|
||||||
|
key: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For angular panels we need to clean up properties when changing type
|
// 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> = {};
|
cachedPluginOptions: Record<string, PanelOptionsCache> = {};
|
||||||
legend?: { show: boolean; sort?: string; sortDesc?: boolean };
|
legend?: { show: boolean; sort?: string; sortDesc?: boolean };
|
||||||
plugin?: PanelPlugin;
|
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.
|
* 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.events = new EventBusSrv();
|
||||||
this.restoreModel(model);
|
this.restoreModel(model);
|
||||||
this.replaceVariables = this.replaceVariables.bind(this);
|
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 */
|
/** 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user