add schema

This commit is contained in:
oscarkilhed
2025-12-20 17:24:07 +01:00
parent 7fec275695
commit e796825e63
2 changed files with 205 additions and 3 deletions
@@ -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<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.
@@ -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<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',
@@ -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() {
},
};
}
+5
View File
@@ -18,6 +18,11 @@ declare global {
*/
dashboardApi?: {
help: () => string;
schema: {
getSources: (space?: number) => string;
getDashboard: (space?: number) => Promise<string>;
getDashboardSync: (space?: number) => string;
};
navigation: {
getCurrent: (space?: number) => string;
selectTab: (tabJson: string) => void;