Compare commits

...

6 Commits

Author SHA1 Message Date
oscarkilhed
0f1e8bb94e add more ops, fix panel plugin changes not updating 2025-12-20 20:57:48 +01:00
oscarkilhed
93551386cc support partial updates 2025-12-20 19:05:55 +01:00
oscarkilhed
e796825e63 add schema 2025-12-20 17:24:07 +01:00
oscarkilhed
7fec275695 add navigation and structure api 2025-12-20 16:37:49 +01:00
oscarkilhed
090078eb80 get dashboard errors 2025-12-20 15:09:57 +01:00
oscarkilhed
1a7c2a4f38 dashboard: expose DashboardScene JSON API with recovery fallback 2025-12-20 14:55:18 +01:00
15 changed files with 2999 additions and 2 deletions

View File

@@ -0,0 +1,460 @@
export interface DashboardSceneJsonApiV2 {
/**
* Read the currently open dashboard as v2beta1 Dashboard kind JSON (JSON string).
*/
getCurrentDashboard(space?: number): string;
/**
* Read query errors for the currently open dashboard (JSON string).
*
* This returns a JSON array of objects shaped like:
* `{ panelId, panelTitle, refId?, datasource?, message, severity }`.
*/
getCurrentDashboardErrors(space?: number): string;
/**
* Read current dashboard variables (JSON string).
*
* This returns JSON shaped like:
* `{ variables: [{ name, value }] }`
* where `value` is `string | string[]`.
*/
getCurrentDashboardVariables(space?: number): string;
/**
* Apply dashboard variable values (JSON string).
*
* Accepts either:
* - `{ variables: [{ name, value }] }`
* - or a map `{ [name]: value }`
*
* where `value` is `string | string[]`.
*/
applyCurrentDashboardVariables(varsJson: string): void;
/**
* Read the current dashboard time range (JSON string).
*
* This returns JSON shaped like:
* `{ from, to, timezone? }`.
*/
getCurrentDashboardTimeRange(space?: number): string;
/**
* Apply the current dashboard time range (JSON string).
*
* Accepts JSON shaped like:
* `{ from, to, timezone? }` where `from/to` are Grafana raw strings (e.g. `now-6h`, `now`).
*/
applyCurrentDashboardTimeRange(timeRangeJson: string): void;
/**
* Select a tab within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ title?: string, slug?: string }`.
*/
selectCurrentDashboardTab(tabJson: string): void;
/**
* Read current in-dashboard navigation state (JSON string).
*
* This returns JSON shaped like:
* `{ tab: { slug: string, title?: string } | null }`.
*/
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).
*
* Accepts JSON shaped like:
* `{ title?: string, rowKey?: string }`.
*/
focusCurrentDashboardRow(rowJson: string): void;
/**
* Scroll/focus a panel within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ panelId: number }`.
*/
focusCurrentDashboardPanel(panelJson: string): void;
/**
* 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;
/**
* 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;
/**
* 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;
}
/**
* A grouped, ergonomic API wrapper around the DashboardScene JSON API.
*
* This is purely a convenience layer: it calls the same underlying registered implementation
* as the top-level helper functions, but organizes functionality into namespaces like
* `navigation`, `variables`, and `timeRange`.
*
* @public
*/
export function getDashboardApi() {
const api = getDashboardSceneJsonApiV2();
let cachedDashboardSchemaBundle: { bundle: unknown; loadedAt: number } | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function collectRefs(value: unknown, out: Set<string>) {
if (Array.isArray(value)) {
for (const v of value) {
collectRefs(v, out);
}
return;
}
if (!isRecord(value)) {
return;
}
for (const [k, v] of Object.entries(value)) {
if (k === '$ref' && typeof v === 'string') {
out.add(v);
} else {
collectRefs(v, out);
}
}
}
function buildOpenApiSchemaBundle(openapi: unknown) {
if (!isRecord(openapi)) {
throw new Error('OpenAPI document is not an object');
}
const info = openapi['info'];
if (!isRecord(info)) {
throw new Error('OpenAPI document is missing info');
}
const title = info['title'];
if (typeof title !== 'string' || title.length === 0) {
throw new Error('OpenAPI document is missing info.title');
}
// This endpoint is expected to return the group/version doc, so validate it explicitly.
if (title !== 'dashboard.grafana.app/v2beta1') {
throw new Error(`OpenAPI document is not dashboard.grafana.app/v2beta1 (info.title="${title}")`);
}
const components = openapi['components'];
if (!isRecord(components)) {
throw new Error('OpenAPI document is missing components');
}
const schemas = components['schemas'];
if (!isRecord(schemas)) {
throw new Error('OpenAPI document is missing components.schemas');
}
// Find the Dashboard kind schema key by GVK annotation.
const dashboardKey = Object.entries(schemas).find(([_, schema]) => {
if (!isRecord(schema)) {
return false;
}
const gvk = schema['x-kubernetes-group-version-kind'];
if (!Array.isArray(gvk)) {
return false;
}
return gvk.some((x) => {
return (
isRecord(x) &&
x['group'] === 'dashboard.grafana.app' &&
x['version'] === 'v2beta1' &&
x['kind'] === 'Dashboard'
);
});
})?.[0];
if (!dashboardKey) {
throw new Error('Could not find dashboard.grafana.app/v2beta1 Dashboard schema in OpenAPI document');
}
const rootRef = `#/components/schemas/${dashboardKey}`;
const pickedSchemas: Record<string, unknown> = {};
const visited = new Set<string>();
const queue: string[] = [rootRef];
while (queue.length) {
const ref = queue.shift()!;
if (!ref.startsWith('#/components/schemas/')) {
continue;
}
const key = ref.slice('#/components/schemas/'.length);
if (visited.has(key)) {
continue;
}
visited.add(key);
const schema = schemas[key];
if (!schema) {
continue;
}
pickedSchemas[key] = schema;
const refs = new Set<string>();
collectRefs(schema, refs);
for (const r of refs) {
if (r.startsWith('#/components/schemas/')) {
queue.push(r);
}
}
}
// Sanity-check the root schema shape (helps LLM consumers, and catches wrong schema sources quickly).
const rootSchema = pickedSchemas[dashboardKey];
if (!isRecord(rootSchema)) {
throw new Error('Dashboard schema is not an object');
}
const required = rootSchema['required'];
if (!Array.isArray(required)) {
throw new Error('Dashboard schema is missing required fields list');
}
for (const req of ['apiVersion', 'kind', 'metadata', 'spec']) {
if (!required.includes(req)) {
throw new Error(`Dashboard schema is missing required field "${req}"`);
}
}
return {
format: 'openapi3.schemaBundle',
source: {
url: '/openapi/v3/apis/dashboard.grafana.app/v2beta1',
},
group: 'dashboard.grafana.app',
version: 'v2beta1',
kind: 'Dashboard',
root: { $ref: rootRef },
stats: { schemas: Object.keys(pickedSchemas).length },
validation: {
ok: true,
info: {
title,
},
root: {
ref: rootRef,
required: ['apiVersion', 'kind', 'metadata', 'spec'],
},
},
components: {
schemas: pickedSchemas,
},
};
}
return {
/**
* Prints/returns a quick reference for the grouped dashboard API, including expected JSON shapes.
*/
help: () => {
const text = [
'Dashboard API (DashboardScene JSON API, schema v2 kinds)',
'',
'All inputs/outputs are JSON strings.',
'Edits are spec-only: apiVersion/kind/metadata/status must not change.',
'',
'Schema (for LLMs):',
'- schema.getSources(space?): string',
'- schema.getDashboard(space?): Promise<string>',
'- schema.getDashboardSync(space?): string',
' - getDashboard() fetches the OpenAPI v3 document for dashboard.grafana.app/v2beta1 and returns a schema bundle.',
' - getDashboard() validates the document is for dashboard.grafana.app/v2beta1 and that the root schema is the Dashboard kind.',
'',
'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: {...} }',
'',
'Errors:',
'- errors.getCurrent(space?): string',
' - returns JSON: { errors: [{ panelId, panelTitle, refId?, datasource?, message, severity }] }',
'',
'Variables:',
'- variables.getCurrent(space?): string',
' - returns JSON: { variables: [{ name, value }] } where value is string | string[]',
'- variables.apply(varsJson: string): void',
' - accepts JSON: { variables: [{ name, value }] } OR { [name]: value }',
'',
'Time range:',
'- timeRange.getCurrent(space?): string',
' - returns JSON: { from: string, to: string, timezone?: string }',
'- timeRange.apply(timeRangeJson: string): void',
' - accepts JSON: { from: "now-6h", to: "now", timezone?: "browser" | "utc" | ... }',
'',
'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',
' - accepts JSON: { title?: string, rowKey?: string }',
'- navigation.focusPanel(panelJson: string): void',
' - accepts JSON: { panelId: number }',
'',
'Examples:',
'- const schema = JSON.parse(await window.dashboardApi.schema.getDashboard(0))',
'- window.dashboardApi.timeRange.apply(JSON.stringify({ from: "now-6h", to: "now", timezone: "browser" }))',
'- window.dashboardApi.navigation.selectTab(JSON.stringify({ title: "Overview" }))',
'- window.dashboardApi.navigation.focusPanel(JSON.stringify({ panelId: 12 }))',
].join('\n');
// Calling help is an explicit action; logging is useful in the browser console.
// Return the text as well so callers can print/store it as they prefer.
try {
// eslint-disable-next-line no-console
console.log(text);
} catch {
// ignore
}
return text;
},
schema: {
/**
* Returns where this API loads schema documents from.
*/
getSources: (space = 2) => {
return JSON.stringify(
{
openapi3: {
url: '/openapi/v3/apis/dashboard.grafana.app/v2beta1',
note: 'This is the Kubernetes-style OpenAPI document for dashboard.grafana.app/v2beta1. `schema.getDashboard()` extracts the Dashboard schemas into a smaller bundle.',
},
},
null,
space
);
},
/**
* Fetches and returns an OpenAPI schema bundle for `dashboard.grafana.app/v2beta1` `Dashboard`.
*
* Returns a JSON string (async) shaped like:
* `{ format, source, group, version, kind, root, stats, components: { schemas } }`.
*/
getDashboard: async (space = 2) => {
if (!cachedDashboardSchemaBundle) {
const rsp = await fetch('/openapi/v3/apis/dashboard.grafana.app/v2beta1', { credentials: 'same-origin' });
if (!rsp.ok) {
throw new Error(
`Failed to fetch OpenAPI document from /openapi/v3/apis/dashboard.grafana.app/v2beta1 (status ${rsp.status})`
);
}
const openapi: unknown = await rsp.json();
cachedDashboardSchemaBundle = { bundle: buildOpenApiSchemaBundle(openapi), loadedAt: Date.now() };
}
return JSON.stringify(cachedDashboardSchemaBundle.bundle, null, space);
},
/**
* Returns the cached schema bundle (sync), if previously loaded by `schema.getDashboard()`.
*/
getDashboardSync: (space = 2) => {
if (!cachedDashboardSchemaBundle) {
throw new Error('Schema bundle is not loaded. Call `await dashboardApi.schema.getDashboard()` first.');
}
return JSON.stringify(cachedDashboardSchemaBundle.bundle, null, space);
},
},
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) =>
JSON.stringify({ errors: JSON.parse(api.getCurrentDashboardErrors(space)) }, null, space),
},
variables: {
getCurrent: (space = 2) => api.getCurrentDashboardVariables(space),
apply: (varsJson: string) => api.applyCurrentDashboardVariables(varsJson),
},
timeRange: {
getCurrent: (space = 2) => api.getCurrentDashboardTimeRange(space),
apply: (timeRangeJson: string) => api.applyCurrentDashboardTimeRange(timeRangeJson),
},
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),
},
};
}

