From 4f66b1df5a55bd413c5e560c723ffcca2309ecd1 Mon Sep 17 00:00:00 2001 From: alexandra vargas Date: Tue, 6 Jan 2026 14:09:29 +0100 Subject: [PATCH] Implemented TypeScript API client for calling the dashboard validator backend from the Grafana frontend. --- .../api/compatibilityApi.test.ts | 350 ++++++++++++++++++ .../DashboardLibrary/api/compatibilityApi.ts | 142 +++++++ t | 0 3 files changed, 492 insertions(+) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts create mode 100644 t diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts new file mode 100644 index 00000000000..9525f929f4c --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts @@ -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; + +// Helper to create mock BackendSrv +const createMockBackendSrv = (overrides: Partial = {}): BackendSrv => + ({ + post: jest.fn(), + ...overrides, + }) as unknown as BackendSrv; + +// Test fixtures +const createMockDashboard = (overrides: Partial = {}): 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; + 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, + }) + ); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts new file mode 100644 index 00000000000..bcab3c30d93 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts @@ -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 { + // 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( + `/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; + } +} diff --git a/t b/t new file mode 100644 index 00000000000..e69de29bb2d