Alerting: Add dashboard and panel picker to the rule form (#58304)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2022-11-23 10:27:49 +01:00 committed by GitHub
parent 245a59548c
commit cae5d89d0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 639 additions and 18 deletions

View File

@ -0,0 +1,22 @@
import { DashboardDTO } from '../../../../types';
import { DashboardSearchItem } from '../../../search/types';
import { alertingApi } from './alertingApi';
export const dashboardApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
search: build.query<DashboardSearchItem[], { query?: string }>({
query: ({ query }) => {
const params = new URLSearchParams({ type: 'dash-db', limit: '1000', page: '1', sort: 'name_sort' });
if (query) {
params.set('query', query);
}
return { url: `/api/search?${params.toString()}` };
},
}),
dashboard: build.query<DashboardDTO, { uid: string }>({
query: ({ uid }) => ({ url: `/api/dashboards/uid/${uid}` }),
}),
}),
});

View File

@ -0,0 +1,276 @@
import { findByText, findByTitle, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { byRole, byTestId } from 'testing-library-selector';
import { setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardDTO } from '../../../../../types';
import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types';
import { mockStore } from '../../mocks';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation } from '../../utils/constants';
import { getDefaultFormValues } from '../../utils/rule-form';
import 'whatwg-fetch';
import AnnotationsField from './AnnotationsField';
// To get anything displayed inside the Autosize component we need to mock it
// Ref https://github.com/bvaughn/react-window/issues/454#issuecomment-646031139
jest.mock(
'react-virtualized-auto-sizer',
() =>
({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) =>
children({ height: 500, width: 330 })
);
const ui = {
setDashboardButton: byRole('button', { name: 'Set dashboard and panel' }),
annotationKeys: byTestId('annotation-key-', { exact: false }),
annotationValues: byTestId('annotation-value-', { exact: false }),
dashboardPicker: {
dialog: byRole('dialog'),
heading: byRole('heading', { name: 'Select dashboard and panel' }),
confirmButton: byRole('button', { name: 'Confirm' }),
},
} as const;
const server = setupServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
beforeEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
function FormWrapper({ formValues }: { formValues?: Partial<RuleFormValues> }) {
const store = mockStore(() => null);
const formApi = useForm<RuleFormValues>({ defaultValues: { ...getDefaultFormValues(), ...formValues } });
return (
<Provider store={store}>
<FormProvider {...formApi}>
<AnnotationsField />
</FormProvider>
</Provider>
);
}
describe('AnnotationsField', function () {
it('should display default list of annotations', function () {
render(<FormWrapper />);
const annotationElements = ui.annotationKeys.getAll();
expect(annotationElements).toHaveLength(3);
expect(annotationElements[0]).toHaveTextContent('Summary');
expect(annotationElements[1]).toHaveTextContent('Description');
expect(annotationElements[2]).toHaveTextContent('Runbook URL');
});
describe('Dashboard and panel picker', function () {
it('should display dashboard and panel selector when select button clicked', async function () {
mockSearchResponse([]);
const user = userEvent.setup();
render(<FormWrapper />);
await user.click(ui.setDashboardButton.get());
expect(ui.dashboardPicker.dialog.get()).toBeInTheDocument();
expect(ui.dashboardPicker.heading.get()).toBeInTheDocument();
});
it('should enable Confirm button only when dashboard and panel selected', async function () {
mockSearchResponse([
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
]);
mockGetDashboardResponse(
mockDashboardDto({
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
],
})
);
const user = userEvent.setup();
render(<FormWrapper />);
await user.click(ui.setDashboardButton.get());
expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled();
await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard'));
expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled();
await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'First panel'));
expect(ui.dashboardPicker.confirmButton.get()).toBeEnabled();
});
it('should add selected dashboard and panel as annotations', async function () {
mockSearchResponse([
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
]);
mockGetDashboardResponse(
mockDashboardDto({
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
],
})
);
const user = userEvent.setup();
render(<FormWrapper formValues={{ annotations: [] }} />);
await user.click(ui.setDashboardButton.get());
await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard'));
await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Second panel'));
await user.click(ui.dashboardPicker.confirmButton.get());
const annotationKeyElements = ui.annotationKeys.getAll();
const annotationValueElements = ui.annotationValues.getAll();
expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument();
expect(annotationKeyElements).toHaveLength(2);
expect(annotationValueElements).toHaveLength(2);
expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID');
expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid');
expect(annotationKeyElements[1]).toHaveTextContent('Panel ID');
expect(annotationValueElements[1]).toHaveTextContent('2');
});
// this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item
// to trigger "handleDashboardChange" skipping it for now but has been manually tested.
it.skip('should update existing dashboard and panel identifies', async function () {
mockSearchResponse([
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
mockDashboardSearchItem({
title: 'My other dashboard',
uid: 'dash-other-uid',
type: DashboardSearchItemType.DashDB,
}),
]);
mockGetDashboardResponse(
mockDashboardDto({
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
],
})
);
mockGetDashboardResponse(
mockDashboardDto({
title: 'My other dashboard',
uid: 'dash-other-uid',
panels: [{ id: 3, title: 'Third panel' }],
})
);
const user = userEvent.setup();
render(
<FormWrapper
formValues={{
annotations: [
{ key: Annotation.dashboardUID, value: 'dash-test-uid' },
{ key: Annotation.panelID, value: '1' },
],
}}
/>
);
let annotationValueElements = ui.annotationValues.getAll();
expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid');
expect(annotationValueElements[1]).toHaveTextContent('1');
await user.click(ui.setDashboardButton.get());
await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My other dashboard'));
await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Third panel'));
await user.click(ui.dashboardPicker.confirmButton.get());
expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument();
const annotationKeyElements = ui.annotationKeys.getAll();
annotationValueElements = ui.annotationValues.getAll();
expect(annotationKeyElements).toHaveLength(2);
expect(annotationValueElements).toHaveLength(2);
expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID');
expect(annotationValueElements[0]).toHaveTextContent('dash-other-uid');
expect(annotationKeyElements[1]).toHaveTextContent('Panel ID');
expect(annotationValueElements[1]).toHaveTextContent('3');
});
});
});
function mockSearchResponse(searchResult: DashboardSearchItem[]) {
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json<DashboardSearchItem[]>(searchResult))));
}
function mockGetDashboardResponse(dashboard: DashboardDTO) {
server.use(
rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) =>
res(ctx.json<DashboardDTO>(dashboard))
)
);
}
function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) {
return {
title: '',
uid: '',
type: DashboardSearchItemType.DashDB,
url: '',
uri: '',
items: [],
tags: [],
isStarred: false,
...searchItem,
};
}
function mockDashboardDto(dashboard: Partial<DashboardDTO['dashboard']>) {
return {
dashboard: {
title: '',
uid: '',
templating: { list: [] },
panels: [],
...dashboard,
},
meta: {},
};
}