View File

@@ -6,6 +6,7 @@ export * from './templateSrv';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export * from './dashboardSceneJsonApi';
export {
setPluginComponentHook,

View File

@@ -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

View File

@@ -0,0 +1,73 @@
import { getCurrentDashboardErrors } from './currentDashboardErrors';
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('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(),
},
}));
jest.mock('../utils/utils', () => ({
getPanelIdForVizPanel: jest.fn(),
getQueryRunnerFor: jest.fn(),
}));
describe('getCurrentDashboardErrors', () => {
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
const { dashboardSceneGraph } = jest.requireMock('../utils/dashboardSceneGraph');
const { getPanelIdForVizPanel, getQueryRunnerFor } = jest.requireMock('../utils/utils');
beforeEach(() => {
jest.clearAllMocks();
});
it('returns an empty list when there are no query errors', () => {
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: {} } });
dashboardSceneGraph.getVizPanels.mockReturnValue([{ state: { title: 'P1' } }]);
getPanelIdForVizPanel.mockReturnValue(1);
getQueryRunnerFor.mockReturnValue({ state: { data: { errors: [] } } });
expect(getCurrentDashboardErrors()).toEqual([]);
});
it('returns per-panel error summaries with refId and datasource when available', () => {
const panel = { state: { title: 'My panel' } };
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: {} } });
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
getPanelIdForVizPanel.mockReturnValue(42);
getQueryRunnerFor.mockReturnValue({
state: {
datasource: { uid: 'fallback-ds' },
queries: [{ refId: 'A', datasource: { name: 'gdev-mysql' } }],
data: { errors: [{ message: 'boom', refId: 'A' }] },
},
});
expect(getCurrentDashboardErrors()).toEqual([
{
panelId: 42,
panelTitle: 'My panel',
refId: 'A',
datasource: 'gdev-mysql',
message: 'boom',
severity: 'error',
},
]);
});
});

