From e796825e633af81b0fec0ab3e33187d66dcbf05d Mon Sep 17 00:00:00 2001 From: oscarkilhed Date: Sat, 20 Dec 2025 17:24:07 +0100 Subject: [PATCH] add schema --- .../src/services/dashboardSceneJsonApi.ts | 203 +++++++++++++++++- public/app/features/runtime/init.ts | 5 + 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts index d1049e27d4e..b0551abb7f7 100644 --- a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts +++ b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts @@ -123,6 +123,151 @@ export function getDashboardSceneJsonApiV2(): DashboardSceneJsonApiV2 { */ export function getDashboardApi() { const api = getDashboardSceneJsonApiV2(); + let cachedDashboardSchemaBundle: { bundle: unknown; loadedAt: number } | undefined; + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + function collectRefs(value: unknown, out: Set) { + 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 = {}; + const visited = new Set(); + 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(); + 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. @@ -134,6 +279,13 @@ export function getDashboardApi() { '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', + '- 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', @@ -167,6 +319,7 @@ export function getDashboardApi() { ' - 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 }))', @@ -183,12 +336,58 @@ export function getDashboardApi() { 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), }, errors: { - getCurrent: (space = 2) => JSON.stringify({ errors: JSON.parse(api.getCurrentDashboardErrors(space)) }, null, space), + getCurrent: (space = 2) => + JSON.stringify({ errors: JSON.parse(api.getCurrentDashboardErrors(space)) }, null, space), }, variables: { getCurrent: (space = 2) => api.getCurrentDashboardVariables(space), @@ -206,5 +405,3 @@ export function getDashboardApi() { }, }; } - - diff --git a/public/app/features/runtime/init.ts b/public/app/features/runtime/init.ts index b3fad1ac222..e6c2ea44279 100644 --- a/public/app/features/runtime/init.ts +++ b/public/app/features/runtime/init.ts @@ -18,6 +18,11 @@ declare global { */ dashboardApi?: { help: () => string; + schema: { + getSources: (space?: number) => string; + getDashboard: (space?: number) => Promise; + getDashboardSync: (space?: number) => string; + }; navigation: { getCurrent: (space?: number) => string; selectTab: (tabJson: string) => void;