support partial updates

This commit is contained in:
oscarkilhed
2025-12-20 19:05:55 +01:00
parent e796825e63
commit 93551386cc
6 changed files with 984 additions and 3 deletions
@@ -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),
@@ -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
);
},
};
@@ -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);
+3
View File
@@ -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;
};
};
}