View File

@@ -0,0 +1,119 @@
import { DataQueryError, DataQueryErrorType } from '@grafana/data';
import { config } from '@grafana/runtime';
import type { SceneDataQuery, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
export type DashboardPanelErrorSeverity = 'error' | 'warning';
export interface DashboardPanelErrorSummary {
panelId: number;
panelTitle: string;
refId?: string;
datasource?: string;
message: string;
severity: DashboardPanelErrorSeverity;
}
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;
}
function toMessage(err: DataQueryError): string {
return err.message || err.data?.message || err.data?.error || 'Query error';
}
function toSeverity(err: DataQueryError): DashboardPanelErrorSeverity {
// Treat cancellations as warnings; everything else is an error.
if (err.type === DataQueryErrorType.Cancelled) {
return 'warning';
}
return 'error';
}
function formatDatasourceRef(ds: unknown): string | undefined {
if (!ds || typeof ds !== 'object') {
return undefined;
}
const obj = ds as Record<string, unknown>;
const uid = obj.uid;
const name = obj.name;
const type = obj.type;
if (typeof uid === 'string' && uid.length) {
return uid;
}
if (typeof name === 'string' && name.length) {
return name;
}
if (typeof type === 'string' && type.length) {
return type;
}
return undefined;
}
function getDatasourceForError(queryRunner: SceneQueryRunner, refId?: string): string | undefined {
const queries = (queryRunner.state.queries ?? []) as SceneDataQuery[];
const q = refId ? queries.find((qq) => qq.refId === refId) : undefined;
const ds = q?.datasource ?? queryRunner.state.datasource;
return formatDatasourceRef(ds);
}
export function getCurrentDashboardErrors(): DashboardPanelErrorSummary[] {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardScene();
const panels = dashboardSceneGraph.getVizPanels(dashboard);
const out: DashboardPanelErrorSummary[] = [];
for (const panel of panels) {
const queryRunner = getQueryRunnerFor(panel);
const errors = queryRunner?.state.data?.errors ?? [];
if (!queryRunner || errors.length === 0) {
continue;
}
const panelId = getPanelIdForVizPanel(panel);
const panelTitle = (panel as VizPanel).state.title ?? '';
for (const err of errors) {
const refId = err.refId;
out.push({
panelId,
panelTitle,
refId,
datasource: getDatasourceForError(queryRunner, refId),
message: toMessage(err),
severity: toSeverity(err),
});
}
}
// Stable ordering for LLM consumption.
out.sort((a, b) => {
if (a.panelId !== b.panelId) {
return a.panelId - b.panelId;
}
return (a.refId ?? '').localeCompare(b.refId ?? '');
});
return out;
}

