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:
Virginia Cepeda 2023-06-21 11:15:12 -03:00 committed by GitHub
parent 8f7e1f36ab
commit 929d9eaa91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 480 additions and 119 deletions

View File

@ -4,8 +4,6 @@ import React from 'react';
import { Route } from 'react-router-dom';
import { TestProvider } from 'test/helpers/TestProvider';
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 { 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');
//check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description');
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary');
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some summary');
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some description');
//check that slashed folders are not in the list
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();
// add an annotation
await clickSelectOptionMatch(ui.inputs.annotationKey(2).get(), /Add new/);
await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom');
await userEvent.type(ui.inputs.annotationValue(2).get(), 'value');
await userEvent.click(screen.getByText('Add custom annotation'));
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom');
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value');
//add a label
await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}');

View File

@ -257,11 +257,9 @@ describe('Receivers', () => {
await waitFor(() => expect(ui.testContactPointModal.get()).toBeInTheDocument(), { timeout: 1000 });
await userEvent.click(ui.customContactPointOption.get());
await waitFor(() => expect(ui.contactPointAnnotationSelect(0).get()).toBeInTheDocument());
// enter custom annotations and labels
await clickSelectOption(ui.contactPointAnnotationSelect(0).get(), 'Description');
await userEvent.type(ui.contactPointAnnotationValue(0).get(), 'Test contact point');
await userEvent.type(screen.getByPlaceholderText('Enter a description...'), 'Test contact point');
await userEvent.type(ui.contactPointLabelKey(0).get(), 'foo');
await userEvent.type(ui.contactPointLabelValue(0).get(), 'bar');
await userEvent.click(ui.testContactPoint.get());

View File

@ -7,6 +7,7 @@ import { Modal, Button, Label, useStyles2, RadioButtonGroup } from '@grafana/ui'
import { TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
import { defaultAnnotations } from '../../../utils/constants';
import AnnotationsField from '../../rule-editor/AnnotationsField';
import LabelsField from '../../rule-editor/LabelsField';
@ -34,7 +35,7 @@ enum NotificationType {
const notificationOptions = Object.values(NotificationType).map((value) => ({ label: value, value: value }));
const defaultValues: FormFields = {
annotations: [{ key: '', value: '' }],
annotations: [...defaultAnnotations],
labels: [{ key: '', value: '' }],
};

View File

@ -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;

View File

@ -33,7 +33,7 @@ jest.mock(
);
const ui = {
setDashboardButton: byRole('button', { name: 'Set dashboard and panel' }),
setDashboardButton: byRole('button', { name: 'Link dashboard and panel' }),
annotationKeys: byTestId('annotation-key-', { exact: false }),
annotationValues: byTestId('annotation-value-', { exact: false }),
dashboardPicker: {
@ -154,18 +154,12 @@ describe('AnnotationsField', function () {
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');
});

View File

@ -1,18 +1,21 @@
import { css, cx } from '@emotion/css';
import produce from 'immer';
import React, { useCallback } from 'react';
import React, { useEffect, useState } 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 { 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 { Annotation } from '../../utils/constants';
import { Annotation, annotationLabels } from '../../utils/constants';
import { AnnotationKeyInput } from './AnnotationKeyInput';
import { DashboardPicker } from './DashboardPicker';
import AnnotationHeaderField from './AnnotationHeaderField';
import DashboardAnnotationField from './DashboardAnnotationField';
import { DashboardPicker, PanelDTO } from './DashboardPicker';
const AnnotationsField = () => {
const styles = useStyles2(getStyles);
@ -27,16 +30,31 @@ const AnnotationsField = () => {
} = useFormContext<RuleFormValues>();
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 selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.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 updatedAnnotations = produce(annotations, (draft) => {
const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID);
@ -59,76 +77,104 @@ const AnnotationsField = () => {
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 (
<>
<Label>Summary and annotations</Label>
<div className={styles.flexColumn}>
{fields.map((annotationField, index) => {
{fields.map((annotationField, index: number) => {
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
const ValueInputComponent = isUrl ? Input : TextArea;
// eslint-disable-next-line
const annotation = annotationField.key as Annotation;
return (
<div key={annotationField.id} className={styles.flexRow}>
<Field
className={styles.field}
invalid={!!errors.annotations?.[index]?.key?.message}
error={errors.annotations?.[index]?.key?.message}
data-testid={`annotation-key-${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.' } }}
<div>
<AnnotationHeaderField
annotationField={annotationField}
annotations={annotations}
annotation={annotation}
index={index}
/>
</Field>
<Field
className={cx(styles.flexRowItemMargin, styles.field)}
invalid={!!errors.annotations?.[index]?.value?.message}
error={errors.annotations?.[index]?.value?.message}
>
<ValueInputComponent
data-testid={`annotation-value-${index}`}
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
{...register(`annotations.${index}.value`)}
placeholder={isUrl ? 'https://' : `Text`}
defaultValue={annotationField.value}
/>
</Field>
<Button
type="button"
className={styles.flexRowItemMargin}
aria-label="delete annotation"
icon="trash-alt"
variant="secondary"
onClick={() => remove(index)}
/>
{selectedDashboardUid && selectedPanelId && annotationField.key === Annotation.dashboardUID && (
<DashboardAnnotationField
dashboard={selectedDashboard}
panel={selectedPanel}
dashboardUid={selectedDashboardUid.toString()}
panelId={selectedPanelId.toString()}
onEditClick={handleEditDashboardAnnotation}
onDeleteClick={handleDeleteDashboardAnnotation}
/>
)}
{
<div className={styles.annotationValueContainer}>
<Field
hidden={
annotationField.key === Annotation.dashboardUID || annotationField.key === Annotation.panelID
}
className={cx(styles.flexRowItemMargin, styles.field)}
invalid={!!errors.annotations?.[index]?.value?.message}
error={errors.annotations?.[index]?.value?.message}
>
<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>
);
})}
<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>
<div className={styles.addAnnotationsButtonContainer}>
<Button
icon="plus"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
>
Add custom annotation
</Button>
{!selectedDashboard && (
<Button type="button" variant="secondary" icon="dashboard" onClick={() => setShowPanelSelector(true)}>
Link dashboard and panel
</Button>
)}
</div>
</Stack>
{showPanelSelector && (
<DashboardPicker
@ -151,10 +197,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
textarea: css`
height: 76px;
`,
addAnnotationsButton: css`
flex-grow: 0;
align-self: flex-start;
margin-left: 148px;
addAnnotationsButtonContainer: css`
margin-top: ${theme.spacing(1)};
gap: ${theme.spacing(1)};
display: flex;
`,
flexColumn: css`
display: flex;
@ -169,7 +215,29 @@ const getStyles = (theme: GrafanaTheme2) => ({
justify-content: flex-start;
`,
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;
`,
});

View File

@ -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;

View File

@ -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;

View File

@ -1,40 +1,62 @@
import { css } from '@emotion/css';
import React from 'react';
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 { HoverCard } from '../HoverCard';
import AnnotationsField from './AnnotationsField';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
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) {
return 'Select the Namespace and Group for your recording rule.';
}
const docsLink =
'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 = () => (
<span>
Click{' '}
<a href={docsLink} target="_blank" rel="noreferrer">
here{' '}
</a>
for documentation on how to template annotations and labels.
</span>
<HoverCard content={<HelpContent />} placement={'bottom-start'}>
<span className={styles.needHelpText}>
<Icon name="info-circle" size="sm" tabIndex={0} /> <span className={styles.underline}>Need help?</span>
</span>
</HoverCard>
);
if (ruleType === RuleFormType.grafana) {
return (
<span>
{' '}
Write a summary to help you better manage your alerts. <LinkToDocs />
{` ${annotationsText} `}
<LinkToDocs />
</span>
);
}
if (ruleType === RuleFormType.cloudAlerting) {
return (
<span>
{' '}
Select the Namespace and evaluation group for your alert. Write a summary to help you better manage your alerts.{' '}
{`Select the Namespace and evaluation group for your alert. ${annotationsText} `} <LinkToDocs />
</span>
);
}
@ -44,6 +66,8 @@ function getDescription(ruleType: RuleFormType | undefined) {
export function DetailsStep() {
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const styles = useStyles2(getStyles);
const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName');
const type = watch('type');
@ -51,8 +75,8 @@ export function DetailsStep() {
return (
<RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
title={type === RuleFormType.cloudRecording ? 'Folder and group' : 'Add details for your alert rule'}
description={getDescription(type)}
title={type === RuleFormType.cloudRecording ? 'Folder and group' : 'Add annotations'}
description={getDescription(type, styles)}
>
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
@ -61,3 +85,42 @@ export function DetailsStep() {
</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;
`,
});

View File

@ -25,7 +25,7 @@ export const RuleEditorSection = ({
</div>
<div className={styles.content}>
<FieldSet label={title} className={styles.fieldset}>
{description && <p className={styles.description}>{description}</p>}
{description && <div className={styles.description}>{description}</div>}
{children}
</FieldSet>
</div>

View File

@ -29,3 +29,18 @@ export const annotationLabels: Record<Annotation, string> = {
[Annotation.panelID]: 'Panel 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: '' },
];

View File

@ -34,7 +34,7 @@ import { EvalFunction } from '../../state/alertDef';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getRulesAccess } from './access-control';
import { Annotation } from './constants';
import { Annotation, defaultAnnotations } from './constants';
import { getDefaultOrFirstCompatibleDataSource, isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
@ -51,11 +51,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
name: '',
uid: '',
labels: [{ key: '', value: '' }],
annotations: [
{ key: Annotation.summary, value: '' },
{ key: Annotation.description, value: '' },
{ key: Annotation.runbookURL, value: '' },
],
annotations: defaultAnnotations,
dataSourceName: null,
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
group: '',
@ -98,8 +94,35 @@ export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
throw new Error(`unexpected rule type: ${type}`);
}
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
return [...recordToArray(item || {}), { key: '', value: '' }];
function listifyLabelsOrAnnotations(
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 {
@ -143,8 +166,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
execErrState: ga.exec_err_state,
queries: ga.data,
condition: ga.condition,
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
};
@ -194,8 +217,8 @@ export function alertingRulerRuleToRuleForm(
expression: rule.expr,
forTime,
forTimeUnit,
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
annotations: listifyLabelsOrAnnotations(rule.annotations, false),
labels: listifyLabelsOrAnnotations(rule.labels, true),
};
}
@ -205,7 +228,7 @@ export function recordingRulerRuleToRuleForm(
return {
name: rule.record,
expression: rule.expr,
labels: listifyLabelsOrAnnotations(rule.labels),
labels: listifyLabelsOrAnnotations(rule.labels, true),
};
}