From d44cab9eafd9b63cba6da7b9e8a24d4c6e378884 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Tue, 6 Jan 2026 06:38:15 -0300 Subject: [PATCH] DashboardLibrary: Add validations to visualize community dashboards (#114562) * dashboard library check added * community dashboard section tests in progress * tests added * translations added * pagination removed * total pages removed * test updated. pagination removed * filters applied * tracking event removed to be created in another pr * slug added so url is correclty generated * ui fix * improvements after review * improvements after review * more tests added. new logic created * fix * changes applied * tests removed. pattern updated * preset of 6 elements applied * Improve code comments and adjust variable name based on PR feedback * Fix unit test and add extra case for regex pattern * Fix interaction event, we were missing contentKind on BasicProvisioned flow and datasources types were not being send --------- Co-authored-by: nmarrs Co-authored-by: alexandra vargas --- .../BasicProvisionedDashboardsEmptyPage.tsx | 1 + .../CommunityDashboardSection.test.tsx | 125 ++++++ .../CommunityDashboardSection.tsx | 172 ++++----- .../DashboardLibrary/DashboardCard.test.tsx | 35 +- .../DashboardLibrarySection.test.tsx | 273 ++++++++++++++ .../SuggestedDashboards.test.tsx | 186 +++++++++ .../DashboardLibrary/SuggestedDashboards.tsx | 80 ++-- .../SuggestedDashboardsModal.test.tsx | 101 +++++ .../api/dashboardLibraryApi.test.ts | 89 +++-- .../api/dashboardLibraryApi.ts | 54 ++- .../dashgrid/DashboardLibrary/interactions.ts | 1 + .../dashgrid/DashboardLibrary/types.ts | 2 + .../utils/communityDashboardHelpers.test.ts | 357 ++++++++++++++++-- .../utils/communityDashboardHelpers.ts | 137 ++++++- .../DashboardLibrary/utils/test-utils.ts | 34 ++ public/locales/en-US/grafana.json | 3 +- 16 files changed, 1423 insertions(+), 227 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx index bcfb61ef823..7e041280b04 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx @@ -78,6 +78,7 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, libraryItemId: dashboard.uid, creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD, + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, }); const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx new file mode 100644 index 00000000000..d4821d96899 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx @@ -0,0 +1,125 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { render } from 'test/test-utils'; + +import { CommunityDashboardSection } from './CommunityDashboardSection'; +import { fetchCommunityDashboards } from './api/dashboardLibraryApi'; +import { GnetDashboard } from './types'; +import { onUseCommunityDashboard } from './utils/communityDashboardHelpers'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchCommunityDashboards: jest.fn(), +})); + +jest.mock('./utils/communityDashboardHelpers', () => ({ + ...jest.requireActual('./utils/communityDashboardHelpers'), + onUseCommunityDashboard: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid: string) => ({ + uid, + name: `DataSource ${uid}`, + type: 'test', + })), + }), +})); + +const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction; +const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction; + +const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 1, + name: 'Test Dashboard', + description: 'Test Description', + downloads: 2000, + datasource: 'Prometheus', + slug: 'test-dashboard', + ...overrides, +}); + +const setup = async ( + props: Partial> = {}, + successScenario = true +) => { + const renderResult = render( + , + { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'], + }, + } + ); + + if (successScenario) { + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + }); + } + + return renderResult; +}; + +describe('CommunityDashboardSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render', async () => { + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 5, + items: [ + createMockGnetDashboard(), + createMockGnetDashboard({ id: 2, name: 'Test Dashboard 2' }), + createMockGnetDashboard({ id: 3, name: 'Test Dashboard 3' }), + ], + }); + + await setup(); + + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Test Dashboard 2')).toBeInTheDocument(); + expect(screen.getByText('Test Dashboard 3')).toBeInTheDocument(); + }); + }); + + it('should show error when fetching a specific community dashboard after clicking use dashboard button fails', async () => { + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 5, + items: [createMockGnetDashboard()], + }); + + mockOnUseCommunityDashboard.mockRejectedValue(new Error('Failed to use community dashboard')); + + const { user } = await setup(); + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + }); + + const useDashboardButton = screen.getByRole('button', { name: 'Use dashboard' }); + await user.click(useDashboardButton); + + await waitFor(() => { + expect(screen.getByText('Error loading community dashboard')).toBeInTheDocument(); + }); + }); + + it('should show error when fetching community dashboards list fails', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockFetchCommunityDashboards.mockRejectedValue(new Error('Failed to fetch community dashboards')); + + await setup(undefined, false); + + await waitFor(() => { + expect(screen.getByText('Error loading community dashboards')).toBeInTheDocument(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx index d914a31f1bc..bd42428564b 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; -import { useAsync, useDebounce } from 'react-use'; +import { useAsyncFn, useAsyncRetry, useDebounce } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui'; +import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui'; import { DashboardCard } from './DashboardCard'; import { MappingContext } from './SuggestedDashboardsModal'; @@ -24,6 +24,8 @@ import { getLogoUrl, buildDashboardDetails, onUseCommunityDashboard, + COMMUNITY_PAGE_SIZE_QUERY, + COMMUNITY_RESULT_SIZE, } from './utils/communityDashboardHelpers'; interface Props { @@ -31,8 +33,6 @@ interface Props { datasourceType?: string; } -// Constants for community dashboard pagination and API params -const COMMUNITY_PAGE_SIZE = 9; const SEARCH_DEBOUNCE_MS = 500; const DEFAULT_SORT_ORDER = 'downloads'; const DEFAULT_SORT_DIRECTION = 'desc'; @@ -42,7 +42,6 @@ const INCLUDE_SCREENSHOTS = true; export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => { const [searchParams] = useSearchParams(); const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); - const [currentPage, setCurrentPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); const hasTrackedLoaded = useRef(false); @@ -55,18 +54,12 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro [searchQuery] ); - // Reset to page 1 when debounced search query changes - useEffect(() => { - if (debouncedSearchQuery) { - setCurrentPage(1); - } - }, [debouncedSearchQuery]); - const { value: response, loading, error, - } = useAsync(async () => { + retry, + } = useAsyncRetry(async () => { if (!datasourceUid) { return null; } @@ -80,8 +73,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro const apiResponse = await fetchCommunityDashboards({ orderBy: DEFAULT_SORT_ORDER, direction: DEFAULT_SORT_DIRECTION, - page: currentPage, - pageSize: COMMUNITY_PAGE_SIZE, + page: 1, + pageSize: COMMUNITY_PAGE_SIZE_QUERY, includeLogo: INCLUDE_LOGO, includeScreenshots: INCLUDE_SCREENSHOTS, dataSourceSlugIn: ds.type, @@ -100,15 +93,14 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro } return { - dashboards: apiResponse.items, - pages: apiResponse.pages, + dashboards: apiResponse.items.slice(0, COMMUNITY_RESULT_SIZE), datasourceType: ds.type, }; } catch (err) { console.error('Error loading community dashboards', err); throw err; } - }, [datasourceUid, currentPage, debouncedSearchQuery]); + }, [datasourceUid, debouncedSearchQuery]); // Track analytics only once on first successful load useEffect(() => { @@ -128,37 +120,49 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro // Determine what to show in results area const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : []; - const totalPages = response?.pages || 1; const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0); const showError = !loading && error; - const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { - if (!response) { - return; - } + const [{ error: isPreviewDashboardError }, onPreviewCommunityDashboard] = useAsyncFn( + async (dashboard: GnetDashboard) => { + if (!response) { + return; + } - // Track item click - DashboardLibraryInteractions.itemClicked({ - contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, - datasourceTypes: [response.datasourceType], - libraryItemId: String(dashboard.id), - libraryItemTitle: dashboard.name, - sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, - eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, - discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE, - }); + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [response.datasourceType], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE, + }); - onUseCommunityDashboard({ - dashboard, - datasourceUid: datasourceUid || '', - datasourceType: response.datasourceType, - eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, - onShowMapping, - }); - }; + await onUseCommunityDashboard({ + dashboard, + datasourceUid: datasourceUid || '', + datasourceType: response.datasourceType, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + onShowMapping, + }); + }, + [response, datasourceUid, debouncedSearchQuery, onShowMapping] + ); return ( + {isPreviewDashboardError && ( +
+ + Failed to load community dashboard. + +
+ )} - {Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => ( + {Array.from({ length: COMMUNITY_RESULT_SIZE }).map((_, i) => ( ))} @@ -197,7 +201,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro Failed to load community dashboards. Please try again. -
@@ -233,42 +237,47 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro )} ) : ( - = 2 ? 2 : 1, - lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1, - }} - > - {dashboards.map((dashboard) => { - const thumbnailUrl = getThumbnailUrl(dashboard); - const logoUrl = getLogoUrl(dashboard); - const imageUrl = thumbnailUrl || logoUrl; - const isLogo = !thumbnailUrl; - const details = buildDashboardDetails(dashboard); + + = 2 ? 2 : 1, + lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1, + }} + > + {dashboards.map((dashboard) => { + const thumbnailUrl = getThumbnailUrl(dashboard); + const logoUrl = getLogoUrl(dashboard); + const imageUrl = thumbnailUrl || logoUrl; + const isLogo = !thumbnailUrl; + const details = buildDashboardDetails(dashboard); - return ( - onPreviewCommunityDashboard(dashboard)} - isLogo={isLogo} - details={details} - kind="suggested_dashboard" - /> - ); - })} - + return ( + onPreviewCommunityDashboard(dashboard)} + isLogo={isLogo} + details={details} + kind="suggested_dashboard" + /> + ); + })} + + + + + )} - {totalPages > 1 && ( -
- -
- )} ); }; @@ -277,18 +286,9 @@ function getStyles(theme: GrafanaTheme2) { return { resultsContainer: css({ width: '100%', - position: 'relative', flex: 1, overflow: 'auto', - }), - paginationWrapper: css({ - position: 'sticky', - bottom: 0, - backgroundColor: theme.colors.background.primary, - padding: theme.spacing(2), - display: 'flex', - justifyContent: 'flex-end', - zIndex: 2, + paddingBottom: theme.spacing(2), }), }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx index 939f1bcdb89..5af933ceec1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx @@ -1,41 +1,8 @@ import { screen } from '@testing-library/react'; import { render } from 'test/test-utils'; -import { PluginDashboard } from 'app/types/plugins'; - import { DashboardCard } from './DashboardCard'; -import { GnetDashboard } from './types'; - -// Helper functions for creating mock objects -const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ - dashboardId: 1, - description: 'Test description', - imported: false, - importedRevision: 0, - importedUri: '', - importedUrl: '', - path: '', - pluginId: 'test-plugin', - removed: false, - revision: 1, - slug: 'test-dashboard', - title: 'Test Dashboard', - uid: 'test-uid', - ...overrides, -}); - -const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ - id: 123, - name: 'Test Dashboard', - description: 'Test description', - datasource: 'Prometheus', - orgName: 'Test Org', - userName: 'testuser', - publishedAt: '', - updatedAt: '', - downloads: 0, - ...overrides, -}); +import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils'; const createMockDetails = (overrides = {}) => ({ id: '123', diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx new file mode 100644 index 00000000000..1147967acd1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx @@ -0,0 +1,273 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { locationService } from '@grafana/runtime'; + +import { DashboardLibrarySection } from './DashboardLibrarySection'; +import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from './interactions'; +import { createMockPluginDashboard } from './utils/test-utils'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchProvisionedDashboards: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid?: string) => { + if (uid) { + return { + uid, + name: `DataSource ${uid}`, + type: 'test-datasource', + }; + } + return null; + }), + }), + locationService: { + push: jest.fn(), + getHistory: jest.fn(() => ({ + listen: jest.fn(() => jest.fn()), + })), + }, +})); + +jest.mock('./interactions', () => ({ + ...jest.requireActual('./interactions'), + DashboardLibraryInteractions: { + loaded: jest.fn(), + itemClicked: jest.fn(), + }, +})); + +jest.mock('./DashboardCard', () => { + const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => ( +
+ {title} +
+ ); + + const DashboardCardSkeleton = () =>
Skeleton
; + + return { + DashboardCard: Object.assign(DashboardCardComponent, { + Skeleton: DashboardCardSkeleton, + }), + }; +}); + +const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction< + typeof fetchProvisionedDashboards +>; +const mockLocationServicePush = locationService.push as jest.MockedFunction; +const mockDashboardLibraryInteractionsLoaded = DashboardLibraryInteractions.loaded as jest.MockedFunction< + typeof DashboardLibraryInteractions.loaded +>; +const mockDashboardLibraryInteractionsItemClicked = DashboardLibraryInteractions.itemClicked as jest.MockedFunction< + typeof DashboardLibraryInteractions.itemClicked +>; + +describe('DashboardLibrarySection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render dashboards when they are available', async () => { + const dashboards = [ + createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }), + createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }), + ]; + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-card-Dashboard 2')).toBeInTheDocument(); + }); + }); + + it('should show empty state when there are no dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByText('No test-datasource provisioned dashboards found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.' + ) + ).toBeInTheDocument(); + const browseButton = screen.getByRole('button', { name: 'Browse plugins' }); + expect(browseButton).toBeInTheDocument(); + }); + }); + + it('should show empty state without datasource type when datasourceUid is not provided', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + + render(, { + historyOptions: { + initialEntries: ['/test'], + }, + }); + + await waitFor(() => { + expect(screen.getByText('No provisioned dashboards found')).toBeInTheDocument(); + }); + }); + + it('should render pagination when there are more than 9 dashboards', async () => { + const dashboards = Array.from({ length: 18 }, (_, i) => + createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` }) + ); + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + const pagination = screen.getByRole('navigation'); + expect(pagination).toBeInTheDocument(); + expect(within(pagination).getByText('1')).toBeInTheDocument(); + expect(within(pagination).getByText('2')).toBeInTheDocument(); + }); + }); + + it('should not render pagination when there are 9 or fewer dashboards', async () => { + const dashboards = Array.from({ length: 9 }, (_, i) => + createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` }) + ); + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + }); + + const pagination = screen.queryByRole('navigation'); + expect(pagination).not.toBeInTheDocument(); + }); + + it('should navigate to template route when clicking on a dashboard', async () => { + const dashboard = createMockPluginDashboard({ + title: 'Test Dashboard', + uid: 'test-uid-123', + pluginId: 'test-plugin', + path: 'test/path.json', + }); + + mockFetchProvisionedDashboards.mockResolvedValue([dashboard]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument(); + }); + + const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard'); + dashboardCard.click(); + + await waitFor(() => { + expect(mockLocationServicePush).toHaveBeenCalled(); + const callArgs = mockLocationServicePush.mock.calls[0][0]; + expect(callArgs).toContain('/dashboard/template'); + expect(callArgs).toContain('datasource=test-uid'); + + expect(callArgs).toContain('title=Test+Dashboard'); + expect(callArgs).toContain('pluginId=test-plugin'); + expect(callArgs).toContain('path=test%2Fpath.json'); + expect(callArgs).toContain('libraryItemId=test-uid-123'); + }); + }); + + it('should track analytics when dashboards are loaded', async () => { + const dashboards = [ + createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }), + createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }), + ]; + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockDashboardLibraryInteractionsLoaded).toHaveBeenCalledWith({ + numberOfItems: 2, + contentKinds: ['datasource_dashboard'], + datasourceTypes: ['test-datasource'], + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', + }); + }); + }); + + it('should track analytics when a dashboard is clicked', async () => { + const dashboard = createMockPluginDashboard({ + title: 'Test Dashboard', + uid: 'test-uid-123', + pluginId: 'test-plugin', + }); + + mockFetchProvisionedDashboards.mockResolvedValue([dashboard]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument(); + }); + + const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard'); + dashboardCard.click(); + + await waitFor(() => { + expect(mockDashboardLibraryInteractionsItemClicked).toHaveBeenCalledWith({ + contentKind: 'datasource_dashboard', + datasourceTypes: ['test-plugin'], + libraryItemId: 'test-uid-123', + libraryItemTitle: 'Test Dashboard', + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', + discoveryMethod: 'browse', + }); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx new file mode 100644 index 00000000000..4109a198f05 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx @@ -0,0 +1,186 @@ +import { screen, waitFor } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { SuggestedDashboards } from './SuggestedDashboards'; +import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchProvisionedDashboards: jest.fn(), + fetchCommunityDashboards: jest.fn(), +})); + +jest.mock('./utils/communityDashboardHelpers', () => ({ + ...jest.requireActual('./utils/communityDashboardHelpers'), + onUseCommunityDashboard: jest.fn(), +})); + +jest.mock('./SuggestedDashboardsModal', () => ({ + SuggestedDashboardsModal: () =>
Modal
, +})); + +jest.mock('./DashboardCard', () => { + const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => ( +
+ {title} +
+ ); + + const DashboardCardSkeleton = () =>
Skeleton
; + + return { + DashboardCard: Object.assign(DashboardCardComponent, { + Skeleton: DashboardCardSkeleton, + }), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid?: string) => { + if (uid) { + return { + uid, + name: `DataSource ${uid}`, + type: 'test-datasource', + }; + } + return null; + }), + }), +})); + +jest.mock('./interactions', () => ({ + ...jest.requireActual('./interactions'), + DashboardLibraryInteractions: { + loaded: jest.fn(), + itemClicked: jest.fn(), + }, +})); + +const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction< + typeof fetchProvisionedDashboards +>; +const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction; + +describe('SuggestedDashboards', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render when there are dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [createMockGnetDashboard()], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('suggested-dashboards')).toBeInTheDocument(); + }); + }); + + it('should not render when there are no dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('suggested-dashboards')).not.toBeInTheDocument(); + }); + }); + + it('should render provisioned dashboard cards', async () => { + const provisionedDashboard = createMockPluginDashboard({ title: 'Provisioned Dashboard 1' }); + mockFetchProvisionedDashboards.mockResolvedValue([provisionedDashboard]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Provisioned Dashboard 1')).toBeInTheDocument(); + }); + }); + + it('should render community dashboard cards', async () => { + const communityDashboard = createMockGnetDashboard({ name: 'Community Dashboard 1' }); + mockFetchProvisionedDashboards.mockResolvedValue([]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [communityDashboard], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Community Dashboard 1')).toBeInTheDocument(); + }); + }); + + it('should show "View all" button when hasMoreDashboards is true', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([ + createMockPluginDashboard(), + createMockPluginDashboard({ title: 'Provisioned Dashboard 2' }), + ]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'View all' })).toBeInTheDocument(); + }); + }); + + it('should not show "View all" button when hasMoreDashboards is false', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [createMockGnetDashboard()], + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'View all' })).not.toBeInTheDocument(); + }); + }); + + it('should render title and subtitle with datasource type when datasourceUid is provided', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Build a dashboard using suggested options for your test-datasource data source') + ).toBeInTheDocument(); + expect( + screen.getByText('Browse and select from data-source provided or community dashboards') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx index d0384e9746d..2a4a051b7cf 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; -import { useAsync } from 'react-use'; +import { useAsync, useAsyncFn } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv, locationService } from '@grafana/runtime'; -import { Button, useStyles2, Grid } from '@grafana/ui'; +import { Button, useStyles2, Grid, Alert } from '@grafana/ui'; import { PluginDashboard } from 'app/types/plugins'; import { DashboardCard } from './DashboardCard'; @@ -26,6 +26,8 @@ import { getLogoUrl, buildDashboardDetails, onUseCommunityDashboard, + COMMUNITY_PAGE_SIZE_QUERY, + COMMUNITY_RESULT_SIZE, } from './utils/communityDashboardHelpers'; import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; @@ -43,7 +45,7 @@ type SuggestedDashboardsResult = { }; // Constants for suggested dashboards API params -const SUGGESTED_COMMUNITY_PAGE_SIZE = 2; +const MAX_SUGGESTED_DASHBOARDS_PREVIEW = 2; const DEFAULT_SORT_ORDER = 'downloads'; const DEFAULT_SORT_DIRECTION = 'desc'; const INCLUDE_SCREENSHOTS = true; @@ -91,14 +93,14 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { orderBy: DEFAULT_SORT_ORDER, direction: DEFAULT_SORT_DIRECTION, page: 1, - pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE, + pageSize: COMMUNITY_PAGE_SIZE_QUERY, includeScreenshots: INCLUDE_SCREENSHOTS, dataSourceSlugIn: ds.type, includeLogo: INCLUDE_LOGO, }), ]); - const community = communityResponse.items; + const community = communityResponse.items.slice(0, COMMUNITY_RESULT_SIZE); // Mix: 1 provisioned + 2 community const mixed: MixedDashboard[] = []; @@ -130,7 +132,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { // Determine if there are more dashboards available beyond what we're showing // Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards - const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE; + const hasMoreDashboards = provisioned.length > 1 || community.length > MAX_SUGGESTED_DASHBOARDS_PREVIEW; return { dashboards: mixed, hasMoreDashboards }; } catch (error) { @@ -233,35 +235,38 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { locationService.push(`/dashboard/template?${params.toString()}`); }; - const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { - if (!datasourceUid) { - return; - } + const [{ error: isPreviewCommunityDashboardError }, onPreviewCommunityDashboard] = useAsyncFn( + async (dashboard: GnetDashboard) => { + if (!datasourceUid) { + return; + } - const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); - if (!ds) { - return; - } + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return; + } - // Track item click - DashboardLibraryInteractions.itemClicked({ - contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, - datasourceTypes: [ds.type], - libraryItemId: String(dashboard.id), - libraryItemTitle: dashboard.name, - sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, - eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, - discoveryMethod: DISCOVERY_METHODS.BROWSE, - }); + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [ds.type], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + discoveryMethod: DISCOVERY_METHODS.BROWSE, + }); - onUseCommunityDashboard({ - dashboard, - datasourceUid, - datasourceType: ds.type, - eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, - onShowMapping: onShowMapping, - }); - }; + await onUseCommunityDashboard({ + dashboard, + datasourceUid, + datasourceType: ds.type, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + onShowMapping: onShowMapping, + }); + }, + [datasourceUid, onShowMapping] + ); // Don't render if no dashboards or still loading if (!loading && (!result || result.dashboards.length === 0)) { @@ -297,7 +302,16 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { )} - + {isPreviewCommunityDashboardError && ( +
+ + Failed to load community dashboard. + +
+ )} ({ + DashboardLibrarySection: () =>
Dashboard Library Section
, +})); + +jest.mock('./CommunityDashboardSection', () => ({ + CommunityDashboardSection: () =>
Community Dashboard Section
, +})); + +jest.mock('./CommunityDashboardMappingForm', () => ({ + CommunityDashboardMappingForm: () => ( +
Community Dashboard Mapping Form
+ ), +})); + +describe('SuggestedDashboardsModal', () => { + const defaultProps = { + isOpen: true, + onDismiss: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render when isOpen is true', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should not render when isOpen is false', () => { + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should render both tabs: Data-source provided and Community', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Data-source provided' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Community' })).toBeInTheDocument(); + }); + + it('should render tablist with both tabs', () => { + render(); + + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('Data-source provided'); + expect(tabs[1]).toHaveTextContent('Community'); + }); + + it('should render DashboardLibrarySection when activeView is datasource', () => { + render(); + + expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument(); + }); + + it('should render CommunityDashboardSection when activeView is community', () => { + render(); + + expect(screen.getByTestId('community-dashboard-section')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument(); + }); + + it('should render CommunityDashboardMappingForm when activeView is mapping', () => { + render( + + ); + + expect(screen.getByTestId('community-dashboard-mapping-form')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts index c662b90e372..4341758358d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts @@ -3,6 +3,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types'; import { PluginDashboard } from 'app/types/plugins'; import { GnetDashboard } from '../types'; +import { createMockGnetDashboard, createMockPluginDashboard } from '../utils/test-utils'; import { fetchCommunityDashboard, @@ -14,8 +15,16 @@ import { jest.mock('@grafana/runtime', () => ({ getBackendSrv: jest.fn(), + reportInteraction: jest.fn(), })); +jest.mock('../interactions', () => ({ + ...jest.requireActual('../interactions'), + DashboardLibraryInteractions: { + ...jest.requireActual('../interactions').DashboardLibraryInteractions, + communityDashboardFiltered: jest.fn(), + }, +})); const mockGetBackendSrv = getBackendSrv as jest.MockedFunction; // Helper to create mock BackendSrv @@ -26,31 +35,9 @@ const createMockBackendSrv = (overrides: Partial = {}): BackendSrv = }) as unknown as BackendSrv; // Helper functions for creating mock objects -const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ - id: 1, - name: 'Test Dashboard', - description: 'Test Description', - downloads: 100, - datasource: 'Prometheus', - ...overrides, -}); - -const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ - dashboardId: 1, - uid: 'dash-uid', - title: 'Test Dashboard', - pluginId: 'prometheus', - path: 'dashboards/test.json', - description: 'Test plugin dashboard', - imported: false, - importedRevision: 0, - importedUri: '', - importedUrl: '', - removed: false, - revision: 1, - slug: 'test-dashboard', - ...overrides, -}); +const createMockGnetDashboardWithDownloads = (overrides: Partial = {}): GnetDashboard => { + return createMockGnetDashboard({ ...overrides, downloads: 10000 }); +}; const defaultFetchParams: FetchCommunityDashboardsParams = { orderBy: 'downloads', @@ -80,8 +67,54 @@ describe('dashboardLibraryApi', () => { }); describe('fetchCommunityDashboards', () => { + describe('filterNotSafeDashboards', () => { + it('should filter out dashboards with panel types that can contain JavaScript code', async () => { + const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 }); + const mockDashboards = [ + safeDashboard, + createMockGnetDashboardWithDownloads({ id: 2, panelTypeSlugs: ['ae3e-plotly-panel'] }), + ]; + const mockResponse = { + page: 1, + pages: 5, + items: mockDashboards, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(result).toEqual({ + page: 1, + pages: 5, + items: [safeDashboard], + }); + }); + + it('should filter out dashboards with low downloads', async () => { + const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 }); + const mockDashboards = [safeDashboard, createMockGnetDashboard({ id: 2, downloads: 999 })]; + const mockResponse = { + page: 1, + pages: 5, + items: mockDashboards, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(result).toEqual({ + page: 1, + pages: 5, + items: [safeDashboard], + }); + }); + }); + it('should fetch community dashboards with correct query parameters', async () => { - const mockDashboards = [createMockGnetDashboard({ id: 1 }), createMockGnetDashboard({ id: 2 })]; + const mockDashboards = [ + createMockGnetDashboardWithDownloads({ id: 1 }), + createMockGnetDashboardWithDownloads({ id: 2 }), + ]; const mockResponse = { page: 1, pages: 5, @@ -93,7 +126,7 @@ describe('dashboardLibraryApi', () => { const result = await fetchCommunityDashboards(defaultFetchParams); expect(mockGet).toHaveBeenCalledWith( - '/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true', + '/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true&includePanelTypeSlugs=true', undefined, undefined, { showErrorAlert: false } @@ -154,7 +187,7 @@ describe('dashboardLibraryApi', () => { }); it('should use fallback values when page/pages are missing', async () => { - const items = [createMockGnetDashboard()]; + const items = [createMockGnetDashboardWithDownloads()]; mockGet.mockResolvedValue({ items, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts index ac74a089f66..3563033a33e 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -2,7 +2,35 @@ import { getBackendSrv } from '@grafana/runtime'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { PluginDashboard } from 'app/types/plugins'; -import { GnetDashboardsResponse, Link } from '../types'; +import { GnetDashboard, GnetDashboardsResponse, Link } from '../types'; + +/** + * Panel types that are known to allow JavaScript code execution. + * These panels are filtered out due to security concerns. + */ +const UNSAFE_PANEL_TYPE_SLUGS = [ + 'aceiot-svg-panel', + 'ae3e-plotly-panel', + 'gapit-htmlgraphics-panel', + 'marcusolsson-dynamictext-panel', + 'volkovlabs-echarts-panel', + 'volkovlabs-form-panel', +]; + +/** + * Minimum number of downloads required for a community dashboard to be shown as a suggestion. + * + * Rationale: + * - Dashboards with higher download counts have been vetted by a larger community + * - This acts as a heuristic for quality and trustworthiness + * - Reduces risk of malicious or poorly-maintained dashboards + * + * Trade-offs: + * - May filter out legitimate but less popular dashboards + * - Newer dashboards with good content but low download counts won't be shown + * - The threshold of 10,000 is somewhat arbitrary and may need tuning based on ecosystem growth + */ +const MIN_DOWNLOADS_FILTER = 10000; /** * Parameters for fetching community dashboards from Grafana.com @@ -56,6 +84,7 @@ export async function fetchCommunityDashboards( pageSize: params.pageSize.toString(), includeLogo: params.includeLogo ? '1' : '0', includeScreenshots: params.includeScreenshots ? 'true' : 'false', + includePanelTypeSlugs: 'true', }); if (params.dataSourceSlugIn) { @@ -69,13 +98,13 @@ export async function fetchCommunityDashboards( showErrorAlert: false, }); - // Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] } - // We normalize it to use "dashboards" instead of "items" for consistency if (result && Array.isArray(result.items)) { + const dashboards = filterNonSafeDashboards(result.items); + return { page: result.page || params.page, pages: result.pages || 1, - items: result.items, + items: dashboards, }; } @@ -109,3 +138,20 @@ export async function fetchProvisionedDashboards(datasourceType: string): Promis return []; } } + +// We only show dashboards with at least MIN_DOWNLOADS_FILTER downloads +// They are already ordered by downloads amount +const filterNonSafeDashboards = (dashboards: GnetDashboard[]): GnetDashboard[] => { + return dashboards.filter((item: GnetDashboard) => { + const hasUnsafePanelTypes = item.panelTypeSlugs?.some((slug: string) => UNSAFE_PANEL_TYPE_SLUGS.includes(slug)); + const hasLowDownloads = typeof item.downloads === 'number' && item.downloads < MIN_DOWNLOADS_FILTER; + + if (hasUnsafePanelTypes || hasLowDownloads) { + console.warn( + `Community dashboard ${item.id} ${item.name} filtered out due to low downloads ${item.downloads} or panel types ${item.panelTypeSlugs?.join(', ')} that can embed JavaScript` + ); + return false; + } + return true; + }); +}; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 804ef885d61..079dab20b69 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -8,6 +8,7 @@ export const EVENT_LOCATIONS = { MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab', MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab', BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page', + COMMUNITY_DASHBOARD_LOADED: 'community_dashboard_loaded', } as const; export const CONTENT_KINDS = { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts index 784e4f2d924..ac50627398e 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts @@ -24,6 +24,7 @@ export interface GnetDashboard { id: number; name: string; description: string; + slug: string; downloads: number; datasource: string; screenshots?: Screenshot[]; @@ -38,6 +39,7 @@ export interface GnetDashboard { orgSlug?: string; userId?: number; userName?: string; + panelTypeSlugs?: string[]; } export interface GnetDashboardsResponse { 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 58e77b7d13f..8a4c2c3c695 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts @@ -11,7 +11,6 @@ import { InputMapping, tryAutoMapDatasources, parseConstantInputs } from './auto import { buildDashboardDetails, buildGrafanaComUrl, - createSlug, getLogoUrl, navigateToTemplate, onUseCommunityDashboard, @@ -27,6 +26,14 @@ jest.mock('./autoMapDatasources', () => ({ parseConstantInputs: jest.fn(), })); +jest.mock('../interactions', () => ({ + ...jest.requireActual('../interactions'), + DashboardLibraryInteractions: { + ...jest.requireActual('../interactions').DashboardLibraryInteractions, + communityDashboardFiltered: jest.fn(), + }, +})); + // Mock function references const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction; const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction; @@ -43,6 +50,7 @@ const createMockGnetDashboard = (overrides: Partial = {}): GnetDa publishedAt: '', updatedAt: '2025-11-05T16:55:41.000Z', downloads: 0, + slug: 'test-dashboard', ...overrides, }); @@ -61,25 +69,11 @@ const createMockDashboardJson = (overrides: Partial = {}): Dashbo }) as DashboardJson; describe('communityDashboardHelpers', () => { - describe('createSlug', () => { - it('should convert to lower case', () => { - expect(createSlug('Test')).toBe('test'); - }); - - it('should replace non-alphanumeric characters with hyphens', () => { - expect(createSlug('Test@#example')).toBe('test-example'); - }); - - it('should remove leading and trailing hyphens', () => { - expect(createSlug('-test-')).toBe('test'); - }); - }); - describe('buildGrafanaComUrl', () => { it('should build a valid URL', () => { const gnetDashboard = createMockGnetDashboard({ id: 1, - name: 'Test', + slug: 'test', }); expect(buildGrafanaComUrl(gnetDashboard)).toBe('https://grafana.com/grafana/dashboards/1-test/'); @@ -91,6 +85,7 @@ describe('communityDashboardHelpers', () => { const gnetDashboard = createMockGnetDashboard({ id: 1, name: 'Test', + slug: 'test', datasource: 'Test', orgName: 'Org', updatedAt: '2025-11-05T16:55:41.000Z', @@ -170,6 +165,10 @@ describe('communityDashboardHelpers', () => { }); describe('onUseCommunityDashboard', () => { + let consoleWarnSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let locationServicePushSpy: jest.SpyInstance; + async function setup(options?: { dashboard?: Partial; dashboardJson?: Partial; @@ -206,7 +205,16 @@ describe('communityDashboardHelpers', () => { } beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + locationServicePushSpy = jest.spyOn(locationService, 'push').mockImplementation(); + }); + + afterEach(() => { jest.clearAllMocks(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + locationServicePushSpy.mockRestore(); }); it('should navigate directly when all datasources are auto-mapped and no constants', async () => { @@ -218,8 +226,8 @@ describe('communityDashboardHelpers', () => { }, }); - expect(locationService.push).toHaveBeenCalled(); - expect(locationService.push).toHaveBeenCalledWith( + expect(locationServicePushSpy).toHaveBeenCalled(); + expect(locationServicePushSpy).toHaveBeenCalledWith( expect.objectContaining({ pathname: expect.any(String), search: expect.stringContaining('gnetId=123'), @@ -249,7 +257,7 @@ describe('communityDashboardHelpers', () => { }); expect(mockOnShowMapping).toHaveBeenCalled(); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); expect(mockOnShowMapping).toHaveBeenCalledWith( expect.objectContaining({ dashboardName: 'Test Dashboard', @@ -281,7 +289,7 @@ describe('communityDashboardHelpers', () => { }); expect(mockOnShowMapping).toHaveBeenCalled(); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); expect(mockOnShowMapping).toHaveBeenCalledWith( expect.objectContaining({ dashboardName: 'Test Dashboard', @@ -294,17 +302,312 @@ describe('communityDashboardHelpers', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); mockFetchCommunityDashboard.mockRejectedValue(new Error('API failed')); - await onUseCommunityDashboard({ - dashboard: createMockGnetDashboard(), - datasourceUid: 'test-ds-uid', - datasourceType: 'prometheus', - eventLocation: 'empty_dashboard', - }); + await expect( + onUseCommunityDashboard({ + dashboard: createMockGnetDashboard(), + datasourceUid: 'test-ds-uid', + datasourceType: 'prometheus', + eventLocation: 'empty_dashboard', + }) + ).rejects.toThrow('API failed'); expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); + + describe('when the dashboard contains JavaScript code', () => { + it('should throw an error if the dashboard contains JavaScript code in options', async () => { + const dashboardJson = createMockDashboardJson({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + panels: [{ type: 'panel', options: { template: '{{ javascript:alert("XSS") }}' } } as any], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in targets/queries', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + targets: [ + { + expr: 'function() { return bad(); }', + refId: 'A', + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in transformations', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + transformations: [ + { + id: 'calculateField', + options: { + mode: 'binary', + binary: { + reducer: 'sum', + left: 'A', + right: 'B', + }, + replaceFields: false, + alias: 'function() { alert("XSS"); }', + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in fieldConfig', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + fieldConfig: { + defaults: { + custom: { + displayMode: 'function() { return "bad"; }', + }, + }, + overrides: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains javascript: URLs in links', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + links: [ + { + title: 'Bad Link', + url: 'javascript:alert("XSS")', + targetBlank: false, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains ', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains arrow functions', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + customCode: '() => { alert("XSS"); }', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains setTimeout or setInterval', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + handler: 'setTimeout(() => alert("XSS"), 1000)', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like beforeRender', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + beforeRender: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like afterRender', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + afterRender: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like handler', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + handler: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains return statements', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + customLogic: 'function test() { return malicious(); }', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains event handlers like onclick', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + html: '
Click me
', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 44c579d27b5..05c20ee1d9f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -1,5 +1,11 @@ +import { PanelModel } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { 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'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; +import { dispatch } from 'app/types/store'; import { DASHBOARD_LIBRARY_ROUTES } from '../../types'; import { MappingContext } from '../SuggestedDashboardsModal'; @@ -9,6 +15,12 @@ import { GnetDashboard, Link } from '../types'; import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources'; +// Constants for community dashboard pagination and API params +// We want to get the most 6 downloaded dashboards, but we first query 12 +// to be sure the next filters we apply to that list doesn not reduce it below 6 +export const COMMUNITY_PAGE_SIZE_QUERY = 12; +export const COMMUNITY_RESULT_SIZE = 6; + /** * Extract thumbnail URL from dashboard screenshots */ @@ -39,21 +51,11 @@ export function formatDate(dateString?: string): string { return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } -/** - * Create URL-friendly slug from dashboard name - */ -export function createSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - /** * Build Grafana.com URL for a dashboard */ export function buildGrafanaComUrl(dashboard: GnetDashboard): string { - return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`; + return `https://grafana.com/grafana/dashboards/${dashboard.id}-${dashboard.slug}/`; } /** @@ -121,12 +123,110 @@ interface UseCommunityDashboardParams { onShowMapping?: (context: MappingContext) => void; } +/** + * Check if a panel contains JavaScript code using heuristic pattern matching. + * + * IMPORTANT: This is a heuristic-based detection, not a perfect mechanism. + * + * Patterns checked: + * - HTML/Script tags: Direct XSS attack vectors + * - Event handlers: Common JS injection points (onclick, onload, etc.) + * - Function declarations: Actual executable code patterns + * - eval/Function constructor: Dynamic code execution + * - setTimeout/setInterval: Deferred code execution + * + * What we DON'T check: + * - Panel title and description are excluded (already sanitized by Grafana's rendering layer) + * - Only the panel's options and configuration are scanned + * + * @param panel - The panel model to check + * @returns true if the panel might contain JavaScript code, false otherwise + */ +function canPanelContainJS(panel: PanelModel): boolean { + // Create a copy of the panel without title and description, as they are already sanitized + // This reduces false positives while still checking all other properties for JavaScript code + const { title, description, ...panelWithoutSanitizedFields } = panel; + + let panelJson: string; + try { + panelJson = JSON.stringify(panelWithoutSanitizedFields); + } catch (e) { + console.warn('Failed to stringify panel', e); + return true; + } + + // Patterns that indicate actual JavaScript code in values + const valuePatterns = [ + /\s*\{[^}]*\breturn\b/, // Arrow function with return statement: () => { return ... } + /\beval\s*\(/i, // eval() calls + /\bnew\s+Function\s*\(/i, // new Function() constructor + /\bsetTimeout\s*\(/i, // setTimeout calls + /\bsetInterval\s*\(/i, // setInterval calls + ]; + + // Patterns for suspicious JSON keys that might indicate JS hooks + const keyPatterns = [ + /"on[a-zA-Z]+"\s*:/, // Event handlers as keys (both camelCase and lowercase): "onClick": or "onclick": + /"beforeRender"\s*:/i, // beforeRender hook as JSON key + /"afterRender"\s*:/i, // afterRender hook as JSON key + /"javascript"\s*:/i, // "javascript" as a key + /"customCode"\s*:/i, // Common pattern for custom code injection + /"script"\s*:/i, // "script" as a JSON key + /"handler"\s*:/i, // "handler" as a JSON key - common for event handlers + ]; + + const hasSuspiciousValue = valuePatterns.some((pattern) => { + if (pattern.test(panelJson)) { + console.warn('Panel contains JavaScript code in value'); + return true; + } + return false; + }); + + const hasSuspiciousKey = keyPatterns.some((pattern) => { + if (pattern.test(panelJson)) { + console.warn('Panel contains JavaScript code in key'); + return true; + } + return false; + }); + + return hasSuspiciousValue || hasSuspiciousKey; +} + +function isPanelModel(panel: unknown): panel is PanelModel { + if (!panel || typeof panel !== 'object') { + return false; + } + return 'options' in panel && 'type' in panel; +} + +/** + * Check if a dashboard contains JavaScript code. This is not a perfect check, but good enough + * Used as a second filter after the first filter of panel types (see api/dashboardLibraryApi.ts) + */ +const canDashboardContainJS = (dashboard: DashboardJson): boolean => { + return dashboard.panels?.some((panel) => { + // Skip library panels - they don't have options/type and are already validated + if (isPanelModel(panel)) { + return canPanelContainJS(panel); + } + return false; + }); +}; + /** * Handles the flow when a user selects a community dashboard: * 1. Tracks analytics * 2. Fetches full dashboard JSON with __inputs - * 3. Attempts auto-mapping of datasources - * 4. Either navigates directly or shows mapping form + * 3. Filters out dashboards that contain JavaScript code due to security reasons + * 4. Attempts auto-mapping of datasources + * 5. Either navigates directly or shows mapping form */ export async function onUseCommunityDashboard({ dashboard, @@ -142,6 +242,10 @@ export async function onUseCommunityDashboard({ const fullDashboard = await fetchCommunityDashboard(dashboard.id); const dashboardJson = fullDashboard.json; + if (canDashboardContainJS(dashboardJson)) { + throw new Error(`Community dashboard ${dashboard.id} "${dashboard.name}" might contain JavaScript code`); + } + // Parse datasource requirements from __inputs const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || []; @@ -199,6 +303,11 @@ export async function onUseCommunityDashboard({ } } catch (err) { console.error('Error loading community dashboard:', err); - // TODO: Show error notification + dispatch( + notifyApp( + createErrorNotification(t('dashboard-library.community-error-title', 'Error loading community dashboard')) + ) + ); + throw err; } } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts new file mode 100644 index 00000000000..351fd4ab438 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts @@ -0,0 +1,34 @@ +import { PluginDashboard } from 'app/types/plugins'; + +import { GnetDashboard } from '../types'; + +export const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ + dashboardId: 1, + uid: 'dash-uid', + title: 'Test Provisioned Dashboard', + description: 'Test plugin dashboard', + path: 'dashboards/test.json', + pluginId: 'prometheus', + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + removed: false, + revision: 1, + slug: 'test-dashboard', + ...overrides, +}); + +export const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 123, + name: 'Test Dashboard', + description: 'Test description', + datasource: 'Prometheus', + orgName: 'Test Org', + userName: 'testuser', + publishedAt: '', + updatedAt: '', + downloads: 0, + slug: 'test-dashboard', + ...overrides, +}); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 99ed9b512a6..3883da6e44e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5799,7 +5799,8 @@ "community-empty-title": "No community dashboards found", "community-empty-title-with-datasource": "No {{datasourceType}} community dashboards found", "community-error": "Failed to load community dashboards. Please try again.", - "community-error-title": "Error loading community dashboards", + "community-error-description": "Failed to load community dashboard.", + "community-error-title": "Error loading community dashboard", "community-mapping-form": { "auto-mapped_one": "{{count}} datasources were automatically configured:", "auto-mapped_other": "{{count}} datasources were automatically configured:",