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 <nathanielmarrs@gmail.com>
Co-authored-by: alexandra vargas <alexa1866@gmail.com>
This commit is contained in:
Juan Cabanas
2026-01-06 10:38:15 +01:00
committed by GitHub
co-authored by nmarrs alexandra vargas
parent 3d3b4dd213
commit d44cab9eaf
16 changed files with 1423 additions and 227 deletions
@@ -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()}`;
@@ -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<typeof fetchCommunityDashboards>;
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction<typeof onUseCommunityDashboard>;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
name: 'Test Dashboard',
description: 'Test Description',
downloads: 2000,
datasource: 'Prometheus',
slug: 'test-dashboard',
...overrides,
});
const setup = async (
props: Partial<React.ComponentProps<typeof CommunityDashboardSection>> = {},
successScenario = true
) => {
const renderResult = render(
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="test" {...props} />,
{
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();
});
});
@@ -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 (
<Stack direction="column" gap={2} height="100%">
{isPreviewDashboardError && (
<div>
<Alert
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
severity="error"
>
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
</Alert>
</div>
)}
<FilterInput
placeholder={
datasourceType
@@ -183,7 +187,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
lg: 3,
}}
>
{Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => (
{Array.from({ length: COMMUNITY_RESULT_SIZE }).map((_, i) => (
<DashboardCard.Skeleton key={`skeleton-${i}`} />
))}
</Grid>
@@ -197,7 +201,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
Failed to load community dashboards. Please try again.
</Trans>
</Alert>
<Button variant="secondary" onClick={() => setCurrentPage(1)}>
<Button variant="secondary" onClick={retry}>
<Trans i18nKey="dashboard-library.retry">Retry</Trans>
</Button>
</Stack>
@@ -233,42 +237,47 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
)}
</EmptyState>
) : (
<Grid
gap={4}
columns={{
xs: 1,
sm: dashboards.length >= 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);
<Stack direction="column" gap={2}>
<Grid
gap={4}
columns={{
xs: 1,
sm: dashboards.length >= 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 (
<DashboardCard
key={dashboard.id}
title={dashboard.name}
imageUrl={imageUrl}
dashboard={dashboard}
onClick={() => onPreviewCommunityDashboard(dashboard)}
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
/>
);
})}
</Grid>
return (
<DashboardCard
key={dashboard.id}
title={dashboard.name}
imageUrl={imageUrl}
dashboard={dashboard}
onClick={() => onPreviewCommunityDashboard(dashboard)}
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
/>
);
})}
</Grid>
<Box display="flex" justifyContent="end" gap={2} paddingRight={1.5}>
<Button
variant="secondary"
onClick={() => window.open('https://grafana.com/grafana/dashboards/', '_blank')}
>
<Trans i18nKey="dashboard-library.browse-grafana-com">Browse Grafana.com</Trans>
</Button>
</Box>
</Stack>
)}
</div>
{totalPages > 1 && (
<div className={styles.paginationWrapper}>
<Pagination currentPage={currentPage} numberOfPages={totalPages} onNavigate={setCurrentPage} />
</div>
)}
</Stack>
);
};
@@ -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),
}),
};
}
@@ -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> = {}): 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> = {}): 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',
@@ -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 }) => (
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
{title}
</div>
);
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
return {
DashboardCard: Object.assign(DashboardCardComponent, {
Skeleton: DashboardCardSkeleton,
}),
};
});
const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction<
typeof fetchProvisionedDashboards
>;
const mockLocationServicePush = locationService.push as jest.MockedFunction<typeof locationService.push>;
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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(<DashboardLibrarySection />, {
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',
});
});
});
});
@@ -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: () => <div data-testid="suggested-dashboards-modal">Modal</div>,
}));
jest.mock('./DashboardCard', () => {
const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => (
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
{title}
</div>
);
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
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<typeof fetchCommunityDashboards>;
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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(<SuggestedDashboards datasourceUid="test-uid" />);
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();
});
});
});
@@ -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) => {
</Button>
)}
</div>
{isPreviewCommunityDashboardError && (
<div>
<Alert
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
severity="error"
>
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
</Alert>
</div>
)}
<Grid
gap={4}
columns={{
@@ -0,0 +1,101 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { SuggestedDashboardsModal } from './SuggestedDashboardsModal';
import { CONTENT_KINDS, EVENT_LOCATIONS } from './interactions';
jest.mock('./DashboardLibrarySection', () => ({
DashboardLibrarySection: () => <div data-testid="dashboard-library-section">Dashboard Library Section</div>,
}));
jest.mock('./CommunityDashboardSection', () => ({
CommunityDashboardSection: () => <div data-testid="community-dashboard-section">Community Dashboard Section</div>,
}));
jest.mock('./CommunityDashboardMappingForm', () => ({
CommunityDashboardMappingForm: () => (
<div data-testid="community-dashboard-mapping-form">Community Dashboard Mapping Form</div>
),
}));
describe('SuggestedDashboardsModal', () => {
const defaultProps = {
isOpen: true,
onDismiss: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render when isOpen is true', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
render(<SuggestedDashboardsModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should render both tabs: Data-source provided and Community', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
expect(screen.getByRole('tab', { name: 'Data-source provided' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Community' })).toBeInTheDocument();
});
it('should render tablist with both tabs', () => {
render(<SuggestedDashboardsModal {...defaultProps} />);
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(<SuggestedDashboardsModal {...defaultProps} defaultTab="datasource" />);
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(<SuggestedDashboardsModal {...defaultProps} defaultTab="community" />);
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(
<SuggestedDashboardsModal
{...defaultProps}
initialMappingContext={{
dashboardName: 'Test Dashboard',
dashboardJson: { title: 'Test Dashboard', panels: [], schemaVersion: 41 } as DashboardJson,
unmappedDsInputs: [],
constantInputs: [],
existingMappings: [],
onInterpolateAndNavigate: jest.fn(),
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
}}
/>
);
expect(screen.getByTestId('community-dashboard-mapping-form')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument();
});
});
@@ -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<typeof getBackendSrv>;
// Helper to create mock BackendSrv
@@ -26,31 +35,9 @@ const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =
}) as unknown as BackendSrv;
// Helper functions for creating mock objects
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
name: 'Test Dashboard',
description: 'Test Description',
downloads: 100,
datasource: 'Prometheus',
...overrides,
});
const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): 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> = {}): 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,
@@ -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;
});
};
@@ -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 = {
@@ -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 {
@@ -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<typeof fetchCommunityDashboard>;
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction<typeof tryAutoMapDatasources>;
@@ -43,6 +50,7 @@ const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDa
publishedAt: '',
updatedAt: '2025-11-05T16:55:41.000Z',
downloads: 0,
slug: 'test-dashboard',
...overrides,
});
@@ -61,25 +69,11 @@ const createMockDashboardJson = (overrides: Partial<DashboardJson> = {}): 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<GnetDashboard>;
dashboardJson?: Partial<DashboardJson>;
@@ -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 <script> tags in any property', async () => {
const dashboardJson = createMockDashboardJson({
panels: [
{
type: 'panel',
options: {
content: '<script>alert("XSS")</script>',
},
// 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: '<div onclick="alert(\'XSS\')">Click me</div>',
},
// 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();
});
});
});
});
@@ -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 = [
/<script\b/i, // HTML script tags
/\bon\w+\s*=\s*/i, // HTML event handlers: onclick=, onload=, etc.
/\bjavascript\s*:/i,
/\bfunction\s*\(/, // Anonymous function declarations: function(
/\bfunction\s+[\w$]+\s*\(/, // Named function declarations: function name(
/=>\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;
}
}
@@ -0,0 +1,34 @@
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboard } from '../types';
export const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): 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> = {}): GnetDashboard => ({
id: 123,
name: 'Test Dashboard',
description: 'Test description',
datasource: 'Prometheus',
orgName: 'Test Org',
userName: 'testuser',
publishedAt: '',
updatedAt: '',
downloads: 0,
slug: 'test-dashboard',
...overrides,
});
+2 -1
View File
@@ -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:",