Implemented TypeScript API client for calling the dashboard validator backend from the Grafana frontend.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user