support partial updates
This commit is contained in:
@@ -64,6 +64,16 @@ export interface DashboardSceneJsonApiV2 {
|
||||
*/
|
||||
getCurrentDashboardNavigation(space?: number): string;
|
||||
|
||||
/**
|
||||
* Read the currently selected element (edit pane selection) (JSON string).
|
||||
*
|
||||
* This returns JSON shaped like:
|
||||
* `{ isEditing: boolean, selection: null | { mode: "single" | "multi", item?: object, items?: object[] } }`.
|
||||
*
|
||||
* Note: selection is only meaningful in edit mode. Implementations should return `selection: null` when not editing.
|
||||
*/
|
||||
getCurrentDashboardSelection(space?: number): string;
|
||||
|
||||
/**
|
||||
* Scroll/focus a row within the current dashboard (JSON string).
|
||||
*
|
||||
@@ -87,6 +97,29 @@ export interface DashboardSceneJsonApiV2 {
|
||||
* `apiVersion`, `kind`, `metadata`, or `status`.
|
||||
*/
|
||||
applyCurrentDashboard(resourceJson: string): void;
|
||||
|
||||
/**
|
||||
* Preview a set of in-place dashboard operations (JSON string).
|
||||
*
|
||||
* Returns JSON shaped like:
|
||||
* `{ ok: boolean, applied?: number, unsupported?: number, errors?: string[] }`
|
||||
*
|
||||
* @internal Experimental: this API is intended for automation and may evolve.
|
||||
*/
|
||||
previewCurrentDashboardOps(opsJson: string): string;
|
||||
|
||||
/**
|
||||
* Apply a set of in-place dashboard operations (JSON string).
|
||||
*
|
||||
* Implementations should update the currently open DashboardScene in place when possible,
|
||||
* to avoid unnecessary panel/query reloads.
|
||||
*
|
||||
* Returns JSON shaped like:
|
||||
* `{ ok: boolean, applied?: number, didRebuild?: boolean, errors?: string[] }`
|
||||
*
|
||||
* @internal Experimental: this API is intended for automation and may evolve.
|
||||
*/
|
||||
applyCurrentDashboardOps(opsJson: string): string;
|
||||
}
|
||||
|
||||
let singletonInstance: DashboardSceneJsonApiV2 | undefined;
|
||||
@@ -289,6 +322,21 @@ export function getDashboardApi() {
|
||||
'Read/apply dashboard:',
|
||||
'- dashboard.getCurrent(space?): string',
|
||||
'- dashboard.apply(resourceJson: string): void',
|
||||
'- dashboard.previewOps(opsJson: string): string',
|
||||
'- dashboard.applyOps(opsJson: string): string',
|
||||
' - Ops are applied in-place when possible to avoid full dashboard reloads.',
|
||||
' - Supported ops (JSON array elements):',
|
||||
' - { op: "mergePanelConfig", panelId, merge: { vizConfig: { fieldConfig: { defaults: {...} }, options: {...} } } }',
|
||||
' - { op: "setPanelTitle", panelId, title }',
|
||||
' - { op: "setGridPos", panelId, x, y, w, h }',
|
||||
' - { op: "addPanel", title?, pluginId?, rowTitle?, rowKey? }',
|
||||
' - { op: "removePanel", panelId }',
|
||||
' - { op: "addRow", title? }',
|
||||
' - { op: "removeRow", title? | rowKey? }',
|
||||
' - { op: "movePanelToRow", panelId, rowKey? | rowTitle? }',
|
||||
' - { op: "movePanelToTab", panelId, tabTitle? | tabSlug? }',
|
||||
' - { op: "addTab", title? }',
|
||||
' - { op: "removeTab", title? | slug? }',
|
||||
' - resourceJson must be a v2beta1 Dashboard kind object:',
|
||||
' { apiVersion: "dashboard.grafana.app/v2beta1", kind: "Dashboard", metadata: {...}, spec: {...}, status: {...} }',
|
||||
'',
|
||||
@@ -311,6 +359,8 @@ export function getDashboardApi() {
|
||||
'Navigation:',
|
||||
'- navigation.getCurrent(space?): string',
|
||||
' - returns JSON: { tab: { slug: string, title?: string } | null }',
|
||||
'- navigation.getSelection(space?): string',
|
||||
' - returns JSON: { isEditing: boolean, selection: null | { mode: "single" | "multi", item?: object, items?: object[] } }',
|
||||
'- navigation.selectTab(tabJson: string): void',
|
||||
' - accepts JSON: { title?: string, slug?: string }',
|
||||
'- navigation.focusRow(rowJson: string): void',
|
||||
@@ -384,6 +434,8 @@ export function getDashboardApi() {
|
||||
dashboard: {
|
||||
getCurrent: (space = 2) => api.getCurrentDashboard(space),
|
||||
apply: (resourceJson: string) => api.applyCurrentDashboard(resourceJson),
|
||||
previewOps: (opsJson: string) => api.previewCurrentDashboardOps(opsJson),
|
||||
applyOps: (opsJson: string) => api.applyCurrentDashboardOps(opsJson),
|
||||
},
|
||||
errors: {
|
||||
getCurrent: (space = 2) =>
|
||||
@@ -399,6 +451,7 @@ export function getDashboardApi() {
|
||||
},
|
||||
navigation: {
|
||||
getCurrent: (space = 2) => api.getCurrentDashboardNavigation(space),
|
||||
getSelection: (space = 2) => api.getCurrentDashboardSelection(space),
|
||||
selectTab: (tabJson: string) => api.selectCurrentDashboardTab(tabJson),
|
||||
focusRow: (rowJson: string) => api.focusCurrentDashboardRow(rowJson),
|
||||
focusPanel: (panelJson: string) => api.focusCurrentDashboardPanel(panelJson),
|
||||
|
||||
+23
@@ -115,6 +115,29 @@ describe('dashboardSceneJsonApiV2 (navigation/variables/time)', () => {
|
||||
expect(nav).toEqual({ tab: { slug: tabB.getSlug(), title: 'Explore' } });
|
||||
});
|
||||
|
||||
it('getCurrentDashboardSelection returns null when not editing', () => {
|
||||
const editPane = { getSelection: jest.fn(() => undefined) };
|
||||
const dashboard = { state: { isEditing: false, editPane }, publishEvent: jest.fn() };
|
||||
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
|
||||
|
||||
const res = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardSelection(0));
|
||||
expect(res).toEqual({ isEditing: false, selection: null });
|
||||
});
|
||||
|
||||
it('getCurrentDashboardSelection returns the selected tab context when editing', () => {
|
||||
const selectedTab = new TabItem({ key: 'tab-a', title: 'Overview' });
|
||||
const editPane = { getSelection: jest.fn(() => selectedTab) };
|
||||
const dashboard = { state: { isEditing: true, editPane }, publishEvent: jest.fn() };
|
||||
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
|
||||
|
||||
const res = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardSelection(0));
|
||||
expect(res.isEditing).toBe(true);
|
||||
expect(res.selection.mode).toBe('single');
|
||||
expect(res.selection.item).toEqual(
|
||||
expect.objectContaining({ type: 'tab', tabSlug: selectedTab.getSlug(), title: 'Overview', key: 'tab-a' })
|
||||
);
|
||||
});
|
||||
|
||||
it('focusCurrentDashboardRow expands a collapsed row and calls scrollIntoView', () => {
|
||||
const row = new RowItem({ title: 'request duration', collapse: true });
|
||||
const scrollSpy = jest.spyOn(row, 'scrollIntoView').mockImplementation(() => {});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowItem } from '../scene/layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
|
||||
|
||||
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
|
||||
|
||||
jest.mock('../utils/utils', () => {
|
||||
const actual = jest.requireActual('../utils/utils');
|
||||
return {
|
||||
...actual,
|
||||
getDefaultVizPanel: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../pages/DashboardScenePageStateManager', () => ({
|
||||
getDashboardScenePageStateManager: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/dashboardSceneGraph', () => ({
|
||||
dashboardSceneGraph: {
|
||||
getVizPanels: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('dashboardSceneJsonApiV2 (ops)', () => {
|
||||
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
|
||||
const { dashboardSceneGraph } = jest.requireMock('../utils/dashboardSceneGraph');
|
||||
const { getDefaultVizPanel } = jest.requireMock('../utils/utils');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
config.featureToggles.kubernetesDashboardsV2 = true;
|
||||
});
|
||||
|
||||
it('mergePanelConfig updates fieldConfig defaults in-place (preserves SceneQueryRunner identity)', () => {
|
||||
const runner = new SceneQueryRunner({ queries: [], data: undefined });
|
||||
const panel = new VizPanel({
|
||||
key: 'panel-2',
|
||||
title: 'Time series',
|
||||
fieldConfig: { defaults: { unit: 'short' }, overrides: [] },
|
||||
$data: runner,
|
||||
});
|
||||
|
||||
const dashboard = {
|
||||
state: { isEditing: true },
|
||||
setState: jest.fn(),
|
||||
};
|
||||
|
||||
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
|
||||
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
|
||||
|
||||
const res = JSON.parse(
|
||||
dashboardSceneJsonApiV2.applyCurrentDashboardOps(
|
||||
JSON.stringify([
|
||||
{
|
||||
op: 'mergePanelConfig',
|
||||
panelId: 2,
|
||||
merge: { vizConfig: { fieldConfig: { defaults: { unit: 'ms' } } } },
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.applied).toBe(1);
|
||||
expect(panel.state.fieldConfig.defaults.unit).toBe('ms');
|
||||
expect(panel.state.$data).toBe(runner);
|
||||
expect(dashboard.setState).toHaveBeenCalledWith({ isDirty: true });
|
||||
});
|
||||
|
||||
it('addPanel uses the current layout addPanel (does not rebuild existing panels)', () => {
|
||||
const existingRunner = new SceneQueryRunner({ queries: [], data: undefined });
|
||||
const existingPanel = new VizPanel({
|
||||
key: 'panel-1',
|
||||
title: 'Existing',
|
||||
$data: existingRunner,
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
});
|
||||
|
||||
const newRunner = new SceneQueryRunner({ queries: [], data: undefined });
|
||||
const newPanel = new VizPanel({
|
||||
// key is assigned by layout.addPanel in real code; the op reads it afterwards.
|
||||
title: 'New panel',
|
||||
$data: newRunner,
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
});
|
||||
|
||||
getDefaultVizPanel.mockReturnValue(newPanel);
|
||||
|
||||
const layout = {
|
||||
addPanel: jest.fn((p: VizPanel) => {
|
||||
// emulate DefaultGridLayoutManager assigning next panel id/key
|
||||
p.setState({ key: 'panel-2' });
|
||||
}),
|
||||
};
|
||||
|
||||
const dashboard = {
|
||||
state: { isEditing: true, body: layout },
|
||||
setState: jest.fn(),
|
||||
removePanel: jest.fn(),
|
||||
};
|
||||
|
||||
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
|
||||
dashboardSceneGraph.getVizPanels.mockReturnValue([existingPanel]);
|
||||
|
||||
const res = JSON.parse(dashboardSceneJsonApiV2.applyCurrentDashboardOps(JSON.stringify([{ op: 'addPanel', title: 'Hello' }])));
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.applied).toBe(1);
|
||||
expect(layout.addPanel).toHaveBeenCalledTimes(1);
|
||||
expect(newPanel.state.key).toBe('panel-2');
|
||||
|
||||
// existing panel runner identity preserved
|
||||
expect(existingPanel.state.$data).toBe(existingRunner);
|
||||
});
|
||||
|
||||
it('movePanelToRow supports moving into a GridLayout row (DefaultGridLayoutManager)', () => {
|
||||
const panel = new VizPanel({
|
||||
key: 'panel-6',
|
||||
title: 'Bar chart (steps)',
|
||||
pluginId: 'barchart',
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
$data: new SceneQueryRunner({ queries: [], data: undefined }),
|
||||
});
|
||||
|
||||
const rowA = new RowItem({ title: 'Charts', layout: DefaultGridLayoutManager.fromVizPanels([panel]) });
|
||||
const rowB = new RowItem({ title: 'Data', layout: DefaultGridLayoutManager.fromVizPanels([]) });
|
||||
const rows = new RowsLayoutManager({ rows: [rowA, rowB] });
|
||||
|
||||
const dashboard = {
|
||||
state: { isEditing: true, body: rows },
|
||||
setState: jest.fn(),
|
||||
};
|
||||
|
||||
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
|
||||
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
|
||||
|
||||
const res = JSON.parse(
|
||||
dashboardSceneJsonApiV2.applyCurrentDashboardOps(JSON.stringify([{ op: 'movePanelToRow', panelId: 6, rowTitle: 'Data' }]))
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.applied).toBe(1);
|
||||
expect(rowA.state.layout.getVizPanels()).toHaveLength(0);
|
||||
expect(rowB.state.layout.getVizPanels()).toContain(panel);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,20 +3,33 @@ import { isEqual } from 'lodash';
|
||||
import { RefreshEvent, config, locationService, type DashboardSceneJsonApiV2 } from '@grafana/runtime';
|
||||
import {
|
||||
MultiValueVariable,
|
||||
SceneGridRow,
|
||||
SceneVariableSet,
|
||||
TextBoxVariable,
|
||||
sceneGraph,
|
||||
VizPanel,
|
||||
type SceneObject,
|
||||
type VariableValue,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
|
||||
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
|
||||
import { AutoGridLayoutManager } from '../scene/layout-auto-grid/AutoGridLayoutManager';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowItem } from '../scene/layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
|
||||
import { TabItem } from '../scene/layout-tabs/TabItem';
|
||||
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
|
||||
import { addNewRowTo } from '../scene/layouts-shared/addNew';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||
import {
|
||||
getDefaultVizPanel,
|
||||
getGridItemKeyForPanelId,
|
||||
getLayoutManagerFor,
|
||||
getPanelIdForVizPanel,
|
||||
getQueryRunnerFor,
|
||||
} from '../utils/utils';
|
||||
|
||||
import { getCurrentDashboardErrors } from './currentDashboardErrors';
|
||||
import { getCurrentDashboardKindV2 as getCurrentDashboardResourceV2 } from './currentDashboardKindV2';
|
||||
@@ -69,6 +82,37 @@ function findParent<T extends SceneObject>(start: SceneObject, isMatch: (obj: Sc
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeVizPanelFromCurrentLayoutInPlace(panel: VizPanel) {
|
||||
const parent = panel.parent;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parent instanceof AutoGridItem) {
|
||||
const mgr = findParent(parent, (o): o is AutoGridLayoutManager => o instanceof AutoGridLayoutManager);
|
||||
if (mgr) {
|
||||
mgr.state.layout.setState({ children: mgr.state.layout.state.children.filter((c) => c !== parent) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (parent instanceof DashboardGridItem) {
|
||||
const mgr = findParent(parent, (o): o is DefaultGridLayoutManager => o instanceof DefaultGridLayoutManager);
|
||||
if (mgr) {
|
||||
mgr.state.grid.setState({ children: mgr.state.grid.state.children.filter((c) => c !== parent) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: ask the layout manager abstraction (may require edit-pane plumbing).
|
||||
try {
|
||||
const mgr = getLayoutManagerFor(panel);
|
||||
mgr.removePanel?.(panel);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getDashboardUidFromUrl(): string | undefined {
|
||||
const pathname = globalThis.location?.pathname ?? '';
|
||||
// Expected: /d/<uid>/<slug>
|
||||
@@ -97,6 +141,116 @@ function getCurrentDashboardResourceWithFallback(): { resource: DashboardResourc
|
||||
}
|
||||
}
|
||||
|
||||
function addVizPanelToLayoutInPlace(layout: unknown, panel: VizPanel, sourceGridItem?: DashboardGridItem) {
|
||||
if (layout instanceof AutoGridLayoutManager) {
|
||||
layout.state.layout.setState({
|
||||
children: [...layout.state.layout.state.children, new AutoGridItem({ body: panel })],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout instanceof DefaultGridLayoutManager) {
|
||||
const children = layout.state.grid.state.children;
|
||||
|
||||
// Put it at the bottom.
|
||||
let nextY = 0;
|
||||
for (const child of children) {
|
||||
const childState = isRecord(child) ? child['state'] : undefined;
|
||||
const y = isRecord(childState) && typeof childState['y'] === 'number' ? childState['y'] : 0;
|
||||
const h = isRecord(childState) && typeof childState['height'] === 'number' ? childState['height'] : 0;
|
||||
nextY = Math.max(nextY, y + h);
|
||||
}
|
||||
|
||||
const panelId = getPanelIdForVizPanel(panel);
|
||||
const width = sourceGridItem?.state.width ?? 24;
|
||||
const height = sourceGridItem?.state.height ?? 8;
|
||||
const x = 0;
|
||||
|
||||
const key = sourceGridItem?.state.key ?? getGridItemKeyForPanelId(panelId);
|
||||
|
||||
const newGridItem = new DashboardGridItem({
|
||||
x,
|
||||
y: nextY,
|
||||
width,
|
||||
height,
|
||||
body: panel,
|
||||
key,
|
||||
});
|
||||
|
||||
layout.state.grid.setState({ children: [...children, newGridItem] });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported layout type: ${layout instanceof Object ? layout.constructor?.name : typeof layout}`);
|
||||
}
|
||||
|
||||
function toSelectedItem(obj: SceneObject, dashboard: ReturnType<typeof getCurrentDashboardSceneOrThrow>): Record<string, unknown> {
|
||||
// Dashboard selection is represented as the dashboard instance itself.
|
||||
if (obj === dashboard) {
|
||||
return {
|
||||
type: 'dashboard',
|
||||
title: dashboard.state.title,
|
||||
key: dashboard.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof VizPanel) {
|
||||
return {
|
||||
type: 'panel',
|
||||
panelId: getPanelIdForVizPanel(obj),
|
||||
title: obj.state.title,
|
||||
pluginId: obj.state.pluginId,
|
||||
key: obj.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof RowItem) {
|
||||
return {
|
||||
type: 'row',
|
||||
rowKey: obj.state.key,
|
||||
title: obj.state.title,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof SceneGridRow) {
|
||||
return {
|
||||
type: 'row',
|
||||
rowKey: obj.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof TabItem) {
|
||||
return {
|
||||
type: 'tab',
|
||||
tabSlug: obj.getSlug(),
|
||||
title: obj.state.title,
|
||||
key: obj.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof SceneVariableSet) {
|
||||
return {
|
||||
type: 'variables',
|
||||
key: obj.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
if (obj instanceof MultiValueVariable || obj instanceof TextBoxVariable) {
|
||||
return {
|
||||
type: 'variable',
|
||||
name: obj.state.name,
|
||||
value: obj.getValue(),
|
||||
key: obj.state.key,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
key: obj.state.key,
|
||||
class: obj.constructor?.name,
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = {
|
||||
getCurrentDashboard: (space = 2) => {
|
||||
const { resource } = getCurrentDashboardResourceWithFallback();
|
||||
@@ -258,6 +412,48 @@ export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = {
|
||||
);
|
||||
},
|
||||
|
||||
getCurrentDashboardSelection: (space = 2) => {
|
||||
assertDashboardV2Enabled();
|
||||
const dashboard = getCurrentDashboardSceneOrThrow();
|
||||
const isEditing = Boolean(dashboard.state.isEditing);
|
||||
|
||||
// Selection is only meaningful when editing; in view mode we keep this explicitly null.
|
||||
if (!isEditing) {
|
||||
return JSON.stringify({ isEditing, selection: null }, null, space);
|
||||
}
|
||||
|
||||
const selection = dashboard.state.editPane.getSelection();
|
||||
if (!selection) {
|
||||
return JSON.stringify({ isEditing, selection: null }, null, space);
|
||||
}
|
||||
|
||||
if (Array.isArray(selection)) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
isEditing,
|
||||
selection: {
|
||||
mode: 'multi',
|
||||
items: selection.map((o) => toSelectedItem(o, dashboard)),
|
||||
},
|
||||
},
|
||||
null,
|
||||
space
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
isEditing,
|
||||
selection: {
|
||||
mode: 'single',
|
||||
item: toSelectedItem(selection, dashboard),
|
||||
},
|
||||
},
|
||||
null,
|
||||
space
|
||||
);
|
||||
},
|
||||
|
||||
focusCurrentDashboardRow: (rowJson: string) => {
|
||||
assertDashboardV2Enabled();
|
||||
const dashboard = getCurrentDashboardSceneOrThrow();
|
||||
@@ -388,6 +584,556 @@ export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
previewCurrentDashboardOps: (opsJson: string) => {
|
||||
assertDashboardV2Enabled();
|
||||
const parsed: unknown = JSON.parse(opsJson);
|
||||
let ops: unknown[] = [];
|
||||
if (Array.isArray(parsed)) {
|
||||
ops = parsed;
|
||||
} else if (isRecord(parsed)) {
|
||||
const v = parsed['ops'];
|
||||
if (Array.isArray(v)) {
|
||||
ops = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(ops)) {
|
||||
return JSON.stringify({ ok: false, errors: ['Invalid ops JSON: expected an array or { ops: [] }'] }, null, 2);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let supported = 0;
|
||||
let unsupported = 0;
|
||||
|
||||
for (const op of ops) {
|
||||
if (!isRecord(op) || typeof op['op'] !== 'string') {
|
||||
errors.push('Invalid op: expected an object with "op" string');
|
||||
continue;
|
||||
}
|
||||
const opName = op['op'];
|
||||
if (
|
||||
opName === 'setPanelTitle' ||
|
||||
opName === 'setGridPos' ||
|
||||
opName === 'mergePanelConfig' ||
|
||||
opName === 'addPanel' ||
|
||||
opName === 'removePanel' ||
|
||||
opName === 'addRow' ||
|
||||
opName === 'removeRow' ||
|
||||
opName === 'addTab' ||
|
||||
opName === 'removeTab' ||
|
||||
opName === 'movePanelToRow' ||
|
||||
opName === 'movePanelToTab'
|
||||
) {
|
||||
supported++;
|
||||
} else {
|
||||
unsupported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: we do not validate existence here beyond parsing; apply will.
|
||||
return JSON.stringify({ ok: errors.length === 0, supported, unsupported, errors: errors.length ? errors : undefined }, null, 2);
|
||||
},
|
||||
|
||||
applyCurrentDashboardOps: (opsJson: string) => {
|
||||
assertDashboardV2Enabled();
|
||||
const dashboard = getCurrentDashboardSceneOrThrow();
|
||||
if (!dashboard.state.isEditing) {
|
||||
dashboard.onEnterEditMode?.();
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(opsJson);
|
||||
let ops: unknown[] = [];
|
||||
if (Array.isArray(parsed)) {
|
||||
ops = parsed;
|
||||
} else if (isRecord(parsed)) {
|
||||
const v = parsed['ops'];
|
||||
if (Array.isArray(v)) {
|
||||
ops = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(ops)) {
|
||||
return JSON.stringify({ ok: false, errors: ['Invalid ops JSON: expected an array or { ops: [] }'] }, null, 2);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let applied = 0;
|
||||
const results: unknown[] = [];
|
||||
|
||||
const getPanelsById = (panelId: number) => {
|
||||
// Note: dashboards can contain repeated panels (and some layouts can temporarily produce multiple
|
||||
// VizPanel instances for the same legacy id). To keep the live view and the serialized save model
|
||||
// consistent, apply config changes to all matching panels.
|
||||
return dashboardSceneGraph.getVizPanels(dashboard).filter((p) => getPanelIdForVizPanel(p) === panelId);
|
||||
};
|
||||
|
||||
function getRootTabsManager(): TabsLayoutManager | null {
|
||||
const found = dashboard.state.body instanceof TabsLayoutManager ? dashboard.state.body : sceneGraph.findObject(dashboard, (o) => o instanceof TabsLayoutManager);
|
||||
return found instanceof TabsLayoutManager ? found : null;
|
||||
}
|
||||
|
||||
function getCurrentLayoutManager() {
|
||||
const tabs = getRootTabsManager();
|
||||
if (tabs) {
|
||||
const currentTab = tabs.getCurrentTab();
|
||||
if (!currentTab) {
|
||||
throw new Error('Could not find currently active tab');
|
||||
}
|
||||
return currentTab.state.layout;
|
||||
}
|
||||
return dashboard.state.body;
|
||||
}
|
||||
|
||||
function getCurrentRowsManagerOrThrow(): RowsLayoutManager {
|
||||
const layout = getCurrentLayoutManager();
|
||||
if (layout instanceof RowsLayoutManager) {
|
||||
return layout;
|
||||
}
|
||||
throw new Error('Current layout is not RowsLayout (addRow/removeRow require RowsLayout)');
|
||||
}
|
||||
|
||||
for (const op of ops) {
|
||||
if (!isRecord(op) || typeof op['op'] !== 'string') {
|
||||
errors.push('Invalid op: expected an object with "op" string');
|
||||
continue;
|
||||
}
|
||||
|
||||
const opName = op['op'];
|
||||
|
||||
try {
|
||||
if (opName === 'addPanel') {
|
||||
const title = op['title'];
|
||||
const pluginId = op['pluginId'];
|
||||
const rowTitle = op['rowTitle'];
|
||||
const rowKey = op['rowKey'];
|
||||
|
||||
if (title !== undefined && typeof title !== 'string') {
|
||||
throw new Error('addPanel: title must be a string when provided');
|
||||
}
|
||||
if (pluginId !== undefined && typeof pluginId !== 'string') {
|
||||
throw new Error('addPanel: pluginId must be a string when provided');
|
||||
}
|
||||
if (rowTitle !== undefined && typeof rowTitle !== 'string') {
|
||||
throw new Error('addPanel: rowTitle must be a string when provided');
|
||||
}
|
||||
if (rowKey !== undefined && typeof rowKey !== 'string') {
|
||||
throw new Error('addPanel: rowKey must be a string when provided');
|
||||
}
|
||||
|
||||
const vizPanel: VizPanel = getDefaultVizPanel();
|
||||
if (typeof title === 'string') {
|
||||
vizPanel.setState({ title });
|
||||
}
|
||||
if (typeof pluginId === 'string') {
|
||||
vizPanel.setState({ pluginId });
|
||||
}
|
||||
|
||||
const layout = getCurrentLayoutManager();
|
||||
|
||||
// If we're in a RowsLayout, optionally target a specific row; otherwise use last row.
|
||||
if (layout instanceof RowsLayoutManager) {
|
||||
const rows = layout.state.rows;
|
||||
const row =
|
||||
rows.find((r) => (rowKey ? r.state.key === rowKey : false) || (rowTitle ? r.state.title === rowTitle : false)) ??
|
||||
rows[rows.length - 1];
|
||||
if (!row) {
|
||||
throw new Error('addPanel: no row available to add to');
|
||||
}
|
||||
row.state.layout.addPanel(vizPanel);
|
||||
} else if ('addPanel' in layout && typeof layout.addPanel === 'function') {
|
||||
layout.addPanel(vizPanel);
|
||||
} else {
|
||||
throw new Error('addPanel: current layout does not support adding panels');
|
||||
}
|
||||
|
||||
const newPanelId = getPanelIdForVizPanel(vizPanel);
|
||||
results.push({ op: 'addPanel', panelId: newPanelId, title: vizPanel.state.title });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'removePanel') {
|
||||
const panelId = op['panelId'];
|
||||
if (typeof panelId !== 'number') {
|
||||
throw new Error('removePanel expects { panelId: number }');
|
||||
}
|
||||
|
||||
const panels = getPanelsById(panelId);
|
||||
if (panels.length === 0) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
|
||||
for (const panel of panels) {
|
||||
dashboard.removePanel?.(panel);
|
||||
}
|
||||
|
||||
results.push({ op: 'removePanel', panelId, removed: panels.length });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'addRow') {
|
||||
const title = op['title'];
|
||||
if (title !== undefined && typeof title !== 'string') {
|
||||
throw new Error('addRow: title must be a string when provided');
|
||||
}
|
||||
|
||||
// Mimic UI behavior: if the current tab is not a RowsLayout, migrate it to RowsLayout,
|
||||
// then add a new empty row (so existing panels remain in the first row).
|
||||
const tabsManager = getRootTabsManager();
|
||||
const currentTab = tabsManager?.getCurrentTab();
|
||||
const currentLayout = getCurrentLayoutManager();
|
||||
|
||||
if (!(currentLayout instanceof RowsLayoutManager)) {
|
||||
addNewRowTo(currentLayout);
|
||||
}
|
||||
|
||||
const nextLayout = currentTab ? currentTab.state.layout : dashboard.state.body;
|
||||
if (!(nextLayout instanceof RowsLayoutManager)) {
|
||||
throw new Error('Failed to switch to RowsLayout');
|
||||
}
|
||||
|
||||
const existingTitles = new Set(
|
||||
nextLayout.state.rows.map((r) => r.state.title).filter((t): t is string => typeof t === 'string' && t.length > 0)
|
||||
);
|
||||
const baseTitle = typeof title === 'string' && title.length ? title : 'New row';
|
||||
let nextTitle = baseTitle;
|
||||
if (existingTitles.has(nextTitle)) {
|
||||
let i = 2;
|
||||
while (existingTitles.has(`${baseTitle} ${i}`)) {
|
||||
i++;
|
||||
}
|
||||
nextTitle = `${baseTitle} ${i}`;
|
||||
}
|
||||
|
||||
const row = new RowItem({ title: nextTitle });
|
||||
nextLayout.setState({ rows: [...nextLayout.state.rows, row] });
|
||||
|
||||
results.push({ op: 'addRow', rowKey: row.state.key, title: row.state.title });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'removeRow') {
|
||||
const title = op['title'];
|
||||
const rowKey = op['rowKey'];
|
||||
if (title !== undefined && typeof title !== 'string') {
|
||||
throw new Error('removeRow: title must be a string when provided');
|
||||
}
|
||||
if (rowKey !== undefined && typeof rowKey !== 'string') {
|
||||
throw new Error('removeRow: rowKey must be a string when provided');
|
||||
}
|
||||
|
||||
const rowsManager = getCurrentRowsManagerOrThrow();
|
||||
const row =
|
||||
rowsManager.state.rows.find((r) => (rowKey ? r.state.key === rowKey : false) || (title ? r.state.title === title : false));
|
||||
if (!row) {
|
||||
throw new Error('Row not found');
|
||||
}
|
||||
rowsManager.removeRow(row, true);
|
||||
results.push({ op: 'removeRow', rowKey: row.state.key, title: row.state.title });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'movePanelToRow') {
|
||||
const panelId = op['panelId'];
|
||||
const rowKey = op['rowKey'];
|
||||
const rowTitle = op['rowTitle'];
|
||||
|
||||
if (typeof panelId !== 'number') {
|
||||
throw new Error('movePanelToRow expects { panelId: number, rowKey?: string, rowTitle?: string }');
|
||||
}
|
||||
if (rowKey !== undefined && typeof rowKey !== 'string') {
|
||||
throw new Error('movePanelToRow: rowKey must be a string when provided');
|
||||
}
|
||||
if (rowTitle !== undefined && typeof rowTitle !== 'string') {
|
||||
throw new Error('movePanelToRow: rowTitle must be a string when provided');
|
||||
}
|
||||
|
||||
const rowsManager = getCurrentRowsManagerOrThrow();
|
||||
const targetRow =
|
||||
rowsManager.state.rows.find((r) => (rowKey ? r.state.key === rowKey : false) || (rowTitle ? r.state.title === rowTitle : false)) ??
|
||||
rowsManager.state.rows[rowsManager.state.rows.length - 1];
|
||||
|
||||
if (!targetRow) {
|
||||
throw new Error('movePanelToRow: target row not found');
|
||||
}
|
||||
|
||||
const targetLayout = targetRow.state.layout;
|
||||
if (!(targetLayout instanceof AutoGridLayoutManager || targetLayout instanceof DefaultGridLayoutManager)) {
|
||||
throw new Error(
|
||||
`movePanelToRow currently supports only AutoGrid and Grid row layouts (got ${targetLayout.constructor?.name ?? 'unknown'})`
|
||||
);
|
||||
}
|
||||
|
||||
const panels = getPanelsById(panelId);
|
||||
if (panels.length === 0) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
|
||||
for (const p of panels) {
|
||||
const sourceGridItem = p.parent instanceof DashboardGridItem ? p.parent : undefined;
|
||||
removeVizPanelFromCurrentLayoutInPlace(p);
|
||||
p.clearParent();
|
||||
addVizPanelToLayoutInPlace(targetLayout, p, sourceGridItem);
|
||||
}
|
||||
|
||||
results.push({
|
||||
op: 'movePanelToRow',
|
||||
panelId,
|
||||
rowKey: targetRow.state.key,
|
||||
rowTitle: targetRow.state.title,
|
||||
moved: panels.length,
|
||||
});
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'movePanelToTab') {
|
||||
const panelId = op['panelId'];
|
||||
const tabTitle = op['tabTitle'];
|
||||
const tabSlug = op['tabSlug'];
|
||||
|
||||
if (typeof panelId !== 'number') {
|
||||
throw new Error('movePanelToTab expects { panelId: number, tabTitle?: string, tabSlug?: string }');
|
||||
}
|
||||
if (tabTitle !== undefined && typeof tabTitle !== 'string') {
|
||||
throw new Error('movePanelToTab: tabTitle must be a string when provided');
|
||||
}
|
||||
if (tabSlug !== undefined && typeof tabSlug !== 'string') {
|
||||
throw new Error('movePanelToTab: tabSlug must be a string when provided');
|
||||
}
|
||||
|
||||
const tabsManager = getRootTabsManager();
|
||||
if (!tabsManager) {
|
||||
throw new Error('movePanelToTab requires TabsLayout');
|
||||
}
|
||||
|
||||
const tab = tabsManager.state.tabs.find(
|
||||
(t) => (tabSlug ? t.getSlug() === tabSlug : false) || (tabTitle ? t.state.title === tabTitle : false)
|
||||
);
|
||||
if (!tab) {
|
||||
throw new Error('Tab not found');
|
||||
}
|
||||
|
||||
const panels = getPanelsById(panelId);
|
||||
if (panels.length === 0) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
if (panels.length > 1) {
|
||||
throw new Error('movePanelToTab does not support repeated panels (multiple instances share the same panelId)');
|
||||
}
|
||||
|
||||
const panel = panels[0];
|
||||
const sourceGridItem = panel.parent instanceof DashboardGridItem ? panel.parent : undefined;
|
||||
removeVizPanelFromCurrentLayoutInPlace(panel);
|
||||
panel.clearParent();
|
||||
const targetLayout = tab.getLayout();
|
||||
if (targetLayout instanceof RowsLayoutManager) {
|
||||
// Keep behavior aligned with RowsLayoutManager.addPanel (adds to first row), but support both grid types.
|
||||
const firstRow = targetLayout.state.rows[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('movePanelToTab: target tab has no rows');
|
||||
}
|
||||
addVizPanelToLayoutInPlace(firstRow.state.layout, panel, sourceGridItem);
|
||||
} else {
|
||||
addVizPanelToLayoutInPlace(targetLayout, panel, sourceGridItem);
|
||||
}
|
||||
|
||||
results.push({ op: 'movePanelToTab', panelId, tabTitle: tab.state.title, tabSlug: tab.getSlug() });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'addTab') {
|
||||
const title = op['title'];
|
||||
if (title !== undefined && typeof title !== 'string') {
|
||||
throw new Error('addTab: title must be a string when provided');
|
||||
}
|
||||
|
||||
const tabsManager = getRootTabsManager();
|
||||
if (!tabsManager) {
|
||||
throw new Error('addTab/removeTab require TabsLayout');
|
||||
}
|
||||
|
||||
// Important: tab slug is derived from title. Set title before adding so currentTabSlug points at the final slug.
|
||||
const newTab = typeof title === 'string' && title.length ? new TabItem({ title }) : new TabItem({});
|
||||
const tab = tabsManager.addNewTab(newTab);
|
||||
// Defensive: make sure we end up on the final slug (slug can change if title is later edited / uniquified).
|
||||
tabsManager.switchToTab(tab);
|
||||
// keep URL in sync with the new tab selection
|
||||
locationService.partial({ [tabsManager.getUrlKey()]: tab.getSlug() }, true);
|
||||
|
||||
results.push({ op: 'addTab', slug: tab.getSlug(), title: tab.state.title });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'removeTab') {
|
||||
const title = op['title'];
|
||||
const slug = op['slug'];
|
||||
if (title !== undefined && typeof title !== 'string') {
|
||||
throw new Error('removeTab: title must be a string when provided');
|
||||
}
|
||||
if (slug !== undefined && typeof slug !== 'string') {
|
||||
throw new Error('removeTab: slug must be a string when provided');
|
||||
}
|
||||
|
||||
const tabsManager = getRootTabsManager();
|
||||
if (!tabsManager) {
|
||||
throw new Error('addTab/removeTab require TabsLayout');
|
||||
}
|
||||
|
||||
const tab = tabsManager.getTabsIncludingRepeats().find((t) => (slug ? t.getSlug() === slug : false) || (title ? t.state.title === title : false));
|
||||
if (!tab) {
|
||||
throw new Error('Tab not found');
|
||||
}
|
||||
|
||||
tabsManager.removeTab(tab, true);
|
||||
// keep URL in sync with the new current tab
|
||||
const current = tabsManager.getCurrentTab();
|
||||
if (current) {
|
||||
locationService.partial({ [tabsManager.getUrlKey()]: current.getSlug() }, true);
|
||||
}
|
||||
|
||||
results.push({ op: 'removeTab', slug: tab.getSlug(), title: tab.state.title });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'setPanelTitle') {
|
||||
const panelId = op['panelId'];
|
||||
const title = op['title'];
|
||||
if (typeof panelId !== 'number' || typeof title !== 'string') {
|
||||
throw new Error('setPanelTitle expects { panelId: number, title: string }');
|
||||
}
|
||||
const panels = getPanelsById(panelId);
|
||||
if (panels.length === 0) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
for (const panel of panels) {
|
||||
panel.onTitleChange?.(title);
|
||||
}
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'setGridPos') {
|
||||
const panelIdRaw = op['panelId'];
|
||||
const xRaw = op['x'];
|
||||
const yRaw = op['y'];
|
||||
const wRaw = op['w'];
|
||||
const hRaw = op['h'];
|
||||
if (
|
||||
typeof panelIdRaw !== 'number' ||
|
||||
typeof xRaw !== 'number' ||
|
||||
typeof yRaw !== 'number' ||
|
||||
typeof wRaw !== 'number' ||
|
||||
typeof hRaw !== 'number'
|
||||
) {
|
||||
throw new Error('setGridPos expects { panelId: number, x: number, y: number, w: number, h: number }');
|
||||
}
|
||||
const panelId = panelIdRaw;
|
||||
const x = xRaw;
|
||||
const y = yRaw;
|
||||
const w = wRaw;
|
||||
const h = hRaw;
|
||||
|
||||
const panels = getPanelsById(panelId);
|
||||
const panel = panels[0];
|
||||
if (!panel) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
const gridItem = findParent(panel, (o): o is DashboardGridItem => o instanceof DashboardGridItem);
|
||||
if (!gridItem) {
|
||||
// AutoGrid does not support explicit positions; require full apply for now.
|
||||
throw new Error('setGridPos is only supported for GridLayout panels');
|
||||
}
|
||||
gridItem.setState({ x, y, width: w, height: h });
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opName === 'mergePanelConfig') {
|
||||
const panelId = op['panelId'];
|
||||
const mergeObj = op['merge'];
|
||||
if (typeof panelId !== 'number' || !isRecord(mergeObj)) {
|
||||
throw new Error('mergePanelConfig expects { panelId: number, merge: object }');
|
||||
}
|
||||
const panels = getPanelsById(panelId);
|
||||
if (panels.length === 0) {
|
||||
throw new Error(`Panel not found: ${panelId}`);
|
||||
}
|
||||
|
||||
// Accept either { vizConfig: { fieldConfig?, options? } } or { fieldConfig?, options? }.
|
||||
const vizConfigAny = mergeObj['vizConfig'];
|
||||
const vizConfig = isRecord(vizConfigAny) ? vizConfigAny : undefined;
|
||||
const fieldConfigAny = (vizConfig && vizConfig['fieldConfig']) ?? mergeObj['fieldConfig'];
|
||||
const optionsAny = (vizConfig && vizConfig['options']) ?? mergeObj['options'];
|
||||
|
||||
for (const panel of panels) {
|
||||
// Field config: support defaults merge (units, decimals, thresholds, mappings, etc).
|
||||
if (isRecord(fieldConfigAny)) {
|
||||
const defaultsAny = fieldConfigAny['defaults'];
|
||||
if (isRecord(defaultsAny)) {
|
||||
const prevDefaults = panel.state.fieldConfig?.defaults ?? {};
|
||||
const prevOverrides = panel.state.fieldConfig?.overrides ?? [];
|
||||
const nextDefaults = { ...prevDefaults, ...defaultsAny };
|
||||
const nextFieldConfig = { defaults: nextDefaults, overrides: prevOverrides };
|
||||
// Prefer `onFieldConfigChange` so the panel clears its internal field-config cache and
|
||||
// applies plugin defaults consistently (needed for correct rendering, e.g. stat thresholds).
|
||||
try {
|
||||
panel.onFieldConfigChange?.(nextFieldConfig, true);
|
||||
} catch {
|
||||
// Fallback to a plain state update if plugin defaults application fails.
|
||||
panel.setState({ fieldConfig: nextFieldConfig });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Options: allow passing arbitrary plugin options object (DeepPartial<{}> is permissive).
|
||||
if (isRecord(optionsAny)) {
|
||||
panel.onOptionsChange?.(optionsAny, false);
|
||||
}
|
||||
|
||||
// If merge included unknown keys, attempt a safe best-effort by merging state keys we recognize.
|
||||
// (This intentionally stays conservative; prefer fieldConfig/options.)
|
||||
const title = mergeObj['title'];
|
||||
if (typeof title === 'string') {
|
||||
panel.onTitleChange?.(title);
|
||||
}
|
||||
}
|
||||
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(`Unsupported op: ${opName}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
errors.push(`${String(opName)}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty explicitly if currently editing and we applied changes. This helps keep Save enabled even if
|
||||
// we applied changes before the change tracker picks them up.
|
||||
if (applied > 0 && dashboard.state.isEditing) {
|
||||
dashboard.setState({ isDirty: true });
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
ok: errors.length === 0,
|
||||
applied,
|
||||
results: results.length ? results : undefined,
|
||||
errors: errors.length ? errors : undefined,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
+6
-2
@@ -140,9 +140,13 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
|
||||
};
|
||||
|
||||
try {
|
||||
// Remove nulls before validation since schema v2 does not support null for many fields,
|
||||
// but the live scene state can still contain nulls (for example, in thresholds step values).
|
||||
const cleaned = sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
|
||||
|
||||
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
|
||||
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
|
||||
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
|
||||
if (validateDashboardSchemaV2(cleaned)) {
|
||||
return cleaned;
|
||||
}
|
||||
// should never reach this point, validation should throw an error
|
||||
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);
|
||||
|
||||
@@ -25,6 +25,7 @@ declare global {
|
||||
};
|
||||
navigation: {
|
||||
getCurrent: (space?: number) => string;
|
||||
getSelection: (space?: number) => string;
|
||||
selectTab: (tabJson: string) => void;
|
||||
focusRow: (rowJson: string) => void;
|
||||
focusPanel: (panelJson: string) => void;
|
||||
@@ -43,6 +44,8 @@ declare global {
|
||||
dashboard: {
|
||||
getCurrent: (space?: number) => string;
|
||||
apply: (resourceJson: string) => void;
|
||||
previewOps: (opsJson: string) => string;
|
||||
applyOps: (opsJson: string) => string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user