From 090078eb8084dd10cebc20ab6dd9aec231556034 Mon Sep 17 00:00:00 2001 From: oscarkilhed Date: Sat, 20 Dec 2025 15:09:57 +0100 Subject: [PATCH] get dashboard errors --- .../src/services/dashboardSceneJsonApi.ts | 17 +++ .../api/currentDashboardErrors.test.ts | 73 +++++++++++ .../api/currentDashboardErrors.ts | 119 ++++++++++++++++++ .../api/runtimeDashboardSceneJsonApiV2.ts | 5 + public/app/features/runtime/init.ts | 3 + 5 files changed, 217 insertions(+) create mode 100644 public/app/features/dashboard-scene/api/currentDashboardErrors.test.ts create mode 100644 public/app/features/dashboard-scene/api/currentDashboardErrors.ts diff --git a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts index b416b88497c..805e3987d9d 100644 --- a/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts +++ b/packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts @@ -4,6 +4,14 @@ export interface DashboardSceneJsonApiV2 { */ 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; + /** * Apply a v2beta1 Dashboard kind JSON (JSON string). * @@ -45,6 +53,15 @@ export function getCurrentDashboard(space = 2): string { return getDashboardSceneJsonApiV2().getCurrentDashboard(space); } +/** + * Plugin-friendly helper to read aggregated query errors for the current dashboard (JSON string). + * + * @public + */ +export function getCurrentDashboardErrors(space = 2): string { + return getDashboardSceneJsonApiV2().getCurrentDashboardErrors(space); +} + /** * JSON-string helper to apply a v2 Dashboard kind JSON (spec-only enforcement happens in the implementation). * diff --git a/public/app/features/dashboard-scene/api/currentDashboardErrors.test.ts b/public/app/features/dashboard-scene/api/currentDashboardErrors.test.ts new file mode 100644 index 00000000000..72ac38366b7 --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardErrors.test.ts @@ -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', + }, + ]); + }); +}); + + diff --git a/public/app/features/dashboard-scene/api/currentDashboardErrors.ts b/public/app/features/dashboard-scene/api/currentDashboardErrors.ts new file mode 100644 index 00000000000..6eb4923a932 --- /dev/null +++ b/public/app/features/dashboard-scene/api/currentDashboardErrors.ts @@ -0,0 +1,119 @@ +import { DataQueryError, DataQueryErrorType } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import type { SceneDataQuery, SceneQueryRunner, VizPanel } from '@grafana/scenes'; + +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; + +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; + 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; +} + + diff --git a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts index 6f6d6523428..3454505b05f 100644 --- a/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts +++ b/public/app/features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2.ts @@ -2,6 +2,7 @@ import { isEqual } from 'lodash'; import type { DashboardSceneJsonApiV2 } from '@grafana/runtime'; +import { getCurrentDashboardErrors } from './currentDashboardErrors'; import { getCurrentDashboardKindV2 as getCurrentDashboardResourceV2 } from './currentDashboardKindV2'; import { applyCurrentDashboardSpecV2 } from './currentDashboardSpecApplyV2'; @@ -53,6 +54,10 @@ export const dashboardSceneJsonApiV2: DashboardSceneJsonApiV2 = { return JSON.stringify(resource, null, space); }, + getCurrentDashboardErrors: (space = 2) => { + return JSON.stringify(getCurrentDashboardErrors(), null, space); + }, + applyCurrentDashboard: (resourceJson: string) => { const resource = JSON.parse(resourceJson); let current: DashboardResourceV2 | undefined; diff --git a/public/app/features/runtime/init.ts b/public/app/features/runtime/init.ts index 00def9924d4..e881343151e 100644 --- a/public/app/features/runtime/init.ts +++ b/public/app/features/runtime/init.ts @@ -2,6 +2,7 @@ import { PanelData, RawTimeRange } from '@grafana/data'; import { applyCurrentDashboard, getCurrentDashboard, + getCurrentDashboardErrors, } from '@grafana/runtime'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -21,6 +22,7 @@ declare global { */ dashboardApi?: { getCurrentDashboard: (space?: number) => string; + getCurrentDashboardErrors: (space?: number) => string; applyCurrentDashboard: (resourceJson: string) => void; }; } @@ -70,6 +72,7 @@ export function initWindowRuntime() { // Expose the same API that plugins use via @grafana/runtime, but on `window` for easy console access. window.dashboardApi = { getCurrentDashboard, + getCurrentDashboardErrors, applyCurrentDashboard, }; }