Alerting: Change the rule yaml data to reflect Prom-based rule format (#54520)

This commit is contained in:
Konrad Lalik 2022-09-08 12:52:36 +02:00 committed by GitHub
parent ef245874da
commit 933ec4817f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 226 additions and 161 deletions

View File

@ -3453,9 +3453,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -1,9 +1,9 @@
import { css, cx } from '@emotion/css';
import React, { FC, useCallback } from 'react';
import { useFormContext } from 'react-hook-form';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme } from '@grafana/data';
import { Button, Field, FieldArray, Input, InputControl, Label, TextArea, useStyles } from '@grafana/ui';
import { Button, Field, Input, InputControl, Label, TextArea, useStyles } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
@ -16,85 +16,83 @@ const AnnotationsField: FC = () => {
register,
watch,
formState: { errors },
} = useFormContext();
const annotations = watch('annotations') as RuleFormValues['annotations'];
} = 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' });
return (
<>
<Label>Summary and annotations</Label>
<FieldArray name={'annotations'} control={control}>
{({ fields, append, remove }) => {
<div className={styles.flexColumn}>
{fields.map((annotationField, index) => {
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
const ValueInputComponent = isUrl ? Input : TextArea;
return (
<div className={styles.flexColumn}>
{fields.map((field, index) => {
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
const ValueInputComponent = isUrl ? Input : TextArea;
return (
<div key={field.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`}
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>
<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={field.value}
/>
</Field>
<Button
type="button"
className={styles.flexRowItemMargin}
aria-label="delete annotation"
icon="trash-alt"
variant="secondary"
onClick={() => remove(index)}
/>
</div>
);
})}
<Button
className={styles.addAnnotationsButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
<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}`}
>
Add info
</Button>
<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>
<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)}
/>
</div>
);
}}
</FieldArray>
})}
<Button
className={styles.addAnnotationsButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
>
Add info
</Button>
</div>
</>
);
};

View File

@ -1,5 +1,5 @@
import { noop } from 'lodash';
import React, { FC, useCallback, useMemo, useState } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
import { useAsync } from 'react-use';
import { CoreApp, DataQuery } from '@grafana/data';
@ -15,7 +15,8 @@ export interface ExpressionEditorProps {
export const ExpressionEditor: FC<ExpressionEditorProps> = ({ value, onChange, dataSourceName }) => {
const { mapToValue, mapToQuery } = useQueryMappers(dataSourceName);
const [query, setQuery] = useState(mapToQuery({ refId: 'A', hide: false }, value));
const query = mapToQuery({ refId: 'A', hide: false }, value);
const {
error,
loading,
@ -26,7 +27,6 @@ export const ExpressionEditor: FC<ExpressionEditorProps> = ({ value, onChange, d
const onChangeQuery = useCallback(
(query: DataQuery) => {
setQuery(query);
onChange(mapToValue(query));
},
[onChange, mapToValue]

View File

@ -1,9 +1,11 @@
import { css, cx } from '@emotion/css';
import React, { FC } from 'react';
import { useFormContext } from 'react-hook-form';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme } from '@grafana/data';
import { Button, Field, FieldArray, Input, InlineLabel, Label, useStyles } from '@grafana/ui';
import { Button, Field, Input, InlineLabel, Label, useStyles } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
interface Props {
className?: string;
@ -16,81 +18,78 @@ const LabelsField: FC<Props> = ({ className }) => {
control,
watch,
formState: { errors },
} = useFormContext();
} = useFormContext<RuleFormValues>();
const labels = watch('labels');
const { fields, append, remove } = useFieldArray({ control, name: 'labels' });
return (
<div className={cx(className, styles.wrapper)}>
<Label>Custom Labels</Label>
<FieldArray control={control} name="labels">
{({ fields, append, remove }) => {
return (
<>
<div className={styles.flexRow}>
<InlineLabel width={18}>Labels</InlineLabel>
<div className={styles.flexColumn}>
{fields.map((field, index) => {
return (
<div key={field.id}>
<div className={cx(styles.flexRow, styles.centerAlignRow)}>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.key?.message}
error={errors.labels?.[index]?.key?.message}
>
<Input
{...register(`labels[${index}].key`, {
required: { value: !!labels[index]?.value, message: 'Required.' },
})}
placeholder="key"
data-testid={`label-key-${index}`}
defaultValue={field.key}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.value?.message}
error={errors.labels?.[index]?.value?.message}
>
<Input
{...register(`labels[${index}].value`, {
required: { value: !!labels[index]?.key, message: 'Required.' },
})}
placeholder="value"
data-testid={`label-value-${index}`}
defaultValue={field.value}
/>
</Field>
<Button
className={styles.deleteLabelButton}
aria-label="delete label"
icon="trash-alt"
variant="secondary"
onClick={() => {
remove(index);
}}
/>
</div>
</div>
);
})}
<Button
className={styles.addLabelButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({});
}}
>
Add label
</Button>
<>
<div className={styles.flexRow}>
<InlineLabel width={18}>Labels</InlineLabel>
<div className={styles.flexColumn}>
{fields.map((field, index) => {
return (
<div key={field.id}>
<div className={cx(styles.flexRow, styles.centerAlignRow)}>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.key?.message}
error={errors.labels?.[index]?.key?.message}
>
<Input
{...register(`labels.${index}.key`, {
required: { value: !!labels[index]?.value, message: 'Required.' },
})}
placeholder="key"
data-testid={`label-key-${index}`}
defaultValue={field.key}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.value?.message}
error={errors.labels?.[index]?.value?.message}
>
<Input
{...register(`labels.${index}.value`, {
required: { value: !!labels[index]?.key, message: 'Required.' },
})}
placeholder="value"
data-testid={`label-value-${index}`}
defaultValue={field.value}
/>
</Field>
<Button
className={styles.deleteLabelButton}
aria-label="delete label"
icon="trash-alt"
variant="secondary"
onClick={() => {
remove(index);
}}
/>
</div>
</div>
</div>
</>
);
}}
</FieldArray>
);
})}
<Button
className={styles.addLabelButton}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({});
}}
>
Add label
</Button>
</div>
</div>
</>
</div>
);
};

