Implemented TypeScript API client for calling the dashboard validator backend from the Grafana frontend.

This commit is contained in:
alexandra vargas
2026-01-06 14:09:29 +01:00
parent 01f959be97
commit 4f66b1df5a
3 changed files with 492 additions and 0 deletions
@@ -0,0 +1,350 @@
import { BackendSrv, getBackendSrv } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types';
import { checkDashboardCompatibility, CompatibilityCheckResult, DatasourceMapping } from './compatibilityApi';
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
getBackendSrv: jest.fn(),
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
user: { orgId: 1 },
},
}));
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
// Helper to create mock BackendSrv
const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =>
({
post: jest.fn(),
...overrides,
}) as unknown as BackendSrv;
// Test fixtures
const createMockDashboard = (overrides: Partial<Dashboard> = {}): Dashboard => ({
title: 'Test Dashboard',
uid: 'test-uid',
schemaVersion: 39,
version: 1,
panels: [
{
id: 1,
type: 'graph',
title: 'CPU Usage',
datasource: {
type: 'prometheus',
uid: 'prometheus-uid-123',
},
targets: [
{
refId: 'A',
expr: 'rate(cpu_usage_total[5m])',
},
],
},
{
id: 2,
type: 'graph',
title: 'Memory Usage',
datasource: {
type: 'prometheus',
uid: 'prometheus-uid-123',
},
targets: [
{
refId: 'A',
expr: 'memory_usage_bytes',
},
],
},
],
...overrides,
});
const createMockDatasourceMappings = (): DatasourceMapping[] => [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
},
];
describe('compatibilityApi', () => {
let mockPost: jest.MockedFunction<BackendSrv['post']>;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
mockPost = jest.fn();
mockGetBackendSrv.mockReturnValue(
createMockBackendSrv({
post: mockPost,
})
);
// Mock console.error to prevent test failures
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
consoleErrorSpy.mockRestore();
});
describe('checkDashboardCompatibility', () => {
it('should successfully check compatibility with high score (100%)', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
totalQueries: 2,
checkedQueries: 2,
totalMetrics: 2,
foundMetrics: 2,
missingMetrics: [],
compatibilityScore: 100,
queryBreakdown: [
{
panelTitle: 'CPU Usage',
panelID: 1,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
{
panelTitle: 'Memory Usage',
panelID: 2,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
],
},
],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
const result = await checkDashboardCompatibility(dashboard, mappings);
expect(result).toEqual(mockResponse);
expect(mockPost).toHaveBeenCalledWith(
'/api/apps/dashvalidator/v1alpha1/org-1/check',
{
dashboardJson: dashboard,
datasourceMappings: mappings,
},
{
showErrorAlert: false,
}
);
});
it('should successfully check compatibility with partial score (50%)', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 50,
datasourceResults: [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
totalQueries: 2,
checkedQueries: 2,
totalMetrics: 2,
foundMetrics: 1,
missingMetrics: ['http_request_duration_seconds'],
compatibilityScore: 50,
queryBreakdown: [
{
panelTitle: 'CPU Usage',
panelID: 1,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
{
panelTitle: 'Memory Usage',
panelID: 2,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 0,
missingMetrics: ['http_request_duration_seconds'],
compatibilityScore: 0,
},
],
},
],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
const result = await checkDashboardCompatibility(dashboard, mappings);
expect(result).toEqual(mockResponse);
expect(result.compatibilityScore).toBe(50);
expect(result.datasourceResults[0].missingMetrics).toContain('http_request_duration_seconds');
});
it('should handle HTTP 404 error (datasource not found)', async () => {
const error404 = {
status: 404,
data: {
message: 'Datasource not found',
code: 'datasource_not_found',
},
};
mockPost.mockRejectedValue(error404);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
// Should re-throw original error from getBackendSrv
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error404);
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith('Dashboard compatibility check failed:', error404);
});
it('should handle HTTP 401 error (authentication failure)', async () => {
const error401 = {
status: 401,
data: {
message: 'Authentication failed for datasource',
code: 'datasource_auth_failed',
},
};
mockPost.mockRejectedValue(error401);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error401);
});
it('should handle HTTP 503 error (datasource unreachable)', async () => {
const error503 = {
status: 503,
data: {
message: 'Datasource is unreachable',
code: 'datasource_unreachable',
},
};
mockPost.mockRejectedValue(error503);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error503);
});
it('should handle HTTP 502 error (invalid Prometheus API response)', async () => {
const error502 = {
status: 502,
data: {
message: 'Invalid response from Prometheus API',
code: 'api_invalid_response',
},
};
mockPost.mockRejectedValue(error502);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error502);
});
it('should handle network error without structured error data', async () => {
const networkError = {
message: 'Network request failed',
};
mockPost.mockRejectedValue(networkError);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(networkError);
});
it('should construct correct namespace for different orgIds', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [],
};
mockPost.mockResolvedValue(mockResponse);
// Change orgId via contextSrv mock
const { contextSrv } = require('app/core/services/context_srv');
contextSrv.user.orgId = 42;
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await checkDashboardCompatibility(dashboard, mappings);
expect(mockPost).toHaveBeenCalledWith(
'/api/apps/dashvalidator/v1alpha1/org-42/check',
expect.any(Object),
expect.any(Object)
);
// Reset orgId for other tests
contextSrv.user.orgId = 1;
});
it('should handle generic error without proper structure', async () => {
const genericError = 'Something went wrong';
mockPost.mockRejectedValue(genericError);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(genericError);
});
it('should disable automatic error alerts', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await checkDashboardCompatibility(dashboard, mappings);
// Verify that showErrorAlert is explicitly set to false
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
showErrorAlert: false,
})
);
});
});
});
@@ -0,0 +1,142 @@
import { getBackendSrv } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types';
import { contextSrv } from 'app/core/services/context_srv';
/**
* Represents a datasource mapping for compatibility checking.
* Maps dashboard datasource references to actual datasource instances.
*/
export interface DatasourceMapping {
/** Unique identifier of the datasource */
uid: string;
/** Type of datasource (e.g., 'prometheus', 'loki') */
type: string;
/** Optional human-readable name for display */
name?: string;
}
/**
* Request body for dashboard compatibility check API call
*/
export interface CheckCompatibilityRequest {
/** Complete dashboard JSON object (v1 schema with panels array) */
dashboardJson: Dashboard;
/** Array of datasource mappings to check compatibility against */
datasourceMappings: DatasourceMapping[];
}
/**
* Breakdown of compatibility metrics for a single query within a panel
*/
export interface QueryBreakdown {
/** Title of the panel containing this query */
panelTitle: string;
/** Numeric ID of the panel */
panelID: number;
/** Query reference ID (e.g., 'A', 'B', 'C') */
queryRefId: string;
/** Total number of metrics extracted from this query */
totalMetrics: number;
/** Number of metrics found in the datasource */
foundMetrics: number;
/** List of metric names that were not found */
missingMetrics: string[];
/** Compatibility score for this query (0-100) */
compatibilityScore: number;
}
/**
* Compatibility check result for a single datasource
*/
export interface DatasourceResult {
/** Unique identifier of the datasource */
uid: string;
/** Type of datasource */
type: string;
/** Optional human-readable name */
name?: string;
/** Total number of queries in the dashboard */
totalQueries: number;
/** Number of queries that were checked */
checkedQueries: number;
/** Total number of unique metrics extracted from all queries */
totalMetrics: number;
/** Number of metrics found in the datasource */
foundMetrics: number;
/** List of all missing metric names across all queries */
missingMetrics: string[];
/** Overall compatibility score for this datasource (0-100) */
compatibilityScore: number;
/** Detailed breakdown of compatibility per query */
queryBreakdown: QueryBreakdown[];
}
/**
* Overall compatibility check result
*/
export interface CompatibilityCheckResult {
/** Overall compatibility score across all datasources (0-100) */
compatibilityScore: number;
/** Results for each datasource checked */
datasourceResults: DatasourceResult[];
}
/**
* Checks dashboard compatibility with specified datasources.
*
* This function sends the dashboard JSON and datasource mappings to the backend
* validation service, which extracts metrics from dashboard queries and checks
* if those metrics exist in the target datasource(s).
*
* @param dashboardJson Complete dashboard JSON object (must be v1 schema)
* @param datasourceMappings Array of datasource mappings to validate against
* @returns Promise resolving to compatibility check results
* @throws CompatibilityCheckError if the API call fails
*
* @example
* ```typescript
* const result = await checkDashboardCompatibility(
* { panels: [...], title: "My Dashboard" },
* [{ uid: "prometheus-uid", type: "prometheus" }]
* );
*
* console.log(`Compatibility: ${result.compatibilityScore}%`);
* console.log(`Missing metrics: ${result.datasourceResults[0].missingMetrics}`);
* ```
*/
export async function checkDashboardCompatibility(
dashboardJson: Dashboard,
datasourceMappings: DatasourceMapping[]
): Promise<CompatibilityCheckResult> {
// Get current organization ID from user context
const orgId = contextSrv.user.orgId;
// Construct namespace in the format expected by the backend: org-{orgID}
const namespace = `org-${orgId}`;
// Build request body matching backend schema
const requestBody: CheckCompatibilityRequest = {
dashboardJson,
datasourceMappings,
};
try {
// Make POST request to the dashboard validator app's /check endpoint
const response = await getBackendSrv().post<CompatibilityCheckResult>(
`/api/apps/dashvalidator/v1alpha1/${namespace}/check`,
requestBody,
{
// Disable automatic error alerts - we'll handle errors in the UI
showErrorAlert: false,
}
);
return response;
} catch (error) {
// Log error for debugging
console.error('Dashboard compatibility check failed:', error);
// Re-throw original error for caller to handle
throw error;
}
}
View File