Create compatibility modal for mvp

This commit is contained in:
alexandra vargas
2026-01-06 15:10:40 +01:00
parent 6ee1a6ea7f
commit 92041e5a05
4 changed files with 573 additions and 43 deletions
@@ -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<typeof getDataSourceSrv>;
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> = {}): 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<typeof CompatibilityModal> = {
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<typeof getDataSourceSrv>);
// Default mock: successful API call
mockCheckDashboardCompatibility.mockResolvedValue(createMockCompatibilityResult(100));
});
describe('Modal visibility', () => {
it('should render modal when isOpen is true', async () => {
render(<CompatibilityModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Dashboard Compatibility Check for Test Dashboard')).toBeInTheDocument();
});
});
it('should not render modal content when isOpen is false', async () => {
render(<CompatibilityModal {...defaultProps} isOpen={false} />);
// 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(<CompatibilityModal {...defaultProps} dashboardJson={dashboardWithCustomTitle} />);
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(<CompatibilityModal {...defaultProps} />);
// 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(<CompatibilityModal {...defaultProps} dashboardJson={v2Dashboard} />);
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<typeof getDataSourceSrv>);
render(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} />);
// 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(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} />);
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(<CompatibilityModal {...defaultProps} dashboardJson={dashboardJson} />);
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(<CompatibilityModal {...defaultProps} isOpen={false} />);
// 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(<CompatibilityModal {...defaultProps} isOpen={false} />);
// API should not be called yet
expect(mockCheckDashboardCompatibility).not.toHaveBeenCalled();
// Open modal
rerender(<CompatibilityModal {...defaultProps} isOpen={true} />);
// 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(<CompatibilityModal {...defaultProps} onDismiss={onDismiss} />);
// 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);
});
});
});
@@ -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 (
<Modal
title={t('compatibility-modal.title', 'Dashboard Compatibility Check for {{dashboardName}}', {
dashboardName: dashboardJson.title || 'Dashboard',
})}
isOpen={isOpen}
onDismiss={onDismiss}
className={styles.modal}
contentClassName={styles.modalContent}
>
<div className={styles.contentContainer}>
{/* Loading State */}
{loading && (
<Stack direction="column" alignItems="center" gap={2}>
<Spinner size="xl" />
<Text>
<Trans i18nKey="compatibility-modal.checking">Checking compatibility...</Trans>
</Text>
</Stack>
)}
{/* Error State */}
{!loading && error && (
<Stack direction="column" alignItems="center" gap={2}>
<Alert title={t('compatibility-modal.error-title', 'Error checking compatibility')} severity="error">
<Trans i18nKey="compatibility-modal.error-description">
Failed to check dashboard compatibility. Please try again.
</Trans>
</Alert>
<Button variant="secondary" onClick={retry}>
<Trans i18nKey="compatibility-modal.retry">Retry</Trans>
</Button>
</Stack>
)}
{/* Success State - Placeholder for Features #12-15 */}
{!loading && !error && result && (
<Stack direction="column" gap={3}>
<div>
<Text element="h3">
<Trans i18nKey="compatibility-modal.score-title">Compatibility Score</Trans>
</Text>
<Text element="p" variant="h2">
{result.compatibilityScore}%
</Text>
</div>
{/* 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 */}
</Stack>
)}
</div>
</Modal>
);
};
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',
}),
};
}
@@ -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> = {}): 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> = {}): 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> = {}): 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[] => [
{
@@ -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<CompatibilityCheckResult> {
// Get current organization ID from user context