mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Change how we display annotations in the rule form (#69338)
* Change how we display annotations in the rule form * Allow to add custom annotations using a free text input * Get dashboard and panel titles to display in the annotations section * Add component to display dashboard and panel annotations as links * Add styling to help tooltip * Fix styling on annotations controls * Fix tests * Fix tests * Remove unused imports * Add component for custom annotations * Display default annotations even if editing and they're empty * Adjust tests * Make conditional rendering more clear * Fix tests * Move annotation header to separate component * Fix lint * Show annotation fields in the right order * Prevent showing custom annotation fields by default * Don't display links to dashboard/panel if response fails * Rename custom annotation header component * Fix after rebase
This commit is contained in:
parent
8f7e1f36ab
commit
929d9eaa91
@ -4,8 +4,6 @@ import React from 'react';
|
|||||||
import { Route } from 'react-router-dom';
|
import { Route } from 'react-router-dom';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
import { ui } from 'test/helpers/alertingRuleEditor';
|
import { ui } from 'test/helpers/alertingRuleEditor';
|
||||||
import { clickSelectOptionMatch } from 'test/helpers/selectOptionInTest';
|
|
||||||
import { byRole } from 'testing-library-selector';
|
|
||||||
|
|
||||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker';
|
import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker';
|
||||||
@ -153,8 +151,8 @@ describe('RuleEditor grafana managed rules', () => {
|
|||||||
expect(nameInput).toHaveValue('my great new rule');
|
expect(nameInput).toHaveValue('my great new rule');
|
||||||
//check that folder is in the list
|
//check that folder is in the list
|
||||||
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
||||||
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description');
|
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some summary');
|
||||||
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary');
|
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some description');
|
||||||
|
|
||||||
//check that slashed folders are not in the list
|
//check that slashed folders are not in the list
|
||||||
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
||||||
@ -170,9 +168,9 @@ describe('RuleEditor grafana managed rules', () => {
|
|||||||
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
|
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
|
||||||
|
|
||||||
// add an annotation
|
// add an annotation
|
||||||
await clickSelectOptionMatch(ui.inputs.annotationKey(2).get(), /Add new/);
|
await userEvent.click(screen.getByText('Add custom annotation'));
|
||||||
await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom');
|
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom');
|
||||||
await userEvent.type(ui.inputs.annotationValue(2).get(), 'value');
|
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value');
|
||||||
|
|
||||||
//add a label
|
//add a label
|
||||||
await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}');
|
await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}');
|
||||||
|
@ -257,11 +257,9 @@ describe('Receivers', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(ui.testContactPointModal.get()).toBeInTheDocument(), { timeout: 1000 });
|
await waitFor(() => expect(ui.testContactPointModal.get()).toBeInTheDocument(), { timeout: 1000 });
|
||||||
await userEvent.click(ui.customContactPointOption.get());
|
await userEvent.click(ui.customContactPointOption.get());
|
||||||
await waitFor(() => expect(ui.contactPointAnnotationSelect(0).get()).toBeInTheDocument());
|
|
||||||
|
|
||||||
// enter custom annotations and labels
|
// enter custom annotations and labels
|
||||||
await clickSelectOption(ui.contactPointAnnotationSelect(0).get(), 'Description');
|
await userEvent.type(screen.getByPlaceholderText('Enter a description...'), 'Test contact point');
|
||||||
await userEvent.type(ui.contactPointAnnotationValue(0).get(), 'Test contact point');
|
|
||||||
await userEvent.type(ui.contactPointLabelKey(0).get(), 'foo');
|
await userEvent.type(ui.contactPointLabelKey(0).get(), 'foo');
|
||||||
await userEvent.type(ui.contactPointLabelValue(0).get(), 'bar');
|
await userEvent.type(ui.contactPointLabelValue(0).get(), 'bar');
|
||||||
await userEvent.click(ui.testContactPoint.get());
|
await userEvent.click(ui.testContactPoint.get());
|
||||||
|
@ -7,6 +7,7 @@ import { Modal, Button, Label, useStyles2, RadioButtonGroup } from '@grafana/ui'
|
|||||||
import { TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
|
import { TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
|
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { defaultAnnotations } from '../../../utils/constants';
|
||||||
import AnnotationsField from '../../rule-editor/AnnotationsField';
|
import AnnotationsField from '../../rule-editor/AnnotationsField';
|
||||||
import LabelsField from '../../rule-editor/LabelsField';
|
import LabelsField from '../../rule-editor/LabelsField';
|
||||||
|
|
||||||
@ -34,7 +35,7 @@ enum NotificationType {
|
|||||||
const notificationOptions = Object.values(NotificationType).map((value) => ({ label: value, value: value }));
|
const notificationOptions = Object.values(NotificationType).map((value) => ({ label: value, value: value }));
|
||||||
|
|
||||||
const defaultValues: FormFields = {
|
const defaultValues: FormFields = {
|
||||||
annotations: [{ key: '', value: '' }],
|
annotations: [...defaultAnnotations],
|
||||||
labels: [{ key: '', value: '' }],
|
labels: [{ key: '', value: '' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -0,0 +1,78 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
import { FieldArrayWithId, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { InputControl, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { Annotation, annotationDescriptions, annotationLabels } from '../../utils/constants';
|
||||||
|
|
||||||
|
import CustomAnnotationHeaderField from './CustomAnnotationHeaderField';
|
||||||
|
|
||||||
|
const AnnotationHeaderField = ({
|
||||||
|
annotationField,
|
||||||
|
annotations,
|
||||||
|
annotation,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
annotationField: FieldArrayWithId<RuleFormValues, 'annotations', 'id'>;
|
||||||
|
annotations: Array<{ key: string; value: string }>;
|
||||||
|
annotation: Annotation;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const { control } = useFormContext<RuleFormValues>();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={styles.annotationContainer}>
|
||||||
|
{
|
||||||
|
<InputControl
|
||||||
|
name={`annotations.${index}.key`}
|
||||||
|
defaultValue={annotationField.key}
|
||||||
|
render={({ field: { ref, ...field } }) => {
|
||||||
|
switch (annotationField.key) {
|
||||||
|
case Annotation.dashboardUID:
|
||||||
|
return <div>Dashboard and panel</div>;
|
||||||
|
case Annotation.panelID:
|
||||||
|
return <span></span>;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{annotationLabels[annotation] && (
|
||||||
|
<span className={styles.annotationTitle} data-testid={`annotation-key-${index}`}>
|
||||||
|
{annotationLabels[annotation]}
|
||||||
|
{' (optional)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!annotationLabels[annotation] && <CustomAnnotationHeaderField field={field} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
control={control}
|
||||||
|
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<div className={styles.annotationDescription}>{annotationDescriptions[annotation]}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
annotationTitle: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
margin-bottom: 3px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationContainer: css`
|
||||||
|
margin-top: 5px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationDescription: css`
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AnnotationHeaderField;
|
@ -33,7 +33,7 @@ jest.mock(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
setDashboardButton: byRole('button', { name: 'Set dashboard and panel' }),
|
setDashboardButton: byRole('button', { name: 'Link dashboard and panel' }),
|
||||||
annotationKeys: byTestId('annotation-key-', { exact: false }),
|
annotationKeys: byTestId('annotation-key-', { exact: false }),
|
||||||
annotationValues: byTestId('annotation-value-', { exact: false }),
|
annotationValues: byTestId('annotation-value-', { exact: false }),
|
||||||
dashboardPicker: {
|
dashboardPicker: {
|
||||||
@ -154,18 +154,12 @@ describe('AnnotationsField', function () {
|
|||||||
|
|
||||||
await user.click(ui.dashboardPicker.confirmButton.get());
|
await user.click(ui.dashboardPicker.confirmButton.get());
|
||||||
|
|
||||||
const annotationKeyElements = ui.annotationKeys.getAll();
|
|
||||||
const annotationValueElements = ui.annotationValues.getAll();
|
const annotationValueElements = ui.annotationValues.getAll();
|
||||||
|
|
||||||
expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument();
|
expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(annotationKeyElements).toHaveLength(2);
|
|
||||||
expect(annotationValueElements).toHaveLength(2);
|
expect(annotationValueElements).toHaveLength(2);
|
||||||
|
|
||||||
expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID');
|
|
||||||
expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid');
|
expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid');
|
||||||
|
|
||||||
expect(annotationKeyElements[1]).toHaveTextContent('Panel ID');
|
|
||||||
expect(annotationValueElements[1]).toHaveTextContent('2');
|
expect(annotationValueElements[1]).toHaveTextContent('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
import { useToggle } from 'react-use';
|
import { useToggle } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui';
|
import { Button, Field, Input, TextArea, useStyles2 } from '@grafana/ui';
|
||||||
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { dashboardApi } from '../../api/dashboardApi';
|
||||||
import { RuleFormValues } from '../../types/rule-form';
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
import { Annotation } from '../../utils/constants';
|
import { Annotation, annotationLabels } from '../../utils/constants';
|
||||||
|
|
||||||
import { AnnotationKeyInput } from './AnnotationKeyInput';
|
import AnnotationHeaderField from './AnnotationHeaderField';
|
||||||
import { DashboardPicker } from './DashboardPicker';
|
import DashboardAnnotationField from './DashboardAnnotationField';
|
||||||
|
import { DashboardPicker, PanelDTO } from './DashboardPicker';
|
||||||
|
|
||||||
const AnnotationsField = () => {
|
const AnnotationsField = () => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
@ -27,16 +30,31 @@ const AnnotationsField = () => {
|
|||||||
} = useFormContext<RuleFormValues>();
|
} = useFormContext<RuleFormValues>();
|
||||||
const annotations = watch('annotations');
|
const annotations = watch('annotations');
|
||||||
|
|
||||||
const existingKeys = useCallback(
|
|
||||||
(index: number): string[] => annotations.filter((_, idx: number) => idx !== index).map(({ key }) => key),
|
|
||||||
[annotations]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'annotations' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'annotations' });
|
||||||
|
|
||||||
const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value;
|
const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value;
|
||||||
const selectedPanelId = annotations.find((annotation) => annotation.key === Annotation.panelID)?.value;
|
const selectedPanelId = annotations.find((annotation) => annotation.key === Annotation.panelID)?.value;
|
||||||
|
|
||||||
|
const [selectedDashboard, setSelectedDashboard] = useState<DashboardDataDTO | undefined>(undefined);
|
||||||
|
const [selectedPanel, setSelectedPanel] = useState<PanelDTO | undefined>(undefined);
|
||||||
|
|
||||||
|
const { useDashboardQuery } = dashboardApi;
|
||||||
|
|
||||||
|
const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery(
|
||||||
|
{ uid: selectedDashboardUid ?? '' },
|
||||||
|
{ skip: !selectedDashboardUid }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDashboardFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDashboard(dashboardResult?.dashboard);
|
||||||
|
const currentPanel = dashboardResult?.dashboard?.panels?.find((panel) => panel.id.toString() === selectedPanelId);
|
||||||
|
setSelectedPanel(currentPanel);
|
||||||
|
}, [selectedPanelId, dashboardResult, isDashboardFetching]);
|
||||||
|
|
||||||
const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => {
|
const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => {
|
||||||
const updatedAnnotations = produce(annotations, (draft) => {
|
const updatedAnnotations = produce(annotations, (draft) => {
|
||||||
const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID);
|
const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID);
|
||||||
@ -59,76 +77,104 @@ const AnnotationsField = () => {
|
|||||||
setShowPanelSelector(false);
|
setShowPanelSelector(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteDashboardAnnotation = () => {
|
||||||
|
const updatedAnnotations = annotations.filter(
|
||||||
|
(a) => a.key !== Annotation.dashboardUID && a.key !== Annotation.panelID
|
||||||
|
);
|
||||||
|
setValue('annotations', updatedAnnotations);
|
||||||
|
setSelectedDashboard(undefined);
|
||||||
|
setSelectedPanel(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditDashboardAnnotation = () => {
|
||||||
|
setShowPanelSelector(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label>Summary and annotations</Label>
|
|
||||||
<div className={styles.flexColumn}>
|
<div className={styles.flexColumn}>
|
||||||
{fields.map((annotationField, index) => {
|
{fields.map((annotationField, index: number) => {
|
||||||
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
|
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
|
||||||
const ValueInputComponent = isUrl ? Input : TextArea;
|
const ValueInputComponent = isUrl ? Input : TextArea;
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const annotation = annotationField.key as Annotation;
|
||||||
return (
|
return (
|
||||||
<div key={annotationField.id} className={styles.flexRow}>
|
<div key={annotationField.id} className={styles.flexRow}>
|
||||||
<Field
|
<div>
|
||||||
className={styles.field}
|
<AnnotationHeaderField
|
||||||
invalid={!!errors.annotations?.[index]?.key?.message}
|
annotationField={annotationField}
|
||||||
error={errors.annotations?.[index]?.key?.message}
|
annotations={annotations}
|
||||||
data-testid={`annotation-key-${index}`}
|
annotation={annotation}
|
||||||
>
|
index={index}
|
||||||
<InputControl
|
|
||||||
name={`annotations.${index}.key`}
|
|
||||||
defaultValue={annotationField.key}
|
|
||||||
render={({ field: { ref, ...field } }) => (
|
|
||||||
<AnnotationKeyInput
|
|
||||||
{...field}
|
|
||||||
aria-label={`Annotation detail ${index + 1}`}
|
|
||||||
existingKeys={existingKeys(index)}
|
|
||||||
width={18}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
control={control}
|
|
||||||
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
{selectedDashboardUid && selectedPanelId && annotationField.key === Annotation.dashboardUID && (
|
||||||
<Field
|
<DashboardAnnotationField
|
||||||
className={cx(styles.flexRowItemMargin, styles.field)}
|
dashboard={selectedDashboard}
|
||||||
invalid={!!errors.annotations?.[index]?.value?.message}
|
panel={selectedPanel}
|
||||||
error={errors.annotations?.[index]?.value?.message}
|
dashboardUid={selectedDashboardUid.toString()}
|
||||||
>
|
panelId={selectedPanelId.toString()}
|
||||||
<ValueInputComponent
|
onEditClick={handleEditDashboardAnnotation}
|
||||||
data-testid={`annotation-value-${index}`}
|
onDeleteClick={handleDeleteDashboardAnnotation}
|
||||||
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
|
/>
|
||||||
{...register(`annotations.${index}.value`)}
|
)}
|
||||||
placeholder={isUrl ? 'https://' : `Text`}
|
|
||||||
defaultValue={annotationField.value}
|
{
|
||||||
/>
|
<div className={styles.annotationValueContainer}>
|
||||||
</Field>
|
<Field
|
||||||
<Button
|
hidden={
|
||||||
type="button"
|
annotationField.key === Annotation.dashboardUID || annotationField.key === Annotation.panelID
|
||||||
className={styles.flexRowItemMargin}
|
}
|
||||||
aria-label="delete annotation"
|
className={cx(styles.flexRowItemMargin, styles.field)}
|
||||||
icon="trash-alt"
|
invalid={!!errors.annotations?.[index]?.value?.message}
|
||||||
variant="secondary"
|
error={errors.annotations?.[index]?.value?.message}
|
||||||
onClick={() => remove(index)}
|
>
|
||||||
/>
|
<ValueInputComponent
|
||||||
|
data-testid={`annotation-value-${index}`}
|
||||||
|
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
|
||||||
|
{...register(`annotations.${index}.value`)}
|
||||||
|
placeholder={
|
||||||
|
isUrl
|
||||||
|
? 'https://'
|
||||||
|
: (annotationField.key && `Enter a ${annotationField.key}...`) ||
|
||||||
|
'Enter custom annotation content...'
|
||||||
|
}
|
||||||
|
defaultValue={annotationField.value}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!annotationLabels[annotation] && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteAnnotationButton}
|
||||||
|
aria-label="delete annotation"
|
||||||
|
icon="trash-alt"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Stack direction="row" gap={1}>
|
<Stack direction="row" gap={1}>
|
||||||
<Button
|
<div className={styles.addAnnotationsButtonContainer}>
|
||||||
className={styles.addAnnotationsButton}
|
<Button
|
||||||
icon="plus-circle"
|
icon="plus"
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
append({ key: '', value: '' });
|
append({ key: '', value: '' });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add annotation
|
Add custom annotation
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" icon="dashboard" onClick={() => setShowPanelSelector(true)}>
|
{!selectedDashboard && (
|
||||||
Set dashboard and panel
|
<Button type="button" variant="secondary" icon="dashboard" onClick={() => setShowPanelSelector(true)}>
|
||||||
</Button>
|
Link dashboard and panel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
{showPanelSelector && (
|
{showPanelSelector && (
|
||||||
<DashboardPicker
|
<DashboardPicker
|
||||||
@ -151,10 +197,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
textarea: css`
|
textarea: css`
|
||||||
height: 76px;
|
height: 76px;
|
||||||
`,
|
`,
|
||||||
addAnnotationsButton: css`
|
addAnnotationsButtonContainer: css`
|
||||||
flex-grow: 0;
|
margin-top: ${theme.spacing(1)};
|
||||||
align-self: flex-start;
|
gap: ${theme.spacing(1)};
|
||||||
margin-left: 148px;
|
display: flex;
|
||||||
`,
|
`,
|
||||||
flexColumn: css`
|
flexColumn: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -169,7 +215,29 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
`,
|
`,
|
||||||
flexRowItemMargin: css`
|
flexRowItemMargin: css`
|
||||||
margin-left: ${theme.spacing(0.5)};
|
margin-top: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
deleteAnnotationButton: css`
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationTitle: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
margin-bottom: 3px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationContainer: css`
|
||||||
|
margin-top: 5px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationDescription: css`
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
`,
|
||||||
|
|
||||||
|
annotationValueContainer: css`
|
||||||
|
display: flex;
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Input, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
interface CustomAnnotationHeaderFieldProps {
|
||||||
|
field: { onChange: () => void; onBlur: () => void; value: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomAnnotationHeaderField = ({ field }: CustomAnnotationHeaderFieldProps) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className={styles.annotationTitle}>Custom annotation name and content</span>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter custom annotation name..."
|
||||||
|
width={18}
|
||||||
|
{...field}
|
||||||
|
className={styles.customAnnotationInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
annotationTitle: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
margin-bottom: 3px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
customAnnotationInput: css`
|
||||||
|
margin-top: 5px;
|
||||||
|
width: 100%;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CustomAnnotationHeaderField;
|
@ -0,0 +1,84 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { makeDashboardLink, makePanelLink } from '../../utils/misc';
|
||||||
|
|
||||||
|
import { PanelDTO } from './DashboardPicker';
|
||||||
|
|
||||||
|
const DashboardAnnotationField = ({
|
||||||
|
dashboard,
|
||||||
|
panel,
|
||||||
|
dashboardUid,
|
||||||
|
panelId,
|
||||||
|
onEditClick,
|
||||||
|
onDeleteClick,
|
||||||
|
}: {
|
||||||
|
dashboard?: DashboardDataDTO;
|
||||||
|
panel?: PanelDTO;
|
||||||
|
dashboardUid: string; //fallback
|
||||||
|
panelId: string; //fallback
|
||||||
|
onEditClick: () => void;
|
||||||
|
onDeleteClick: () => void;
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const dashboardLink = makeDashboardLink(dashboard?.uid || dashboardUid);
|
||||||
|
const panelLink = makePanelLink(dashboard?.uid || dashboardUid, panel?.id.toString() || panelId);
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{dashboard && (
|
||||||
|
<a
|
||||||
|
href={dashboardLink}
|
||||||
|
className={styles.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
data-testid="dashboard-annotation"
|
||||||
|
>
|
||||||
|
{dashboard.title} <Icon name={'external-link-alt'} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!dashboard && <span className={styles.noLink}>Dashboard {dashboardUid} </span>}
|
||||||
|
|
||||||
|
{panel && (
|
||||||
|
<a href={panelLink} className={styles.link} target="_blank" rel="noreferrer" data-testid="panel-annotation">
|
||||||
|
{panel.title || '<No title>'} <Icon name={'external-link-alt'} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!panel && <span className={styles.noLink}> - Panel {panelId}</span>}
|
||||||
|
|
||||||
|
{(dashboard || panel) && (
|
||||||
|
<>
|
||||||
|
<Icon name={'pen'} onClick={onEditClick} className={styles.icon} />
|
||||||
|
<Icon name={'trash-alt'} onClick={onDeleteClick} className={styles.icon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css`
|
||||||
|
margin-top: 5px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
noLink: css`
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
`,
|
||||||
|
link: css`
|
||||||
|
color: ${theme.colors.text.link};
|
||||||
|
margin-right: ${theme.spacing(1.5)};
|
||||||
|
`,
|
||||||
|
|
||||||
|
icon: css`
|
||||||
|
margin-right: ${theme.spacing(1)};
|
||||||
|
cursor: pointer;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DashboardAnnotationField;
|
@ -1,40 +1,62 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { HoverCard } from '../HoverCard';
|
||||||
|
|
||||||
import AnnotationsField from './AnnotationsField';
|
import AnnotationsField from './AnnotationsField';
|
||||||
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
||||||
import { RuleEditorSection } from './RuleEditorSection';
|
import { RuleEditorSection } from './RuleEditorSection';
|
||||||
|
|
||||||
function getDescription(ruleType: RuleFormType | undefined) {
|
function getDescription(ruleType: RuleFormType | undefined, styles: { [key: string]: string }) {
|
||||||
|
const annotationsText = 'Add annotations to provide more context in your alert notifications.';
|
||||||
|
|
||||||
if (ruleType === RuleFormType.cloudRecording) {
|
if (ruleType === RuleFormType.cloudRecording) {
|
||||||
return 'Select the Namespace and Group for your recording rule.';
|
return 'Select the Namespace and Group for your recording rule.';
|
||||||
}
|
}
|
||||||
const docsLink =
|
const docsLink =
|
||||||
'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation';
|
'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation';
|
||||||
|
|
||||||
|
const HelpContent = () => (
|
||||||
|
<div className={styles.needHelpTooltip}>
|
||||||
|
<div className={styles.tooltipHeader}>
|
||||||
|
<Icon name="question-circle" /> Annotations
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Annotations add metadata to provide more information on the alert in your alert notifications. For example, add
|
||||||
|
a Summary annotation to tell you which value caused the alert to fire or which server it happened on.
|
||||||
|
</div>
|
||||||
|
<div>Annotations can contain a combination of text and template code.</div>
|
||||||
|
<div>
|
||||||
|
<a href={docsLink} target="_blank" rel="noreferrer" className={styles.tooltipLink}>
|
||||||
|
Read about annotations <Icon name="external-link-alt" size="sm" tabIndex={0} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
const LinkToDocs = () => (
|
const LinkToDocs = () => (
|
||||||
<span>
|
<HoverCard content={<HelpContent />} placement={'bottom-start'}>
|
||||||
Click{' '}
|
<span className={styles.needHelpText}>
|
||||||
<a href={docsLink} target="_blank" rel="noreferrer">
|
<Icon name="info-circle" size="sm" tabIndex={0} /> <span className={styles.underline}>Need help?</span>
|
||||||
here{' '}
|
</span>
|
||||||
</a>
|
</HoverCard>
|
||||||
for documentation on how to template annotations and labels.
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
if (ruleType === RuleFormType.grafana) {
|
if (ruleType === RuleFormType.grafana) {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{` ${annotationsText} `}
|
||||||
Write a summary to help you better manage your alerts. <LinkToDocs />
|
<LinkToDocs />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (ruleType === RuleFormType.cloudAlerting) {
|
if (ruleType === RuleFormType.cloudAlerting) {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{`Select the Namespace and evaluation group for your alert. ${annotationsText} `} <LinkToDocs />
|
||||||
Select the Namespace and evaluation group for your alert. Write a summary to help you better manage your alerts.{' '}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -44,6 +66,8 @@ function getDescription(ruleType: RuleFormType | undefined) {
|
|||||||
export function DetailsStep() {
|
export function DetailsStep() {
|
||||||
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
|
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
|
||||||
|
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const ruleFormType = watch('type');
|
const ruleFormType = watch('type');
|
||||||
const dataSourceName = watch('dataSourceName');
|
const dataSourceName = watch('dataSourceName');
|
||||||
const type = watch('type');
|
const type = watch('type');
|
||||||
@ -51,8 +75,8 @@ export function DetailsStep() {
|
|||||||
return (
|
return (
|
||||||
<RuleEditorSection
|
<RuleEditorSection
|
||||||
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
|
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
|
||||||
title={type === RuleFormType.cloudRecording ? 'Folder and group' : 'Add details for your alert rule'}
|
title={type === RuleFormType.cloudRecording ? 'Folder and group' : 'Add annotations'}
|
||||||
description={getDescription(type)}
|
description={getDescription(type, styles)}
|
||||||
>
|
>
|
||||||
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
|
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
|
||||||
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
|
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
|
||||||
@ -61,3 +85,42 @@ export function DetailsStep() {
|
|||||||
</RuleEditorSection>
|
</RuleEditorSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
needHelpText: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
margin-bottom: ${theme.spacing(0.5)};
|
||||||
|
cursor: pointer;
|
||||||
|
text-underline-position: under;
|
||||||
|
`,
|
||||||
|
|
||||||
|
needHelpTooltip: css`
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
margin-left: 5px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
tooltipHeader: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
font-weight: bold;
|
||||||
|
`,
|
||||||
|
|
||||||
|
tooltipLink: css`
|
||||||
|
color: ${theme.colors.text.link};
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
underline: css`
|
||||||
|
text-decoration: underline;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
@ -25,7 +25,7 @@ export const RuleEditorSection = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<FieldSet label={title} className={styles.fieldset}>
|
<FieldSet label={title} className={styles.fieldset}>
|
||||||
{description && <p className={styles.description}>{description}</p>}
|
{description && <div className={styles.description}>{description}</div>}
|
||||||
{children}
|
{children}
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,3 +29,18 @@ export const annotationLabels: Record<Annotation, string> = {
|
|||||||
[Annotation.panelID]: 'Panel ID',
|
[Annotation.panelID]: 'Panel ID',
|
||||||
[Annotation.alertId]: 'Alert ID',
|
[Annotation.alertId]: 'Alert ID',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const annotationDescriptions: Record<Annotation, string> = {
|
||||||
|
[Annotation.description]: 'Description of what the alert rule does',
|
||||||
|
[Annotation.summary]: 'Short summary of what happened and why',
|
||||||
|
[Annotation.runbookURL]: 'Webpage where you keep your runbook for the alert',
|
||||||
|
[Annotation.dashboardUID]: '',
|
||||||
|
[Annotation.panelID]: '',
|
||||||
|
[Annotation.alertId]: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultAnnotations = [
|
||||||
|
{ key: Annotation.summary, value: '' },
|
||||||
|
{ key: Annotation.description, value: '' },
|
||||||
|
{ key: Annotation.runbookURL, value: '' },
|
||||||
|
];
|
||||||
|
@ -34,7 +34,7 @@ import { EvalFunction } from '../../state/alertDef';
|
|||||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
|
|
||||||
import { getRulesAccess } from './access-control';
|
import { getRulesAccess } from './access-control';
|
||||||
import { Annotation } from './constants';
|
import { Annotation, defaultAnnotations } from './constants';
|
||||||
import { getDefaultOrFirstCompatibleDataSource, isGrafanaRulesSource } from './datasource';
|
import { getDefaultOrFirstCompatibleDataSource, isGrafanaRulesSource } from './datasource';
|
||||||
import { arrayToRecord, recordToArray } from './misc';
|
import { arrayToRecord, recordToArray } from './misc';
|
||||||
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
|
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
|
||||||
@ -51,11 +51,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
|||||||
name: '',
|
name: '',
|
||||||
uid: '',
|
uid: '',
|
||||||
labels: [{ key: '', value: '' }],
|
labels: [{ key: '', value: '' }],
|
||||||
annotations: [
|
annotations: defaultAnnotations,
|
||||||
{ key: Annotation.summary, value: '' },
|
|
||||||
{ key: Annotation.description, value: '' },
|
|
||||||
{ key: Annotation.runbookURL, value: '' },
|
|
||||||
],
|
|
||||||
dataSourceName: null,
|
dataSourceName: null,
|
||||||
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
||||||
group: '',
|
group: '',
|
||||||
@ -98,8 +94,35 @@ export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
|
|||||||
throw new Error(`unexpected rule type: ${type}`);
|
throw new Error(`unexpected rule type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
|
function listifyLabelsOrAnnotations(
|
||||||
return [...recordToArray(item || {}), { key: '', value: '' }];
|
item: Labels | Annotations | undefined,
|
||||||
|
addEmpty: boolean
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
const list = [...recordToArray(item || {})];
|
||||||
|
if (addEmpty) {
|
||||||
|
list.push({ key: '', value: '' });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
//make sure default annotations are always shown in order even if empty
|
||||||
|
function normalizeDefaultAnnotations(annotations: Array<{ key: string; value: string }>) {
|
||||||
|
const orderedAnnotations = [...annotations];
|
||||||
|
const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key);
|
||||||
|
|
||||||
|
defaultAnnotationKeys.forEach((defaultAnnotationKey, index) => {
|
||||||
|
const fieldIndex = orderedAnnotations.findIndex((field) => field.key === defaultAnnotationKey);
|
||||||
|
|
||||||
|
if (fieldIndex === -1) {
|
||||||
|
//add the default annotation if abstent
|
||||||
|
const emptyValue = { key: defaultAnnotationKey, value: '' };
|
||||||
|
orderedAnnotations.splice(index, 0, emptyValue);
|
||||||
|
} else if (fieldIndex !== index) {
|
||||||
|
//move it to the correct position if present
|
||||||
|
orderedAnnotations.splice(index, 0, orderedAnnotations.splice(fieldIndex, 1)[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return orderedAnnotations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
|
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
|
||||||
@ -143,8 +166,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
|||||||
execErrState: ga.exec_err_state,
|
execErrState: ga.exec_err_state,
|
||||||
queries: ga.data,
|
queries: ga.data,
|
||||||
condition: ga.condition,
|
condition: ga.condition,
|
||||||
annotations: listifyLabelsOrAnnotations(rule.annotations),
|
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
|
||||||
labels: listifyLabelsOrAnnotations(rule.labels),
|
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||||
folder: { title: namespace, uid: ga.namespace_uid },
|
folder: { title: namespace, uid: ga.namespace_uid },
|
||||||
isPaused: ga.is_paused,
|
isPaused: ga.is_paused,
|
||||||
};
|
};
|
||||||
@ -194,8 +217,8 @@ export function alertingRulerRuleToRuleForm(
|
|||||||
expression: rule.expr,
|
expression: rule.expr,
|
||||||
forTime,
|
forTime,
|
||||||
forTimeUnit,
|
forTimeUnit,
|
||||||
annotations: listifyLabelsOrAnnotations(rule.annotations),
|
annotations: listifyLabelsOrAnnotations(rule.annotations, false),
|
||||||
labels: listifyLabelsOrAnnotations(rule.labels),
|
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,7 +228,7 @@ export function recordingRulerRuleToRuleForm(
|
|||||||
return {
|
return {
|
||||||
name: rule.record,
|
name: rule.record,
|
||||||
expression: rule.expr,
|
expression: rule.expr,
|
||||||
labels: listifyLabelsOrAnnotations(rule.labels),
|
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user