dashboard: expose DashboardScene JSON API with recovery fallback
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './templateSrv';
|
||||
export * from './live';
|
||||
export * from './LocationService';
|
||||
export * from './appEvents';
|
||||
export * from './dashboardSceneJsonApi';
|
||||
|
||||
export {
|
||||
setPluginComponentHook,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<DashboardV2Spec, Status, 'Dashboard'> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<DashboardV2Spec> = {
|
||||
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<DashboardV2Spec, Status, 'Dashboard'>): 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof getCurrentDashboardResourceV2>;
|
||||
|
||||
/**
|
||||
* 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/<uid>/<slug>
|
||||
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
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<number, PanelData | undefined> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user