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; +}