mirror of
https://github.com/grafana/grafana.git
synced 2025-12-24 05:44:14 +08:00
Compare commits
6 Commits
update-ale
...
oscark/poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1e8bb94e | ||
|
|
93551386cc | ||
|
|
e796825e63 | ||
|
|
7fec275695 | ||
|
|
090078eb80 | ||
|
|
1a7c2a4f38 |
460
packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts
Normal file
460
packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user