View File

@ -1,21 +1,29 @@
import { css, cx } from '@emotion/css';
import produce from 'immer';
import React, { useCallback } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation } from '../../utils/constants';
import { AnnotationKeyInput } from './AnnotationKeyInput';
import { DashboardPicker } from './DashboardPicker';
const AnnotationsField = () => {
const styles = useStyles2(getStyles);
const [showPanelSelector, setShowPanelSelector] = useToggle(false);
const {
control,
register,
watch,
formState: { errors },
setValue,
} = useFormContext<RuleFormValues>();
const annotations = watch('annotations');
@ -26,6 +34,31 @@ const AnnotationsField = () => {
const { fields, append, remove } = useFieldArray({ control, name: 'annotations' });
const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value;
const selectedPanelId = annotations.find((annotation) => annotation.key === Annotation.panelID)?.value;
const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => {
const updatedAnnotations = produce(annotations, (draft) => {
const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID);
const panelAnnotation = draft.find((a) => a.key === Annotation.panelID);
if (dashboardAnnotation) {
dashboardAnnotation.value = dashboardUid;
} else {
draft.push({ key: Annotation.dashboardUID, value: dashboardUid });
}
if (panelAnnotation) {
panelAnnotation.value = panelId;
} else {
draft.push({ key: Annotation.panelID, value: panelId });
}
});
setValue('annotations', updatedAnnotations);
setShowPanelSelector(false);
};
return (
<>
<Label>Summary and annotations</Label>
@ -81,17 +114,31 @@ const AnnotationsField = () => {
</div>
);
})}
<Button
className={styles.addAnnotationsButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
>
Add info
</Button>
<Stack direction="row" gap={1}>
<Button
className={styles.addAnnotationsButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
>
Add annotation
</Button>
<Button type="button" variant="secondary" icon="dashboard" onClick={() => setShowPanelSelector(true)}>
Set dashboard and panel
</Button>
</Stack>
{showPanelSelector && (
<DashboardPicker
isOpen={true}
dashboardUid={selectedDashboardUid}
panelId={selectedPanelId}
onChange={setSelectedDashboardAndPanelId}
onDismiss={() => setShowPanelSelector(false)}
/>
)}
</div>
</>
);

View File

@ -0,0 +1,280 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, useCallback, useMemo, useState } from 'react';
import { useDebounce } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data/src';
import { FilterInput, LoadingPlaceholder, useStyles2, Icon, Modal, Button, Alert } from '@grafana/ui';
import { dashboardApi } from '../../api/dashboardApi';
export interface PanelDTO {
id: number;
title?: string;
}
function panelSort(a: PanelDTO, b: PanelDTO) {
if (a.title && b.title) {
return a.title.localeCompare(b.title);
}
if (a.title && !b.title) {
return 1;
} else if (!a.title && b.title) {
return -1;
}
return 0;
}
interface DashboardPickerProps {
isOpen: boolean;
dashboardUid?: string | undefined;
panelId?: string | undefined;
onChange: (dashboardUid: string, panelId: string) => void;
onDismiss: () => void;
}
export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDismiss }: DashboardPickerProps) => {
const styles = useStyles2(getPickerStyles);
const [selectedDashboardUid, setSelectedDashboardUid] = useState(dashboardUid);
const [selectedPanelId, setSelectedPanelId] = useState(panelId);
const [dashboardFilter, setDashboardFilter] = useState('');
const [debouncedDashboardFilter, setDebouncedDashboardFilter] = useState('');
const [panelFilter, setPanelFilter] = useState('');
const { useSearchQuery, useDashboardQuery } = dashboardApi;
const { currentData: filteredDashboards = [], isFetching: isDashSearchFetching } = useSearchQuery({
query: debouncedDashboardFilter,
});
const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery(
{ uid: selectedDashboardUid ?? '' },
{ skip: !selectedDashboardUid }
);
const handleDashboardChange = useCallback((dashboardUid: string) => {
setSelectedDashboardUid(dashboardUid);
setSelectedPanelId(undefined);
}, []);
const filteredPanels =
dashboardResult?.dashboard?.panels
?.filter((panel): panel is PanelDTO => typeof panel.id === 'number')
?.filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase()))
.sort(panelSort) ?? [];
const currentPanel = dashboardResult?.dashboard?.panels?.find((panel) => panel.id.toString() === selectedPanelId);
const selectedDashboardIndex = useMemo(() => {
return filteredDashboards.map((dashboard) => dashboard.uid).indexOf(selectedDashboardUid ?? '');
}, [filteredDashboards, selectedDashboardUid]);
const isDefaultSelection = dashboardUid && dashboardUid === selectedDashboardUid;
const selectedDashboardIsInPageResult = selectedDashboardIndex >= 0;
const scrollToItem = useCallback(
(node) => {
const canScroll = selectedDashboardIndex >= 0;
if (isDefaultSelection && canScroll) {
node?.scrollToItem(selectedDashboardIndex, 'smart');
}
},
[isDefaultSelection, selectedDashboardIndex]
);
useDebounce(
() => {
setDebouncedDashboardFilter(dashboardFilter);
},
500,
[dashboardFilter]
);
const DashboardRow = ({ index, style }: { index: number; style?: CSSProperties }) => {
const dashboard = filteredDashboards[index];
const isSelected = selectedDashboardUid === dashboard.uid;
return (
<div
title={dashboard.title}
style={style}
className={cx(styles.row, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
onClick={() => handleDashboardChange(dashboard.uid)}
>
<div className={styles.dashboardTitle}>{dashboard.title}</div>
<div className={styles.dashboardFolder}>
<Icon name="folder" /> {dashboard.folderTitle ?? 'General'}
</div>
</div>
);
};
const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => {
const panel = filteredPanels[index];
const isSelected = selectedPanelId === panel.id.toString();
return (
<div
style={style}
className={cx(styles.row, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
onClick={() => setSelectedPanelId(panel.id.toString())}
>
{panel.title || '<No title>'}
</div>
);
};
return (
<Modal
title="Select dashboard and panel"
closeOnEscape
isOpen={isOpen}
onDismiss={onDismiss}
className={styles.modal}
contentClassName={styles.modalContent}
>
{/* This alert shows if the selected dashboard is not found in the first page of dashboards */}
{!selectedDashboardIsInPageResult && dashboardUid && (
<Alert title="Current selection" severity="info" topSpacing={0} bottomSpacing={1} className={styles.modalAlert}>
<div>
Dashboard: {dashboardResult?.dashboard.title} ({dashboardResult?.dashboard.uid}) in folder{' '}
{dashboardResult?.meta.folderTitle ?? 'General'}
</div>
{Boolean(currentPanel) && (
<div>
Panel: {currentPanel.title} ({currentPanel.id})
</div>
)}
</Alert>
)}
<div className={styles.container}>
<FilterInput
value={dashboardFilter}
onChange={setDashboardFilter}
title="Search dashboard"
placeholder="Search dashboard"
autoFocus
/>
<FilterInput value={panelFilter} onChange={setPanelFilter} title="Search panel" placeholder="Search panel" />
<div className={styles.column}>
{isDashSearchFetching && (
<LoadingPlaceholder text="Loading dashboards..." className={styles.loadingPlaceholder} />
)}
{!isDashSearchFetching && (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
ref={scrollToItem}
itemSize={50}
height={height}
width={width}
itemCount={filteredDashboards.length}
>
{DashboardRow}
</FixedSizeList>
)}
</AutoSizer>
)}
</div>
<div className={styles.column}>
{!dashboardUid && !isDashboardFetching && <div>Select a dashboard to get a list of available panels</div>}
{isDashboardFetching && (
<LoadingPlaceholder text="Loading dashboard..." className={styles.loadingPlaceholder} />
)}
{!isDashboardFetching && (
<AutoSizer>
{({ width, height }) => (
<FixedSizeList itemSize={32} height={height} width={width} itemCount={filteredPanels.length}>
{PanelRow}
</FixedSizeList>
)}
</AutoSizer>
)}
</div>
</div>
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onDismiss}>
Cancel
</Button>
<Button
type="button"
variant="primary"
disabled={!(selectedDashboardUid && selectedPanelId)}
onClick={() => {
if (selectedDashboardUid && selectedPanelId) {
onChange(selectedDashboardUid, selectedPanelId);
}
}}
>
Confirm
</Button>
</Modal.ButtonRow>
</Modal>
);
};
const getPickerStyles = (theme: GrafanaTheme2) => ({
container: css`
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content auto;
gap: ${theme.spacing(2)};
flex: 1;
`,
column: css`
flex: 1 1 auto;
`,
dashboardTitle: css`
height: 22px;
font-weight: ${theme.typography.fontWeightBold};
`,
dashboardFolder: css`
height: 20px;
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
display: flex;
flex-direction: row;
justify-content: flex-start;
column-gap: ${theme.spacing(1)};
align-items: center;
`,
row: css`
padding: ${theme.spacing(0.5)};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border: 2px solid transparent;
`,
rowSelected: css`
border-color: ${theme.colors.primary.border};
`,
rowOdd: css`
background-color: ${theme.colors.background.secondary};
`,
loadingPlaceholder: css`
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`,
modal: css`
height: 100%;
`,
modalContent: css`
flex: 1;
display: flex;
flex-direction: column;
`,
modalAlert: css`
flex-grow: 0;
`,
});

View File

@ -18,7 +18,7 @@ export function DetailsStep() {
<RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
title={
type === RuleFormType.cloudRecording ? 'Add details for your recording rule' : 'Add details for your alert'
type === RuleFormType.cloudRecording ? 'Add details for your recording rule' : 'Add details for your alert rule'
}
description={
type === RuleFormType.cloudRecording

View File

@ -6,7 +6,7 @@ import { Provider } from 'react-redux';
import 'whatwg-fetch';
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
@ -17,12 +17,8 @@ import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
const server = setupServer();
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getBackendSrv: () => backendSrv,
}));
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});