View File

@@ -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');
});
});

View File

@@ -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 scenes 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 dont persist/status-sync status in the scene; keep it stable and non-authoritative.
status: {} as Status,
};
}

View File

@@ -0,0 +1,196 @@
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,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: 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.setInitialSaveModel).toHaveBeenCalledWith(currentSpec, currentScene.state.meta.k8s, 'dashboard.grafana.app/v2beta1');
expect(nextScene.onEnterEditMode).toHaveBeenCalled();
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: true });
expect(mgr.setSceneCache).toHaveBeenCalledWith('dash-uid', nextScene);
expect(mgr.setState).toHaveBeenCalledWith({ dashboard: nextScene });
});
it('applyCurrentDashboardSpecV2 does not mark dirty when the applied spec matches the saved baseline', () => {
const currentSpec = defaultDashboardV2Spec();
const nextSpec = { ...currentSpec };
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,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: jest.fn(),
};
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
const mgr = {
state: { dashboard: currentScene },
setSceneCache: jest.fn(),
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue(mgr);
applyCurrentDashboardSpecV2(nextSpec);
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: false });
});
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');
});
});

View File

@@ -0,0 +1,135 @@
import { isEqual } from 'lodash';
import { config } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/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);
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 currentResource = getCurrentDashboardKindV2();
const k8sMeta = currentResource.metadata;
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: {},
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);
// IMPORTANT: Preserve the *saved baseline* (initialSaveModel) from the currently loaded dashboard,
// otherwise the change tracker will treat the applied spec as the new baseline and the dashboard
// won't become saveable (Save button stays non-primary).
const initialSaveModel = currentScene.getInitialSaveModel();
const hasBaseline = Boolean(initialSaveModel && isDashboardV2Spec(initialSaveModel));
if (initialSaveModel && isDashboardV2Spec(initialSaveModel)) {
nextScene.setInitialSaveModel(initialSaveModel, k8sMeta, 'dashboard.grafana.app/v2beta1');
}
// Preserve edit/view mode. Don't force-enter edit mode; that's a UI side effect.
if (currentScene.state.isEditing) {
nextScene.onEnterEditMode();
}
// Set dirty based on the saved baseline (if present). This prevents the save button from being
// stuck "blue" when the applied spec matches the baseline.
const shouldBeDirty = hasBaseline ? !isEqual(nextSpec, initialSaveModel) : true;
nextScene.setState({ isDirty: shouldBeDirty });
// 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, unknown, '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);
}

