From 92041e5a05eff6913f948edb13b613fb98b9837f Mon Sep 17 00:00:00 2001 From: alexandra vargas Date: Tue, 6 Jan 2026 15:10:40 +0100 Subject: [PATCH] Create compatibility modal for mvp --- .../CompatibilityModal.test.tsx | 330 ++++++++++++++++++ .../DashboardLibrary/CompatibilityModal.tsx | 186 ++++++++++ .../api/compatibilityApi.test.ts | 85 +++-- .../DashboardLibrary/api/compatibilityApi.ts | 15 +- 4 files changed, 573 insertions(+), 43 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.test.tsx new file mode 100644 index 00000000000..a7554ead216 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.test.tsx @@ -0,0 +1,330 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ComponentProps } from 'react'; + +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; + +import { CompatibilityModal } from './CompatibilityModal'; +import { checkDashboardCompatibility, CompatibilityCheckResult } from './api/compatibilityApi'; + +// Mock dependencies +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: jest.fn(), +})); + +jest.mock('./api/compatibilityApi', () => ({ + checkDashboardCompatibility: jest.fn(), +})); + +const mockGetDataSourceSrv = getDataSourceSrv as jest.MockedFunction; +const mockCheckDashboardCompatibility = checkDashboardCompatibility as jest.MockedFunction< + typeof checkDashboardCompatibility +>; + +// Suppress console.error for expected errors in tests +const originalError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalError; +}); + +// Prometheus-specific query type (extends DataQuery) +interface PrometheusQuery extends DataQuery { + expr: string; +} + +// Test fixtures +const createMockDashboard = (overrides: Partial = {}): DashboardJson => { + // Create a minimal dashboard for testing purposes + // Panels array is intentionally minimal - only includes fields needed for compatibility check + const dashboard: DashboardJson = { + 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])', + } as PrometheusQuery, + ], + }, + ] as unknown as DashboardJson['panels'], + ...overrides, + }; + return dashboard; +}; + +const createMockCompatibilityResult = (score = 100): CompatibilityCheckResult => ({ + compatibilityScore: score, + datasourceResults: [ + { + uid: 'prometheus-uid', + type: 'prometheus', + name: 'Test Prometheus', + totalQueries: 5, + checkedQueries: 5, + totalMetrics: 10, + foundMetrics: score === 100 ? 10 : Math.floor(10 * (score / 100)), + missingMetrics: score === 100 ? [] : ['missing_metric_1', 'missing_metric_2'], + compatibilityScore: score, + queryBreakdown: [], + }, + ], +}); + +const defaultProps: ComponentProps = { + isOpen: true, + onDismiss: jest.fn(), + dashboardJson: createMockDashboard(), + datasourceUid: 'prometheus-uid', +}; + +describe('CompatibilityModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock: datasource found + mockGetDataSourceSrv.mockReturnValue({ + getInstanceSettings: jest.fn().mockReturnValue({ + uid: 'prometheus-uid', + type: 'prometheus', + name: 'Test Prometheus', + }), + } as unknown as ReturnType); + + // Default mock: successful API call + mockCheckDashboardCompatibility.mockResolvedValue(createMockCompatibilityResult(100)); + }); + + describe('Modal visibility', () => { + it('should render modal when isOpen is true', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboard Compatibility Check for Test Dashboard')).toBeInTheDocument(); + }); + }); + + it('should not render modal content when isOpen is false', async () => { + render(); + + // Wait for any async updates to settle + await waitFor(() => { + expect(screen.queryByText('Dashboard Compatibility Check for Test Dashboard')).not.toBeInTheDocument(); + }); + }); + + it('should include dashboard title in modal title', async () => { + const dashboardWithCustomTitle = createMockDashboard({ title: 'My Custom Dashboard' }); + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboard Compatibility Check for My Custom Dashboard')).toBeInTheDocument(); + }); + }); + }); + + describe('Loading state', () => { + it('should show loading spinner and message while checking compatibility', async () => { + // Make API call pending + mockCheckDashboardCompatibility.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(createMockCompatibilityResult(100)), 1000)) + ); + + render(); + + // Should show loading state immediately + expect(screen.getByText('Checking compatibility...')).toBeInTheDocument(); + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); + }); + }); + + describe('Error state', () => { + it('should show error alert when dashboard is v2 schema', async () => { + // Create a v2 dashboard (has 'elements' property instead of 'panels') + const v2Dashboard = { elements: {}, schemaVersion: 40 } as unknown as DashboardJson; + + render(); + + await waitFor(() => { + expect(screen.getByText('Error checking compatibility')).toBeInTheDocument(); + expect(screen.getByText('Failed to check dashboard compatibility. Please try again.')).toBeInTheDocument(); + }); + + // API should not be called for v2 dashboards + expect(mockCheckDashboardCompatibility).not.toHaveBeenCalled(); + }); + + it('should show error alert when datasource is not found', async () => { + mockGetDataSourceSrv.mockReturnValue({ + getInstanceSettings: jest.fn().mockReturnValue(null), + } as unknown as ReturnType); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error checking compatibility')).toBeInTheDocument(); + expect(screen.getByText('Failed to check dashboard compatibility. Please try again.')).toBeInTheDocument(); + }); + }); + + it('should show error alert when API call fails', async () => { + mockCheckDashboardCompatibility.mockRejectedValue(new Error('API Error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error checking compatibility')).toBeInTheDocument(); + expect(screen.getByText('Failed to check dashboard compatibility. Please try again.')).toBeInTheDocument(); + }); + }); + + it('should show retry button in error state', async () => { + mockCheckDashboardCompatibility.mockRejectedValue(new Error('API Error')); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it('should retry API call when retry button is clicked', async () => { + const user = userEvent.setup(); + + // First call fails + mockCheckDashboardCompatibility.mockRejectedValueOnce(new Error('API Error')); + // Second call succeeds + mockCheckDashboardCompatibility.mockResolvedValueOnce(createMockCompatibilityResult(100)); + + render(); + + // Wait for error state + await waitFor(() => { + expect(screen.getByText('Error checking compatibility')).toBeInTheDocument(); + }); + + // Click retry button + const retryButton = screen.getByRole('button', { name: /retry/i }); + await user.click(retryButton); + + // Should eventually show success state (loading may be too fast to catch) + await waitFor(() => { + expect(screen.getByText('Compatibility Score')).toBeInTheDocument(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + // API should have been called twice + expect(mockCheckDashboardCompatibility).toHaveBeenCalledTimes(2); + }); + }); + + describe('Success state', () => { + it('should show compatibility score when check succeeds', async () => { + mockCheckDashboardCompatibility.mockResolvedValue(createMockCompatibilityResult(100)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Compatibility Score')).toBeInTheDocument(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + }); + + it('should display partial compatibility score', async () => { + mockCheckDashboardCompatibility.mockResolvedValue(createMockCompatibilityResult(75)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Compatibility Score')).toBeInTheDocument(); + expect(screen.getByText('75%')).toBeInTheDocument(); + }); + }); + + it('should display low compatibility score', async () => { + mockCheckDashboardCompatibility.mockResolvedValue(createMockCompatibilityResult(25)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Compatibility Score')).toBeInTheDocument(); + expect(screen.getByText('25%')).toBeInTheDocument(); + }); + }); + }); + + describe('API call behavior', () => { + it('should call checkDashboardCompatibility with correct parameters', async () => { + const dashboardJson = createMockDashboard(); + render(); + + await waitFor(() => { + expect(mockCheckDashboardCompatibility).toHaveBeenCalledWith(dashboardJson, [ + { + uid: 'prometheus-uid', + type: 'prometheus', + name: 'Test Prometheus', + }, + ]); + }); + }); + + it('should not call API when modal is closed', async () => { + render(); + + // Wait for any async updates to settle + await waitFor(() => { + expect(mockCheckDashboardCompatibility).not.toHaveBeenCalled(); + }); + }); + + it('should trigger API call when modal opens', async () => { + const { rerender } = render(); + + // API should not be called yet + expect(mockCheckDashboardCompatibility).not.toHaveBeenCalled(); + + // Open modal + rerender(); + + // API should now be called + await waitFor(() => { + expect(mockCheckDashboardCompatibility).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Modal interactions', () => { + it('should call onDismiss when modal is closed', async () => { + const onDismiss = jest.fn(); + render(); + + // Wait for modal to render + await waitFor(() => { + expect(screen.getByText('Dashboard Compatibility Check for Test Dashboard')).toBeInTheDocument(); + }); + + // Find and click close button (X button in modal header) + const closeButton = screen.getByLabelText('Close'); + await userEvent.click(closeButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx new file mode 100644 index 00000000000..3a725045787 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx @@ -0,0 +1,186 @@ +import { css } from '@emotion/css'; +import { useAsyncRetry } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Modal, useStyles2, Stack, Alert, Button, Spinner, Text } from '@grafana/ui'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; + +import { checkDashboardCompatibility } from './api/compatibilityApi'; + +interface CompatibilityModalProps { + /** Controls modal visibility */ + isOpen: boolean; + /** Handler called when modal is dismissed */ + onDismiss: () => void; + /** Dashboard JSON to check (v1 or v2 schema) */ + dashboardJson: DashboardJson; + /** UID of the datasource to check compatibility against */ + datasourceUid: string; +} + +/** + * Modal component that checks dashboard compatibility with a datasource. + * + * This modal is self-contained and handles its own data fetching. When opened, + * it automatically triggers a compatibility check by calling the dashboard validator + * backend API. It displays loading, error, or success states accordingly. + * + * This component is generic and works with any dashboard source: + * - Community dashboards (GnetDashboard) + * - Plugin-provided dashboards (PluginDashboard) + * - User-created dashboards + * + * Features #12-15 will add detailed result displays (color-coded scores, missing + * metrics lists, and query breakdowns). + */ +export const CompatibilityModal = ({ isOpen, onDismiss, dashboardJson, datasourceUid }: CompatibilityModalProps) => { + const styles = useStyles2(getStyles); + + // Fetch compatibility results when modal opens + const { + value: result, + loading, + error, + retry, + } = useAsyncRetry(async () => { + // Don't trigger API call if modal is closed + if (!isOpen) { + return null; + } + + // Validate dashboard is v1 schema (reject v2 for MVP) + // isDashboardV2Spec checks for 'elements' property at runtime + if ('elements' in dashboardJson) { + throw new Error( + t( + 'compatibility-modal.v2-not-supported', + 'Dashboard v2 schema is not yet supported. Compatibility checking is currently only available for v1 dashboards. Support for v2 dashboards is coming soon.' + ) + ); + } + + // Fetch datasource details to build mapping + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + throw new Error( + t('compatibility-modal.datasource-not-found', 'Datasource not found with UID: {{uid}}', { + uid: datasourceUid, + }) + ); + } + + // Call compatibility check API with validated dashboard + return await checkDashboardCompatibility(dashboardJson, [ + { + uid: ds.uid, + type: ds.type, + name: ds.name, + }, + ]); + }, [isOpen, dashboardJson, datasourceUid]); + + return ( + +
+ {/* Loading State */} + {loading && ( + + + + Checking compatibility... + + + )} + + {/* Error State */} + {!loading && error && ( + + + + Failed to check dashboard compatibility. Please try again. + + + + + )} + + {/* Success State - Placeholder for Features #12-15 */} + {!loading && !error && result && ( + +
+ + Compatibility Score + + + {result.compatibilityScore}% + +
+ + {/* Feature #12: CompatibilityScoreDisplay with color coding */} + {/* - Large score display with color coding (green >=80%, yellow 50-79%, red <50%) */} + {/* - Icon based on score range (check-circle, warning, exclamation-circle) */} + {/* - Descriptive text: 'Highly Compatible', 'Partially Compatible', 'Low Compatibility' */} + + {/* Feature #13: DatasourceResultSection component */} + {/* - Display datasource name and type */} + {/* - Show total queries vs checked queries count */} + {/* - Show total metrics vs found metrics count */} + {/* - Display number of missing metrics */} + + {/* Feature #14: MissingMetricsList component */} + {/* - Collapsible/expandable section with missing metrics */} + {/* - Show count of missing metrics in header */} + {/* - Display bullet list of missing metric names when expanded */} + {/* - Add copy-to-clipboard button for metric names */} + {/* - Show 'All metrics found!' message when missingMetrics array is empty */} + + {/* Feature #15: QueryBreakdownTable component */} + {/* - Collapsible section with 'Show panel breakdown' toggle */} + {/* - Table with columns: Panel Title, Panel ID, Query Ref, Metrics Found/Total, Compatibility % */} + {/* - Color-code compatibility percentage in table cells */} + {/* - Add sorting capability by compatibility score */} + {/* - Show expandable row details with missing metrics list per query */} +
+ )} +
+
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + modal: css({ + width: '90%', + maxWidth: '1200px', + height: '80vh', + display: 'flex', + flexDirection: 'column', + }), + modalContent: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + padding: theme.spacing(3), + height: '100%', + }), + contentContainer: css({ + flex: 1, + overflow: 'auto', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts index 9525f929f4c..fbfbc1d44ec 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.test.ts @@ -1,5 +1,6 @@ import { BackendSrv, getBackendSrv } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types'; +import { DataQuery } from '@grafana/schema'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; import { checkDashboardCompatibility, CompatibilityCheckResult, DatasourceMapping } from './compatibilityApi'; @@ -23,46 +24,56 @@ const createMockBackendSrv = (overrides: Partial = {}): BackendSrv = ...overrides, }) as unknown as BackendSrv; +// Prometheus-specific query type (extends DataQuery) +interface PrometheusQuery extends DataQuery { + expr: string; +} + // 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])', +const createMockDashboard = (overrides: Partial = {}): DashboardJson => { + // Create a minimal dashboard for testing purposes + // Panels array is intentionally minimal - only includes fields needed for compatibility check + const dashboard: DashboardJson = { + 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', }, - ], - }, - { - id: 2, - type: 'graph', - title: 'Memory Usage', - datasource: { - type: 'prometheus', - uid: 'prometheus-uid-123', + targets: [ + { + refId: 'A', + expr: 'rate(cpu_usage_total[5m])', + } as PrometheusQuery, + ], }, - targets: [ - { - refId: 'A', - expr: 'memory_usage_bytes', + { + id: 2, + type: 'graph', + title: 'Memory Usage', + datasource: { + type: 'prometheus', + uid: 'prometheus-uid-123', }, - ], - }, - ], - ...overrides, -}); + targets: [ + { + refId: 'A', + expr: 'memory_usage_bytes', + } as PrometheusQuery, + ], + }, + ] as unknown as DashboardJson['panels'], + ...overrides, + }; + return dashboard; +}; const createMockDatasourceMappings = (): DatasourceMapping[] => [ { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts index bcab3c30d93..b8cc51e3665 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/compatibilityApi.ts @@ -1,6 +1,6 @@ import { getBackendSrv } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types'; import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; /** * Represents a datasource mapping for compatibility checking. @@ -19,8 +19,8 @@ export interface DatasourceMapping { * Request body for dashboard compatibility check API call */ export interface CheckCompatibilityRequest { - /** Complete dashboard JSON object (v1 schema with panels array) */ - dashboardJson: Dashboard; + /** Complete dashboard JSON object (supports both v1 and v2 schemas) */ + dashboardJson: DashboardJson; /** Array of datasource mappings to check compatibility against */ datasourceMappings: DatasourceMapping[]; } @@ -88,10 +88,13 @@ export interface CompatibilityCheckResult { * 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) + * Note: The backend currently only supports v1 dashboards (with panels array). + * V2 dashboards (with elements) will be rejected by the backend with an appropriate error. + * + * @param dashboardJson Complete dashboard JSON object (v1 or v2 schema) * @param datasourceMappings Array of datasource mappings to validate against * @returns Promise resolving to compatibility check results - * @throws CompatibilityCheckError if the API call fails + * @throws Error if the API call fails or dashboard schema is unsupported * * @example * ```typescript @@ -105,7 +108,7 @@ export interface CompatibilityCheckResult { * ``` */ export async function checkDashboardCompatibility( - dashboardJson: Dashboard, + dashboardJson: DashboardJson, datasourceMappings: DatasourceMapping[] ): Promise { // Get current organization ID from user context