Wire up compatibility check to CommunityDashboardSection

This commit is contained in:
alexandra vargas
2026-01-07 16:02:57 +01:00
parent c9e044b2c7
commit 751a399b03
6 changed files with 404 additions and 11 deletions
@@ -2,10 +2,12 @@ import { screen, waitFor } from '@testing-library/react';
import React from 'react';
import { render } from 'test/test-utils';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { CommunityDashboardSection } from './CommunityDashboardSection';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
import { GnetDashboard } from './types';
import { onUseCommunityDashboard } from './utils/communityDashboardHelpers';
import { onUseCommunityDashboard, interpolateDashboardForCompatibilityCheck } from './utils/communityDashboardHelpers';
jest.mock('./api/dashboardLibraryApi', () => ({
fetchCommunityDashboards: jest.fn(),
@@ -14,21 +16,32 @@ jest.mock('./api/dashboardLibraryApi', () => ({
jest.mock('./utils/communityDashboardHelpers', () => ({
...jest.requireActual('./utils/communityDashboardHelpers'),
onUseCommunityDashboard: jest.fn(),
interpolateDashboardForCompatibilityCheck: jest.fn(),
}));
jest.mock('./CompatibilityModal', () => ({
CompatibilityModal: jest.fn(() => <div>Compatibility Modal</div>),
}));
// Track the datasource type for mocking
let mockDatasourceType = 'prometheus';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn((uid: string) => ({
uid,
name: `DataSource ${uid}`,
type: 'test',
type: mockDatasourceType,
})),
}),
}));
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction<typeof onUseCommunityDashboard>;
const mockInterpolateDashboard = interpolateDashboardForCompatibilityCheck as jest.MockedFunction<
typeof interpolateDashboardForCompatibilityCheck
>;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
@@ -42,13 +55,14 @@ const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDa
const setup = async (
props: Partial<React.ComponentProps<typeof CommunityDashboardSection>> = {},
successScenario = true
successScenario = true,
datasourceUid = 'test-datasource-uid'
) => {
const renderResult = render(
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="test" {...props} />,
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType={mockDatasourceType} {...props} />,
{
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'],
initialEntries: [`/test?dashboardLibraryDatasourceUid=${datasourceUid}`],
},
}
);
@@ -65,6 +79,7 @@ const setup = async (
describe('CommunityDashboardSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDatasourceType = 'prometheus';
});
it('should render', async () => {
@@ -122,4 +137,115 @@ describe('CommunityDashboardSection', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error));
consoleErrorSpy.mockRestore();
});
describe('Compatibility Check Feature', () => {
it('should show "Check Compatibility" button when datasource type is prometheus', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
await setup();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Check compatibility' })).toBeInTheDocument();
});
});
it('should hide "Check Compatibility" button when datasource type is not prometheus', async () => {
mockDatasourceType = 'influxdb';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Check compatibility' })).not.toBeInTheDocument();
});
it('should hide "Check Compatibility" button when no datasourceUid in URL', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
// Render without datasourceUid in URL
render(<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="prometheus" />, {
historyOptions: {
initialEntries: ['/test'],
},
});
// Wait for component to finish initial rendering
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Check compatibility' })).not.toBeInTheDocument();
});
});
it('should call interpolation function and open modal when "Check Compatibility" is clicked', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
const mockInterpolatedDashboard: DashboardJson = { title: 'Interpolated Dashboard' } as DashboardJson;
mockInterpolateDashboard.mockResolvedValue(mockInterpolatedDashboard);
const { user } = await setup();
const checkCompatibilityButton = screen.getByRole('button', { name: 'Check compatibility' });
await user.click(checkCompatibilityButton);
await waitFor(() => {
expect(mockInterpolateDashboard).toHaveBeenCalledWith(1, 'test-datasource-uid');
});
await waitFor(() => {
expect(screen.getByText('Compatibility Modal')).toBeInTheDocument();
});
});
it('should display error alert when interpolation fails', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockRejectedValue(
new Error('Unable to automatically map all datasource inputs for this dashboard')
);
const { user } = await setup();
const checkCompatibilityButton = screen.getByRole('button', { name: 'Check compatibility' });
await user.click(checkCompatibilityButton);
await waitFor(() => {
expect(screen.getByText('Error loading dashboard')).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText('Unable to automatically map all datasource inputs for this dashboard')
).toBeInTheDocument();
});
consoleErrorSpy.mockRestore();
});
});
});
@@ -7,7 +7,10 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
import { CompatibilityModal } from './CompatibilityModal';
import { DashboardCard } from './DashboardCard';
import { MappingContext } from './SuggestedDashboardsModal';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
@@ -18,12 +21,13 @@ import {
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { GnetDashboard } from './types';
import { GnetDashboard, isGnetDashboard } from './types';
import {
getThumbnailUrl,
getLogoUrl,
buildDashboardDetails,
onUseCommunityDashboard,
interpolateDashboardForCompatibilityCheck,
COMMUNITY_PAGE_SIZE_QUERY,
COMMUNITY_RESULT_SIZE,
} from './utils/communityDashboardHelpers';
@@ -44,6 +48,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
const [searchQuery, setSearchQuery] = useState('');
const hasTrackedLoaded = useRef(false);
const [selectedDashboardJson, setSelectedDashboardJson] = useState<DashboardJson | null>(null);
const [isCompatibilityModalOpen, setIsCompatibilityModalOpen] = useState(false);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
useDebounce(
@@ -151,6 +157,31 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
[response, datasourceUid, debouncedSearchQuery, onShowMapping]
);
const [{ error: fetchError }, handleCheckCompatibility] = useAsyncFn(
async (dashboard: PluginDashboard | GnetDashboard): Promise<void> => {
// Type guard: Only GnetDashboards (community dashboards) are supported
if (!isGnetDashboard(dashboard)) {
console.warn('Compatibility check is only supported for community dashboards (GnetDashboard)');
return;
}
if (!datasourceUid || !response?.datasourceType) {
return;
}
try {
const interpolatedDashboard = await interpolateDashboardForCompatibilityCheck(dashboard.id, datasourceUid);
setSelectedDashboardJson(interpolatedDashboard);
setIsCompatibilityModalOpen(true);
} catch (err) {
console.error('Error preparing dashboard for compatibility check:', err);
throw err;
}
},
[datasourceUid, response]
);
return (
<Stack direction="column" gap={2} height="100%">
{isPreviewDashboardError && (
@@ -163,6 +194,20 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
</Alert>
</div>
)}
{fetchError && (
<div>
<Alert
title={t('dashboard-library.compatibility-check-error-title', 'Error loading dashboard')}
severity="error"
>
{fetchError.message ||
t(
'dashboard-library.compatibility-check-error-description',
'Failed to load dashboard for compatibility check. Please try again.'
)}
</Alert>
</div>
)}
<FilterInput
placeholder={
datasourceType
@@ -263,6 +308,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
showCompatibilityButton={!!datasourceUid && response?.datasourceType === 'prometheus'}
onCheckCompatibility={handleCheckCompatibility}
/>
);
})}
@@ -278,6 +325,19 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
</Stack>
)}
</div>
{/* Compatibility Modal - conditionally rendered */}
{isCompatibilityModalOpen && selectedDashboardJson && datasourceUid && (
<CompatibilityModal
isOpen={isCompatibilityModalOpen}
onDismiss={() => {
setIsCompatibilityModalOpen(false);
setSelectedDashboardJson(null);
}}
dashboardJson={selectedDashboardJson}
datasourceUid={datasourceUid}
/>
)}
</Stack>
);
};
@@ -7,7 +7,7 @@ import { Badge, Box, Button, Card, IconButton, Text, TextLink, Tooltip, useStyle
import { attachSkeleton, SkeletonComponent } from '@grafana/ui/unstable';
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboard } from './types';
import { GnetDashboard, isGnetDashboard } from './types';
interface Details {
id: string;
@@ -28,7 +28,7 @@ interface Props {
showDatasourceProvidedBadge?: boolean;
dimThumbnail?: boolean; // Apply 50% opacity to thumbnail when badge is shown
kind: 'template_dashboard' | 'suggested_dashboard';
onCheckCompatibility?: (dashboard: PluginDashboard | GnetDashboard) => void;
onCheckCompatibility?: (dashboard: PluginDashboard | GnetDashboard) => void | Promise<void>;
showCompatibilityButton?: boolean;
}
@@ -116,7 +116,12 @@ function DashboardCardComponent({
icon="check-circle"
onClick={(e) => {
e.stopPropagation();
onCheckCompatibility(dashboard);
// Only call compatibility check for GnetDashboards (community dashboards)
if (isGnetDashboard(dashboard)) {
onCheckCompatibility(dashboard);
} else {
console.warn('Compatibility check is only supported for community dashboards (GnetDashboard)');
}
}}
aria-label={t('dashboard-library.card.check-compatibility-button', 'Check compatibility')}
/>
@@ -1,4 +1,5 @@
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
export interface Link {
rel: string;
@@ -47,3 +48,11 @@ export interface GnetDashboardsResponse {
pages: number;
items: GnetDashboard[];
}
/**
* Type guard to check if a dashboard is a GnetDashboard (community dashboard).
* PluginDashboard has fields like importedRevision, importedUri, path that GnetDashboard doesn't have.
*/
export function isGnetDashboard(dashboard: PluginDashboard | GnetDashboard): dashboard is GnetDashboard {
return !('importedRevision' in dashboard || 'importedUri' in dashboard || 'path' in dashboard);
}
@@ -1,4 +1,4 @@
import { locationService } from '@grafana/runtime';
import { BackendSrv, getBackendSrv, locationService } from '@grafana/runtime';
import { InputType, DataSourceInput, DashboardInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
@@ -14,6 +14,7 @@ import {
getLogoUrl,
navigateToTemplate,
onUseCommunityDashboard,
interpolateDashboardForCompatibilityCheck,
} from './communityDashboardHelpers';
jest.mock('../api/dashboardLibraryApi', () => ({
@@ -34,12 +35,34 @@ jest.mock('../interactions', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: jest.fn(),
locationService: {
push: jest.fn(),
},
}));
// Mock function references
const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction<typeof fetchCommunityDashboard>;
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction<typeof tryAutoMapDatasources>;
const mockParseConstantInputs = parseConstantInputs as jest.MockedFunction<typeof parseConstantInputs>;
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
// Helper functions for creating mock objects
const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =>
({
post: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
put: jest.fn(),
request: jest.fn(),
datasourceRequest: jest.fn(),
resolveCancelerIfExists: jest.fn(),
...overrides,
}) as BackendSrv;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 123,
name: 'Test Dashboard',
@@ -610,4 +633,125 @@ describe('communityDashboardHelpers', () => {
});
});
});
describe('interpolateDashboardForCompatibilityCheck', () => {
let mockPost: jest.Mock;
beforeEach(() => {
mockPost = jest.fn();
mockGetBackendSrv.mockReturnValue(createMockBackendSrv({ post: mockPost }));
});
afterEach(() => {
jest.clearAllMocks();
});
it('should successfully interpolate dashboard when auto-mapping succeeds', async () => {
const dashboardJson = createMockDashboardJson({
__inputs: [
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
value: '',
description: '',
pluginId: 'prometheus',
info: '',
} as DataSourceInput & { description: string },
],
});
const interpolatedDashboard = createMockDashboardJson({ title: 'Interpolated Dashboard' });
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [{ name: 'DS_PROMETHEUS', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }],
unmappedDsInputs: [],
});
mockPost.mockResolvedValue(interpolatedDashboard);
const result = await interpolateDashboardForCompatibilityCheck(123, 'prom-uid');
expect(result).toEqual(interpolatedDashboard);
expect(mockFetchCommunityDashboard).toHaveBeenCalledWith(123);
expect(mockTryAutoMapDatasources).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith('/api/dashboards/interpolate', {
dashboard: dashboardJson,
overwrite: true,
inputs: [{ name: 'DS_PROMETHEUS', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }],
});
});
it('should throw error when auto-mapping fails', async () => {
const dashboardJson = createMockDashboardJson({
__inputs: [
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
value: '',
description: '',
pluginId: 'prometheus',
info: '',
} as DataSourceInput & { description: string },
],
});
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: false,
mappings: [],
unmappedDsInputs: [
{
name: 'DS_PROMETHEUS',
pluginId: 'prometheus',
type: InputType.DataSource,
value: '',
label: 'Prometheus',
description: '',
info: '',
},
],
});
await expect(interpolateDashboardForCompatibilityCheck(123, 'prom-uid')).rejects.toThrow(
'Unable to automatically map all datasource inputs for this dashboard'
);
expect(mockPost).not.toHaveBeenCalled();
});
it('should throw error when interpolation API fails', async () => {
const dashboardJson = createMockDashboardJson();
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [],
unmappedDsInputs: [],
});
mockPost.mockRejectedValue(new Error('API failed'));
await expect(interpolateDashboardForCompatibilityCheck(123, 'prom-uid')).rejects.toThrow('API failed');
});
it('should handle dashboard with no __inputs', async () => {
const dashboardJson = createMockDashboardJson({ __inputs: undefined });
const interpolatedDashboard = createMockDashboardJson({ title: 'Interpolated Dashboard' });
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [],
unmappedDsInputs: [],
});
mockPost.mockResolvedValue(interpolatedDashboard);
const result = await interpolateDashboardForCompatibilityCheck(123, 'prom-uid');
expect(result).toEqual(interpolatedDashboard);
expect(mockTryAutoMapDatasources).toHaveBeenCalledWith([], 'prom-uid');
});
});
});
@@ -1,6 +1,6 @@
import { PanelModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
@@ -311,3 +311,52 @@ export async function onUseCommunityDashboard({
throw err;
}
}
/**
* Interpolate a community dashboard for compatibility checking.
*
* This function fetches the dashboard from Grafana.com, auto-maps datasource inputs,
* and returns the interpolated dashboard with template variables resolved.
*
* @throws Error if auto-mapping fails - compatibility check requires all datasource inputs to be resolved
* @param dashboardId - The Grafana.com dashboard ID
* @param datasourceUid - The UID of the datasource to map to
* @returns Promise<DashboardJson> - The interpolated dashboard with resolved template variables
*/
export async function interpolateDashboardForCompatibilityCheck(
dashboardId: number,
datasourceUid: string
): Promise<DashboardJson> {
// 1. Fetch full dashboard JSON from Grafana.com
const gnetResponse = await fetchCommunityDashboard(dashboardId);
const dashboardJson = gnetResponse.json;
// 2. Extract datasource inputs from dashboard's __inputs array
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
// 3. Auto-map datasources using existing utility
const mappingResult = tryAutoMapDatasources(dsInputs, datasourceUid);
// 4. Check if auto-mapping was successful
// Compatibility check requires all datasource variables to be resolved
if (!mappingResult.allMapped) {
throw new Error(
t(
'dashboard-library.compatibility-auto-map-failed',
'Unable to automatically map all datasource inputs for this dashboard. Compatibility check requires all datasource variables to be resolved.'
)
);
}
// 5. Prepare inputs array for interpolation API
const inputs: InputMapping[] = mappingResult.mappings;
// 6. Call interpolation endpoint to replace template variables
const interpolatedDashboard = await getBackendSrv().post<DashboardJson>('/api/dashboards/interpolate', {
dashboard: dashboardJson,
overwrite: true,
inputs: inputs,
});
return interpolatedDashboard;
}