View File

@@ -0,0 +1,157 @@
import { config, locationService } from '@grafana/runtime';
import { CustomVariable, SceneTimeRange, TextBoxVariable, sceneGraph } from '@grafana/scenes';
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(() => []),
},
}));
jest.mock('../utils/utils', () => ({
getPanelIdForVizPanel: jest.fn(),
getQueryRunnerFor: jest.fn(),
}));
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
describe('dashboardSceneJsonApiV2 (navigation/variables/time)', () => {
beforeEach(() => {
jest.restoreAllMocks();
// Enable v2 API gate for tests.
config.featureToggles.kubernetesDashboards = true;
config.featureToggles.kubernetesDashboardsV2 = true;
jest.spyOn(locationService, 'partial').mockImplementation(() => {});
});
it('getCurrentDashboardVariables returns a stable JSON shape and applyCurrentDashboardVariables updates values', () => {
const customVar = new CustomVariable({ name: 'customVar', query: 'a,b,c', value: 'a', text: 'a' });
const textVar = new TextBoxVariable({ type: 'textbox', name: 'tb', value: 'x' });
const dashboard = {
state: {
$variables: { state: { variables: [customVar, textVar] } },
},
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const before = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardVariables(0));
expect(before.variables).toEqual(
expect.arrayContaining([
{ name: 'customVar', value: 'a' },
{ name: 'tb', value: 'x' },
])
);
dashboardSceneJsonApiV2.applyCurrentDashboardVariables(JSON.stringify({ customVar: ['b', 'c'], tb: 'y' }));
expect(customVar.getValue()).toEqual(['b', 'c']);
expect(textVar.getValue()).toBe('y');
expect(locationService.partial).toHaveBeenCalledWith({ 'var-customVar': ['b', 'c'] }, true);
expect(locationService.partial).toHaveBeenCalledWith({ 'var-tb': 'y' }, true);
});
it('getCurrentDashboardTimeRange returns raw values and applyCurrentDashboardTimeRange calls SceneTimeRange APIs', () => {
const tr = new SceneTimeRange({ from: 'now-1h', to: 'now', timeZone: 'browser' });
const tzSpy = jest.spyOn(tr, 'onTimeZoneChange');
const trSpy = jest.spyOn(tr, 'onTimeRangeChange');
const refreshSpy = jest.spyOn(tr, 'onRefresh');
jest.spyOn(sceneGraph, 'getTimeRange').mockReturnValue(tr);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const current = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardTimeRange(0));
expect(current).toEqual({ from: 'now-1h', to: 'now', timezone: 'browser' });
dashboardSceneJsonApiV2.applyCurrentDashboardTimeRange(JSON.stringify({ from: 'now-6h', to: 'now', timezone: 'utc' }));
expect(tzSpy).toHaveBeenCalledWith('utc');
expect(trSpy).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('selectCurrentDashboardTab selects a matching tab by title', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'A' });
const tabB = new TabItem({ key: 'tab-b', title: 'B' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB] });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const spy = jest.spyOn(tabs, 'switchToTab');
dashboardSceneJsonApiV2.selectCurrentDashboardTab(JSON.stringify({ title: 'B' }));
expect(spy).toHaveBeenCalledWith(tabB);
});
it('getCurrentDashboardNavigation returns the active tab', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'Overview' });
const tabB = new TabItem({ key: 'tab-b', title: 'Explore' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB], currentTabSlug: tabB.getSlug() });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const nav = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardNavigation(0));
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(() => {});
jest.spyOn(sceneGraph, 'findAllObjects').mockReturnValue([row]);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
dashboardSceneJsonApiV2.focusCurrentDashboardRow(JSON.stringify({ title: 'request duration' }));
expect(row.getCollapsedState()).toBe(false);
expect(scrollSpy).toHaveBeenCalled();
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -1,4 +1,5 @@
import { PanelData, RawTimeRange } from '@grafana/data';
import { getDashboardApi } from '@grafana/runtime';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -11,6 +12,42 @@ 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?: {
help: () => string;
schema: {
getSources: (space?: number) => string;
getDashboard: (space?: number) => Promise<string>;
getDashboardSync: (space?: number) => string;
};
navigation: {
getCurrent: (space?: number) => string;
getSelection: (space?: number) => string;
selectTab: (tabJson: string) => void;
focusRow: (rowJson: string) => void;
focusPanel: (panelJson: string) => void;
};
variables: {
getCurrent: (space?: number) => string;
apply: (varsJson: string) => void;
};
timeRange: {
getCurrent: (space?: number) => string;
apply: (timeRangeJson: string) => void;
};
errors: {
getCurrent: (space?: number) => string;
};
dashboard: {
getCurrent: (space?: number) => string;
apply: (resourceJson: string) => void;
previewOps: (opsJson: string) => string;
applyOps: (opsJson: string) => string;
};
};
}
}
@@ -54,4 +91,8 @@ export function initWindowRuntime() {
}, {});
},
};
// Expose the same API that plugins use via @grafana/runtime, but on `window` for easy console access.
// Only the grouped API surface is exposed.
window.dashboardApi = getDashboardApi();
}