From 1a7c2a4f38fe54d5152e7fe0ccee672c824d32b8 Mon Sep 17 00:00:00 2001 From: oscarkilhed Date: Sat, 20 Dec 2025 14:55:18 +0100 Subject: [PATCH] dashboard: expose DashboardScene JSON API with recovery fallback --- .../src/services/dashboardSceneJsonApi.ts | 57 +++++++ .../grafana-runtime/src/services/index.ts | 1 + public/app/app.ts | 6 + .../api/currentDashboardKindV2.test.ts | 68 +++++++++ .../api/currentDashboardKindV2.ts | 70 +++++++++ .../api/currentDashboardSpecApplyV2.test.ts | 141 ++++++++++++++++++ .../api/currentDashboardSpecApplyV2.ts | 126 ++++++++++++++++ .../runtimeDashboardSceneJsonApiV2.test.ts | 92 ++++++++++++ .../api/runtimeDashboardSceneJsonApiV2.ts | 122 +++++++++++++++ public/app/features/runtime/init.ts | 18 +++ 10 files changed, 701 insertions(+) create mode 100644 packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts create mode 100644 public/app/features/dashboard-scene/api/currentDashboardKindV2.test.ts create mode 100644 public/app/features/dashboard-scene/api/currentDashboardKindV2.ts create mode 100644 public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts create mode 100644 public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts create mode 100644 public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.test.ts create mode 100644 public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts diff --git a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts new file mode 100644 index 00000000000..b416b88497c --- /dev/null +++ b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts @@ -0,0 +1,57 @@ +export interface DashboardSceneJsonApiV2 { + /** + * Read the currently open dashboard as v2beta1 Dashboard kind JSON (JSON string). + */ + getCurrentDashboard(space?: number): string; + + /** + * Apply a v2beta1 Dashboard kind JSON (JSON string). + * + * Implementations must enforce **spec-only** updates by rejecting any changes to + * `apiVersion`, `kind`, `metadata`, or `status`. + */ + applyCurrentDashboard(resourceJson: string): void; +} + +let singletonInstance: DashboardSceneJsonApiV2 | undefined; + +/** + * Used during startup by Grafana to register the implementation. + * + * @internal + */ +export function setDashboardSceneJsonApiV2(instance: DashboardSceneJsonApiV2) { + singletonInstance = instance; +} + +/** + * Returns the registered DashboardScene JSON API. + * + * @public + */ +export function getDashboardSceneJsonApiV2(): DashboardSceneJsonApiV2 { + if (!singletonInstance) { + throw new Error('DashboardScene JSON API is not available'); + } + return singletonInstance; +} + +/** + * Plugin-friendly helper to read the current dashboard as kind JSON (JSON string). + * + * @public + */ +export function getCurrentDashboard(space = 2): string { + return getDashboardSceneJsonApiV2().getCurrentDashboard(space); +} + +/** + * JSON-string helper to apply a v2 Dashboard kind JSON (spec-only enforcement happens in the implementation). + * + * @public + */ +export function applyCurrentDashboard(resourceJson: string): void { + return getDashboardSceneJsonApiV2().applyCurrentDashboard(resourceJson); +} + + diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 42ef1c3f655..18087defe96 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -6,6 +6,7 @@ export * from './templateSrv'; export * from './live'; export * from './LocationService'; export * from './appEvents'; +export * from './dashboardSceneJsonApi'; export { setPluginComponentHook, diff --git a/public/app/app.ts b/public/app/app.ts index 8c9c5167e42..79559290369 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,6 +41,7 @@ import { setCorrelationsService, setPluginFunctionsHook, setMegaMenuOpenHook, + setDashboardSceneJsonApiV2, } from '@grafana/runtime'; import { initOpenFeature, @@ -85,6 +86,7 @@ import { startMeasure, stopMeasure } from './core/utils/metrics'; import { initAlerting } from './features/alerting/unified/initAlerting'; import { initAuthConfig } from './features/auth-config'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; +import { dashboardSceneJsonApiV2 } from './features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2'; import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy'; import { DashboardLevelTimeMacro } from './features/dashboard-scene/scene/DashboardLevelTimeMacro'; import { initGrafanaLive } from './features/live'; @@ -251,6 +253,10 @@ export class GrafanaApp { const dataSourceSrv = new DatasourceSrv(); dataSourceSrv.init(config.datasources, config.defaultDatasource); setDataSourceSrv(dataSourceSrv); + + // Expose current-dashboard schema-v2 JSON APIs to plugins via @grafana/runtime + setDashboardSceneJsonApiV2(dashboardSceneJsonApiV2); + initWindowRuntime(); // Do not pre-load apps if rendererDisableAppPluginsPreload is true and the request comes from the image renderer diff --git a/public/app/features/dashboard-scene/api/currentDashboardKindV2.test.ts b/public/app/features/dashboard-scene/api/currentDashboardKindV2.test.ts new file mode 100644 index 00000000000..d1c2dca34ae --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardKindV2.test.ts @@ -0,0 +1,68 @@ +import { defaultSpec as defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; + +import { getCurrentDashboardKindV2 } from './currentDashboardKindV2'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + kubernetesDashboards: true, + kubernetesDashboardsV2: true, + dashboardNewLayouts: false, + }, + }, +})); + +jest.mock('../pages/DashboardScenePageStateManager', () => ({ + getDashboardScenePageStateManager: jest.fn(), +})); + +describe('getCurrentDashboardKindV2', () => { + const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns v2beta1 Dashboard kind JSON for the currently open dashboard', () => { + const spec = defaultDashboardV2Spec(); + + const dashboard = { + state: { + uid: 'dash-uid', + meta: { + k8s: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: 'now', + annotations: {}, + labels: {}, + }, + }, + }, + getSaveResource: () => ({ + apiVersion: 'dashboard.grafana.app/v2beta1', + kind: 'Dashboard', + metadata: { name: 'dash-uid' }, + spec, + }), + }; + + getDashboardScenePageStateManager.mockReturnValue({ + state: { dashboard }, + }); + + const res = getCurrentDashboardKindV2(); + expect(res.apiVersion).toBe('dashboard.grafana.app/v2beta1'); + expect(res.kind).toBe('Dashboard'); + expect(res.metadata.name).toBe('dash-uid'); + expect(res.spec).toBe(spec); + }); + + it('throws if no dashboard is currently open', () => { + getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: undefined } }); + expect(() => getCurrentDashboardKindV2()).toThrow('No dashboard is currently open'); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/currentDashboardKindV2.ts b/public/app/features/dashboard-scene/api/currentDashboardKindV2.ts new file mode 100644 index 00000000000..b70c1ff9730 --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardKindV2.ts @@ -0,0 +1,70 @@ +import { config } from '@grafana/runtime'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; +import { Resource } from 'app/features/apiserver/types'; +import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; + +function assertDashboardV2Enabled() { + const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards); + const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts); + + if (!isKubernetesDashboardsEnabled || !isV2Enabled) { + throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled'); + } +} + +function getCurrentDashboardScene() { + const mgr = getDashboardScenePageStateManager(); + const dashboard = mgr.state.dashboard; + if (!dashboard) { + throw new Error('No dashboard is currently open'); + } + return dashboard; +} + +/** + * Returns the currently open dashboard as a v2beta1 Dashboard kind JSON resource. + * + * Note: This is intentionally scoped to the current `DashboardScene` only (no lookups by UID). + */ +export function getCurrentDashboardKindV2(): Resource { + assertDashboardV2Enabled(); + + const scene = getCurrentDashboardScene(); + + // Use the scene’s canonical “save resource” representation to avoid hand-assembling fields. + const saveResource = scene.getSaveResource({ isNew: !scene.state.uid }); + if (saveResource.apiVersion !== 'dashboard.grafana.app/v2beta1' || saveResource.kind !== 'Dashboard') { + throw new Error('Current dashboard is not a v2beta1 Dashboard resource'); + } + + const spec = saveResource.spec as unknown; + if (!isDashboardV2Spec(spec)) { + throw new Error('Current dashboard is not using schema v2 spec'); + } + + const k8sMeta = scene.state.meta.k8s; + if (!k8sMeta) { + throw new Error('Current dashboard is missing Kubernetes metadata'); + } + + return { + apiVersion: saveResource.apiVersion, + kind: 'Dashboard', + metadata: { + ...k8sMeta, + // Prefer the metadata coming from the save resource for name/generateName if present. + name: (saveResource.metadata?.name ?? k8sMeta.name) as string, + namespace: saveResource.metadata?.namespace ?? k8sMeta.namespace, + labels: saveResource.metadata?.labels ?? k8sMeta.labels, + annotations: saveResource.metadata?.annotations ?? k8sMeta.annotations, + }, + spec, + // We currently don’t persist/status-sync status in the scene; keep it stable and non-authoritative. + status: {} as Status, + }; +} + + diff --git a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts new file mode 100644 index 00000000000..5057cf2a8fd --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.test.ts @@ -0,0 +1,141 @@ +import { defaultSpec as defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; + +import { getCurrentDashboardKindV2 } from './currentDashboardKindV2'; +import { applyCurrentDashboardKindV2, applyCurrentDashboardSpecV2 } from './currentDashboardSpecApplyV2'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + kubernetesDashboards: true, + kubernetesDashboardsV2: true, + dashboardNewLayouts: false, + }, + }, +})); + +jest.mock('../pages/DashboardScenePageStateManager', () => ({ + getDashboardScenePageStateManager: jest.fn(), +})); + +jest.mock('../serialization/transformSaveModelSchemaV2ToScene', () => ({ + transformSaveModelSchemaV2ToScene: jest.fn(), +})); + +describe('current dashboard spec apply API', () => { + const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager'); + const { transformSaveModelSchemaV2ToScene } = jest.requireMock('../serialization/transformSaveModelSchemaV2ToScene'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('applyCurrentDashboardSpecV2 swaps in a new scene immediately and marks it dirty', () => { + const currentSpec = defaultDashboardV2Spec(); + const nextSpec = { ...defaultDashboardV2Spec(), title: 'new title' }; + + const currentScene = { + state: { + uid: 'dash-uid', + meta: { + url: '/d/dash-uid/slug', + slug: 'slug', + canSave: true, + canEdit: true, + canDelete: true, + canShare: true, + canStar: true, + canAdmin: true, + publicDashboardEnabled: false, + k8s: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: 'now', + annotations: {}, + labels: {}, + }, + }, + isEditing: true, + }, + getSaveModel: () => currentSpec, + }; + + const nextScene = { + onEnterEditMode: jest.fn(), + setState: jest.fn(), + }; + + transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene); + + const mgr = { + state: { dashboard: currentScene }, + setSceneCache: jest.fn(), + setState: jest.fn(), + }; + + getDashboardScenePageStateManager.mockReturnValue(mgr); + + applyCurrentDashboardSpecV2(nextSpec); + + expect(transformSaveModelSchemaV2ToScene).toHaveBeenCalledTimes(1); + expect(nextScene.onEnterEditMode).toHaveBeenCalled(); + expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: true }); + expect(mgr.setSceneCache).toHaveBeenCalledWith('dash-uid', nextScene); + expect(mgr.setState).toHaveBeenCalledWith({ dashboard: nextScene }); + }); + + it('applyCurrentDashboardKindV2 rejects metadata changes and applies only spec when unchanged', () => { + const spec = defaultDashboardV2Spec(); + + const currentScene = { + state: { + uid: 'dash-uid', + meta: { + url: '/d/dash-uid/slug', + slug: 'slug', + canSave: true, + canEdit: true, + canDelete: true, + canShare: true, + canStar: true, + canAdmin: true, + publicDashboardEnabled: false, + k8s: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: 'now', + annotations: {}, + labels: {}, + }, + }, + isEditing: true, + }, + getSaveModel: () => spec, + getSaveResource: () => ({ + apiVersion: 'dashboard.grafana.app/v2beta1', + kind: 'Dashboard', + metadata: { name: 'dash-uid' }, + spec, + }), + }; + + const nextScene = { onEnterEditMode: jest.fn(), setState: jest.fn() }; + transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene); + + const mgr = { state: { dashboard: currentScene }, setSceneCache: jest.fn(), setState: jest.fn() }; + getDashboardScenePageStateManager.mockReturnValue(mgr); + + const current = getCurrentDashboardKindV2(); + expect(() => + applyCurrentDashboardKindV2({ + ...current, + metadata: { + ...current.metadata, + annotations: { ...(current.metadata.annotations ?? {}), 'grafana.app/message': 'changed' }, + }, + }) + ).toThrow('Changing metadata is not allowed'); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts new file mode 100644 index 00000000000..35cef236f00 --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardSpecApplyV2.ts @@ -0,0 +1,126 @@ +import { isEqual } from 'lodash'; + +import { config } from '@grafana/runtime'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { Status } from '@grafana/schema/src/schema/dashboard/v2'; +import { Resource } from 'app/features/apiserver/types'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; +import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene'; +import { validateDashboardSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; + +import { getCurrentDashboardKindV2 } from './currentDashboardKindV2'; + +function assertDashboardV2Enabled() { + const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards); + const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts); + + if (!isKubernetesDashboardsEnabled || !isV2Enabled) { + throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled'); + } +} + +function getCurrentSceneOrThrow() { + const mgr = getDashboardScenePageStateManager(); + const dashboard = mgr.state.dashboard; + if (!dashboard) { + throw new Error('No dashboard is currently open'); + } + return { mgr, dashboard }; +} + +/** + * Immediately applies a schema-v2 dashboard spec to the currently open `DashboardScene`. + * + * This is a **spec-only** mutation API: it does not allow changing `apiVersion`, `kind`, `metadata`, or `status`. + */ +export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void { + assertDashboardV2Enabled(); + + // Validate (throws on error) + validateDashboardSchemaV2(nextSpec as unknown); + + const { mgr, dashboard: currentScene } = getCurrentSceneOrThrow(); + + // Only operate on v2 scenes + const currentModel = currentScene.getSaveModel(); + if (!isDashboardV2Spec(currentModel)) { + throw new Error('Current dashboard is not using schema v2'); + } + + const k8sMeta = currentScene.state.meta.k8s; + if (!k8sMeta) { + throw new Error('Current dashboard is missing Kubernetes metadata'); + } + + // Rebuild a new scene from the current immutable wrapper (metadata/access) + new spec. + // This guarantees the UI updates immediately to match the JSON. + const dto: DashboardWithAccessInfo = { + apiVersion: 'dashboard.grafana.app/v2beta1', + kind: 'DashboardWithAccessInfo', + metadata: k8sMeta, + spec: nextSpec, + status: {} as Status, + access: { + url: currentScene.state.meta.url, + slug: currentScene.state.meta.slug, + canSave: currentScene.state.meta.canSave, + canEdit: currentScene.state.meta.canEdit, + canDelete: currentScene.state.meta.canDelete, + canShare: currentScene.state.meta.canShare, + canStar: currentScene.state.meta.canStar, + canAdmin: currentScene.state.meta.canAdmin, + annotationsPermissions: currentScene.state.meta.annotationsPermissions, + isPublic: currentScene.state.meta.publicDashboardEnabled, + }, + }; + + const nextScene = transformSaveModelSchemaV2ToScene(dto); + + // Keep edit mode semantics consistent with other JSON-apply flows: + // - Enter edit mode if needed + // - Mark dirty because the spec changed + if (currentScene.state.isEditing) { + nextScene.onEnterEditMode(); + } else { + nextScene.onEnterEditMode(); + } + nextScene.setState({ isDirty: true }); + + // Keep cache coherent for the currently open dashboard + if (currentScene.state.uid) { + mgr.setSceneCache(currentScene.state.uid, nextScene); + } + + mgr.setState({ dashboard: nextScene }); +} + +/** + * Convenience helper that accepts a full Dashboard kind JSON object, but enforces **spec-only** updates. + * + * It rejects any attempt to change `apiVersion`, `kind`, `metadata`, or `status` from the currently open dashboard. + */ +export function applyCurrentDashboardKindV2(resource: Resource): void { + assertDashboardV2Enabled(); + + const current = getCurrentDashboardKindV2(); + + if (!isEqual(resource.apiVersion, current.apiVersion)) { + throw new Error('Changing apiVersion is not allowed'); + } + if (!isEqual(resource.kind, current.kind)) { + throw new Error('Changing kind is not allowed'); + } + if (!isEqual(resource.metadata, current.metadata)) { + throw new Error('Changing metadata is not allowed'); + } + if ('status' in resource && !isEqual(resource.status, current.status)) { + throw new Error('Changing status is not allowed'); + } + + applyCurrentDashboardSpecV2(resource.spec); +} + + diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.test.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.test.ts new file mode 100644 index 00000000000..8d346e9bfa7 --- /dev/null +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.test.ts @@ -0,0 +1,92 @@ +import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2'; + +jest.mock('./currentDashboardKindV2', () => ({ + getCurrentDashboardKindV2: jest.fn(), +})); + +jest.mock('./currentDashboardSpecApplyV2', () => ({ + applyCurrentDashboardSpecV2: jest.fn(), +})); + +describe('dashboardSceneJsonApiV2 (runtime adapter)', () => { + const { getCurrentDashboardKindV2 } = jest.requireMock('./currentDashboardKindV2'); + const { applyCurrentDashboardSpecV2 } = jest.requireMock('./currentDashboardSpecApplyV2'); + + const baseResource = { + apiVersion: 'dashboard.grafana.app/v2beta1', + kind: 'Dashboard', + metadata: { name: 'dash-uid', namespace: 'default' }, + spec: { title: 'x' }, + status: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.history.pushState({}, '', '/d/dash-uid/slug'); + }); + + it('getCurrentDashboard returns cached JSON if live serialization fails', () => { + getCurrentDashboardKindV2.mockReturnValue(baseResource); + const first = dashboardSceneJsonApiV2.getCurrentDashboard(0); + expect(JSON.parse(first).metadata.name).toBe('dash-uid'); + + getCurrentDashboardKindV2.mockImplementation(() => { + throw new Error('Unsupported transformation type'); + }); + const second = dashboardSceneJsonApiV2.getCurrentDashboard(0); + expect(second).toBe(first); + }); + + it('applyCurrentDashboard uses cached baseline to enforce immutability if live serialization fails', () => { + // Prime cache + getCurrentDashboardKindV2.mockReturnValue(baseResource); + dashboardSceneJsonApiV2.getCurrentDashboard(0); + + // Now break live serialization + getCurrentDashboardKindV2.mockImplementation(() => { + throw new Error('Unsupported transformation type'); + }); + + expect(() => + dashboardSceneJsonApiV2.applyCurrentDashboard( + JSON.stringify({ + ...baseResource, + apiVersion: 'dashboard.grafana.app/v2alpha1', + }) + ) + ).toThrow('Changing apiVersion is not allowed'); + }); + + it('applyCurrentDashboard can recover without a baseline by validating against URL UID and applying spec', () => { + getCurrentDashboardKindV2.mockImplementation(() => { + throw new Error('Unsupported transformation type'); + }); + + const nextSpec = { title: 'recovered' }; + dashboardSceneJsonApiV2.applyCurrentDashboard( + JSON.stringify({ + ...baseResource, + spec: nextSpec, + }) + ); + + expect(applyCurrentDashboardSpecV2).toHaveBeenCalledWith(nextSpec); + }); + + it('applyCurrentDashboard rejects recovery attempts targeting a different dashboard UID', () => { + getCurrentDashboardKindV2.mockImplementation(() => { + throw new Error('Unsupported transformation type'); + }); + + expect(() => + dashboardSceneJsonApiV2.applyCurrentDashboard( + JSON.stringify({ + ...baseResource, + metadata: { ...baseResource.metadata, name: 'other-uid' }, + }) + ) + ).toThrow('Changing metadata is not allowed'); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts new file mode 100644 index 00000000000..6f6d6523428 --- /dev/null +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts @@ -0,0 +1,122 @@ +import { isEqual } from 'lodash'; + +import type { DashboardSceneJsonApiV2 } from '@grafana/runtime'; + +import { getCurrentDashboardKindV2 as getCurrentDashboardResourceV2 } from './currentDashboardKindV2'; +import { applyCurrentDashboardSpecV2 } from './currentDashboardSpecApplyV2'; + +type DashboardResourceV2 = ReturnType; + +/** + * The dashboard JSON API is required to be resilient for automation. + * + * In practice, the currently loaded `DashboardScene` might temporarily be in a state that cannot be + * serialized back to a v2 resource (for example, if the scene contains unsupported transformation types). + * + * To avoid “bricking” the API in that situation (where both `getCurrentDashboard()` and + * `applyCurrentDashboard()` would fail because they need to read the current resource), + * we keep a last-known-good dashboard resource cached as a recovery fallback. + */ +let lastKnownGoodResource: DashboardResourceV2 | undefined; + +function getDashboardUidFromUrl(): string | undefined { + const pathname = globalThis.location?.pathname ?? ''; + // Expected: /d// + const match = pathname.match(/\/d\/([^/]+)/); + return match?.[1]; +} + +function getCurrentDashboardResourceWithFallback(): { resource: DashboardResourceV2; source: 'live' | 'cache' } { + try { + const resource = getCurrentDashboardResourceV2(); + lastKnownGoodResource = resource; + return { resource, source: 'live' }; + } catch (err) { + if (lastKnownGoodResource) { + return { resource: lastKnownGoodResource, source: 'cache' }; + } + + const details = err instanceof Error ? err.message : String(err); + throw new Error( + 'DashboardScene JSON API could not read the current dashboard resource. ' + + 'This can happen if the loaded DashboardScene cannot be serialized to schema v2. ' + + 'To recover, call applyCurrentDashboard() with a valid v2beta1 Dashboard JSON (spec-only changes) ' + + 'whose metadata.name matches the dashboard UID in the URL.\n\n' + + `Underlying error: ${details}` + ); + } +} + +export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = { + getCurrentDashboard: (space = 2) => { + const { resource } = getCurrentDashboardResourceWithFallback(); + return JSON.stringify(resource, null, space); + }, + + applyCurrentDashboard: (resourceJson: string) => { + const resource = JSON.parse(resourceJson); + let current: DashboardResourceV2 | undefined; + try { + // Prefer live for strict immutability checks, but fall back to cached baseline. + current = getCurrentDashboardResourceV2(); + lastKnownGoodResource = current; + } catch { + current = lastKnownGoodResource; + } + + // If we can’t read the current resource at all (no cache), we still allow recovery by validating + // that the caller targets the currently open dashboard, and that the payload is a v2beta1 Dashboard. + if (!current) { + const uidFromUrl = getDashboardUidFromUrl(); + + if (resource.apiVersion !== 'dashboard.grafana.app/v2beta1') { + throw new Error('Changing apiVersion is not allowed'); + } + if (resource.kind !== 'Dashboard') { + throw new Error('Changing kind is not allowed'); + } + if (!resource.metadata || typeof resource.metadata !== 'object') { + throw new Error('Changing metadata is not allowed'); + } + if (uidFromUrl && resource.metadata.name !== uidFromUrl) { + throw new Error('Changing metadata is not allowed'); + } + if (!('status' in resource)) { + // Keep error message consistent; callers should include status even if empty. + throw new Error('Changing status is not allowed'); + } + + applyCurrentDashboardSpecV2(resource.spec); + // Best-effort refresh cache after recovery. + try { + lastKnownGoodResource = getCurrentDashboardResourceV2(); + } catch { + // ignore + } + return; + } + + if (!isEqual(resource.apiVersion, current.apiVersion)) { + throw new Error('Changing apiVersion is not allowed'); + } + if (!isEqual(resource.kind, current.kind)) { + throw new Error('Changing kind is not allowed'); + } + if (!isEqual(resource.metadata, current.metadata)) { + throw new Error('Changing metadata is not allowed'); + } + if (!isEqual(resource.status, current.status)) { + throw new Error('Changing status is not allowed'); + } + + applyCurrentDashboardSpecV2(resource.spec); + // Best-effort cache refresh after apply. + try { + lastKnownGoodResource = getCurrentDashboardResourceV2(); + } catch { + // ignore + } + }, +}; + + diff --git a/public/app/features/runtime/init.ts b/public/app/features/runtime/init.ts index 6960d0183d9..00def9924d4 100644 --- a/public/app/features/runtime/init.ts +++ b/public/app/features/runtime/init.ts @@ -1,4 +1,8 @@ import { PanelData, RawTimeRange } from '@grafana/data'; +import { + applyCurrentDashboard, + getCurrentDashboard, +} from '@grafana/runtime'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -11,6 +15,14 @@ declare global { getDashboardTimeRange: () => { from: number; to: number; raw: RawTimeRange }; getPanelData: () => Record | undefined; }; + /** + * Exposes the current-dashboard schema v2 JSON API for debugging / automation. + * Intended for browser console usage. + */ + dashboardApi?: { + getCurrentDashboard: (space?: number) => string; + applyCurrentDashboard: (resourceJson: string) => void; + }; } } @@ -54,4 +66,10 @@ export function initWindowRuntime() { }, {}); }, }; + + // Expose the same API that plugins use via @grafana/runtime, but on `window` for easy console access. + window.dashboardApi = { + getCurrentDashboard, + applyCurrentDashboard, + }; }