diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx
index d4821d96899..f321c8e5bd9 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx
@@ -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(() =>
Compatibility Modal
),
+}));
+
+// 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;
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction;
+const mockInterpolateDashboard = interpolateDashboardForCompatibilityCheck as jest.MockedFunction<
+ typeof interpolateDashboardForCompatibilityCheck
+>;
const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({
id: 1,
@@ -42,13 +55,14 @@ const createMockGnetDashboard = (overrides: Partial = {}): GnetDa
const setup = async (
props: Partial> = {},
- successScenario = true
+ successScenario = true,
+ datasourceUid = 'test-datasource-uid'
) => {
const renderResult = render(
- ,
+ ,
{
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(, {
+ 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();
+ });
+ });
});
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx
index bd42428564b..f70c934597c 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx
@@ -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(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 => {
+ // 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 (
{isPreviewDashboardError && (
@@ -163,6 +194,20 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
)}
+ {fetchError && (
+
+
+ {fetchError.message ||
+ t(
+ 'dashboard-library.compatibility-check-error-description',
+ 'Failed to load dashboard for compatibility check. Please try again.'
+ )}
+
+
+ )}
);
})}
@@ -278,6 +325,19 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
)}
+
+ {/* Compatibility Modal - conditionally rendered */}
+ {isCompatibilityModalOpen && selectedDashboardJson && datasourceUid && (
+ {
+ setIsCompatibilityModalOpen(false);
+ setSelectedDashboardJson(null);
+ }}
+ dashboardJson={selectedDashboardJson}
+ datasourceUid={datasourceUid}
+ />
+ )}
);
};
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx
index 3943ed296b3..c884ceae33b 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx
@@ -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;
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')}
/>
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts
index ac50627398e..475095987f3 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts
@@ -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);
+}
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts
index 8a4c2c3c695..92f7dd2bfbf 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts
@@ -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;
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction;
const mockParseConstantInputs = parseConstantInputs as jest.MockedFunction;
+const mockGetBackendSrv = getBackendSrv as jest.MockedFunction;
// Helper functions for creating mock objects
+const createMockBackendSrv = (overrides: Partial = {}): 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 => ({
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');
+ });
+ });
});
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts
index 05c20ee1d9f..8cfe96a88dd 100644
--- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts
+++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts
@@ -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 - The interpolated dashboard with resolved template variables
+ */
+export async function interpolateDashboardForCompatibilityCheck(
+ dashboardId: number,
+ datasourceUid: string
+): Promise {
+ // 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('/api/dashboards/interpolate', {
+ dashboard: dashboardJson,
+ overwrite: true,
+ inputs: inputs,
+ });
+
+ return interpolatedDashboard;
+}