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:
co-authored by
nmarrs
alexandra vargas
parent
3d3b4dd213
commit
d44cab9eaf
+1
@@ -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()}`;
|
||||
|
||||
+125
@@ -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();
|
||||
});
|
||||
});
|
||||
+86
-86
@@ -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',
|
||||
|
||||
+273
@@ -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={{
|
||||
|
||||
+101
@@ -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();
|
||||
});
|
||||
});
|
||||
+61
-28
@@ -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 {
|
||||
|
||||
+330
-27
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+123
-14
@@ -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,
|
||||
});
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user