get dashboard errors

This commit is contained in:
oscarkilhed
2025-12-20 15:09:57 +01:00
parent 1a7c2a4f38
commit 090078eb80
5 changed files with 217 additions and 0 deletions
@@ -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).
*
@@ -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 { 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<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;
}
@@ -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;
+3
View File
@@ -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,
};
}