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 { 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}');
|
||||
|
@ -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());
|
||||
|
@ -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: '' }],
|
||||
};
|
||||
|
||||
|
@ -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 = {
|
||||
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');
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
||||
|
||||
|
@ -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 { 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;
|
||||
`,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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: '' },
|
||||
];
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user