From 93551386cc62794940c253b72f4ba2fcc457abe5 Mon Sep 17 00:00:00 2001 From: oscarkilhed Date: Sat, 20 Dec 2025 19:05:55 +0100 Subject: [PATCH] support partial updates --- .../src/services/dashboardSceneJsonApi.ts | 53 ++ ...DashboardSceneJsonApiV2.navigation.test.ts | 23 + ...runtimeDashboardSceneJsonApiV2.ops.test.ts | 152 ++++ .../api/runtimeDashboardSceneJsonApiV2.ts | 748 +++++++++++++++++- .../transformSceneToSaveModelSchemaV2.ts | 8 +- public/app/features/runtime/init.ts | 3 + 6 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ops.test.ts diff --git a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts index b0551abb7f7..33e05e65a61 100644 --- a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts +++ b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts @@ -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), diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts index 6796187ecdc..789d731f5a3 100644 --- a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.navigation.test.ts @@ -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(() => {}); diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ops.test.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ops.test.ts new file mode 100644 index 00000000000..7617be1cec2 --- /dev/null +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ops.test.ts @@ -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); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts index 670553ad138..2a4b818b9ec 100644 --- a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts @@ -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(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// @@ -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): Record { + // 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 + ); + }, }; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 4a63c62af93..1e3e4fbf3a7 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -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); diff --git a/public/app/features/runtime/init.ts b/public/app/features/runtime/init.ts index e6c2ea44279..aa9f6d7af93 100644 --- a/public/app/features/runtime/init.ts +++ b/public/app/features/runtime/init.ts @@ -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; }; }; }