View File

@ -5,9 +5,16 @@ import { useFormContext } from 'react-hook-form';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, CodeEditor, Drawer, Tab, TabsBar, useStyles2 } from '@grafana/ui';
import { Button, CodeEditor, Drawer, Icon, Tab, TabsBar, useStyles2, Tooltip } from '@grafana/ui';
import { RulerRuleDTO } from '../../../../../types/unified-alerting-dto';
import { RuleFormValues } from '../../types/rule-form';
import {
alertingRulerRuleToRuleForm,
formValuesToRulerRuleDTO,
recordingRulerRuleToRuleForm,
} from '../../utils/rule-form';
import { isAlertingRulerRule, isRecordingRulerRule } from '../../utils/rules';
interface Props {
onClose: () => void;
@ -75,10 +82,16 @@ interface YamlTabProps {
const InspectorYamlTab: FC<YamlTabProps> = ({ onSubmit }) => {
const styles = useStyles2(yamlTabStyle);
const { getValues } = useFormContext<RuleFormValues>();
const [alertRuleAsYaml, setAlertRuleAsYaml] = useState(dump(getValues()));
const yamlValues = formValuesToRulerRuleDTO(getValues());
const [alertRuleAsYaml, setAlertRuleAsYaml] = useState(dump(yamlValues));
const onApply = () => {
onSubmit(load(alertRuleAsYaml) as RuleFormValues);
const rulerRule = load(alertRuleAsYaml) as RulerRuleDTO;
const currentFormValues = getValues();
const yamlFormValues = rulerRuleToRuleFormValues(rulerRule);
onSubmit({ ...currentFormValues, ...yamlFormValues });
};
return (
@ -87,6 +100,9 @@ const InspectorYamlTab: FC<YamlTabProps> = ({ onSubmit }) => {
<Button type="button" onClick={onApply}>
Apply
</Button>
<Tooltip content={<YamlContentInfo />} theme="info" placement="left-start" interactive={true}>
<Icon name="exclamation-triangle" size="xl" />
</Tooltip>
</div>
<div className={styles.content}>
@ -111,6 +127,32 @@ const InspectorYamlTab: FC<YamlTabProps> = ({ onSubmit }) => {
);
};
function YamlContentInfo() {
return (
<div>
The YAML content in the editor only contains alert rule configuration <br />
To configure Prometheus, you need to provide the rest of the{' '}
<a
href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/"
target="_blank"
rel="noreferrer"
>
configuration file content.
</a>
</div>
);
}
function rulerRuleToRuleFormValues(rulerRule: RulerRuleDTO): Partial<RuleFormValues> {
if (isAlertingRulerRule(rulerRule)) {
return alertingRulerRuleToRuleForm(rulerRule);
} else if (isRecordingRulerRule(rulerRule)) {
return recordingRulerRuleToRuleForm(rulerRule);
}
return {};
}
const yamlTabStyle = (theme: GrafanaTheme2) => ({
content: css`
flex-grow: 1;
@ -120,7 +162,11 @@ const yamlTabStyle = (theme: GrafanaTheme2) => ({
`,
applyButton: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-grow: 0;
margin-bottom: ${theme.spacing(2)};
`,
});

View File

@ -21,6 +21,8 @@ import {
GrafanaAlertStateDecision,
Labels,
PostableRuleGrafanaRuleDTO,
RulerAlertingRuleDTO,
RulerRecordingRuleDTO,
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
@ -136,32 +138,26 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
}
} else {
if (isAlertingRulerRule(rule)) {
const [forTime, forTimeUnit] = rule.for
? parseInterval(rule.for)
: [defaultFormValues.forTime, defaultFormValues.forTimeUnit];
const alertingRuleValues = alertingRulerRuleToRuleForm(rule);
return {
...defaultFormValues,
name: rule.alert,
...alertingRuleValues,
type: RuleFormType.cloudAlerting,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
expression: rule.expr,
forTime,
forTimeUnit,
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
};
} else if (isRecordingRulerRule(rule)) {
const recordingRuleValues = recordingRulerRuleToRuleForm(rule);
return {
...defaultFormValues,
name: rule.record,
...recordingRuleValues,
type: RuleFormType.cloudRecording,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
expression: rule.expr,
labels: listifyLabelsOrAnnotations(rule.labels),
};
} else {
throw new Error('Unexpected type of rule for cloud rules source');
@ -169,6 +165,35 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
}
}
export function alertingRulerRuleToRuleForm(
rule: RulerAlertingRuleDTO
): Pick<RuleFormValues, 'name' | 'forTime' | 'forTimeUnit' | 'expression' | 'annotations' | 'labels'> {
const defaultFormValues = getDefaultFormValues();
const [forTime, forTimeUnit] = rule.for
? parseInterval(rule.for)
: [defaultFormValues.forTime, defaultFormValues.forTimeUnit];
return {
name: rule.alert,
expression: rule.expr,
forTime,
forTimeUnit,
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
};
}
export function recordingRulerRuleToRuleForm(
rule: RulerRecordingRuleDTO
): Pick<RuleFormValues, 'name' | 'expression' | 'labels'> {
return {
name: rule.record,
expression: rule.expr,
labels: listifyLabelsOrAnnotations(rule.labels),
};
}
export const getDefaultQueries = (): AlertQuery[] => {
const dataSource = getDefaultOrFirstCompatibleDataSource();