Alerting: unified alerting frontend (#32708)

This commit is contained in:
Domas
2021-04-07 08:42:43 +03:00
committed by GitHub
parent 6082a9360e
commit a56293142a
79 changed files with 3857 additions and 28 deletions

View File

@@ -0,0 +1,53 @@
import React, { FC } from 'react';
import { Field, FieldSet, Input, Select, useStyles, Label, InputControl } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { AlertRuleFormMethods } from './AlertRuleForm';
type Props = AlertRuleFormMethods;
enum TIME_OPTIONS {
seconds = 's',
minutes = 'm',
hours = 'h',
days = 'd',
}
const timeOptions = Object.entries(TIME_OPTIONS).map(([key, value]) => ({
label: key,
value: value,
}));
const getStyles = (theme: GrafanaTheme) => ({
flexRow: css`
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: flex-start;
`,
numberInput: css`
width: 200px;
& + & {
margin-left: ${theme.spacing.sm};
}
`,
});
const AlertConditionsSection: FC<Props> = ({ register, control }) => {
const styles = useStyles(getStyles);
return (
<FieldSet label="Define alert conditions">
<Label description="Required time for which the expression has to happen">For</Label>
<div className={styles.flexRow}>
<Field className={styles.numberInput}>
<Input ref={register()} name="forTime" />
</Field>
<Field className={styles.numberInput}>
<InputControl name="timeUnit" as={Select} options={timeOptions} control={control} />
</Field>
</div>
</FieldSet>
);
};
export default AlertConditionsSection;

View File

@@ -0,0 +1,17 @@
import React, { FC } from 'react';
import { FieldSet, FormAPI } from '@grafana/ui';
import LabelsField from './LabelsField';
import AnnotationsField from './AnnotationsField';
interface Props extends FormAPI<{}> {}
const AlertDetails: FC<Props> = (props) => {
return (
<FieldSet label="Add details for your alert">
<AnnotationsField {...props} />
<LabelsField {...props} />
</FieldSet>
);
};
export default AlertDetails;

View File

@@ -0,0 +1,129 @@
import React, { FC, useState } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { PageToolbar, ToolbarButton, stylesFactory, Form, FormAPI } from '@grafana/ui';
import { css } from '@emotion/css';
import { config } from 'app/core/config';
import AlertTypeSection from './AlertTypeSection';
import AlertConditionsSection from './AlertConditionsSection';
import AlertDetails from './AlertDetails';
import Expression from './Expression';
import { fetchRulerRulesNamespace, setRulerRuleGroup } from '../../api/ruler';
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { locationService } from '@grafana/runtime';
type Props = {};
interface AlertRuleFormFields {
name: string;
type: SelectableValue;
folder: SelectableValue;
forTime: string;
dataSource: SelectableValue;
expression: string;
timeUnit: SelectableValue;
labels: Array<{ key: string; value: string }>;
annotations: Array<{ key: SelectableValue; value: string }>;
}
export type AlertRuleFormMethods = FormAPI<AlertRuleFormFields>;
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
fullWidth: css`
width: 100%;
`,
formWrapper: css`
padding: 0 ${theme.spacing.md};
`,
formInput: css`
width: 400px;
& + & {
margin-left: ${theme.spacing.sm};
}
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
};
});
const AlertRuleForm: FC<Props> = () => {
const styles = getStyles(config.theme);
const [folder, setFolder] = useState<{ namespace: string; group: string }>();
const handleSubmit = (alertRule: AlertRuleFormFields) => {
const { name, expression, forTime, dataSource, timeUnit, labels, annotations } = alertRule;
console.log('saving', alertRule);
const { namespace, group: groupName } = folder || {};
if (namespace && groupName) {
fetchRulerRulesNamespace(dataSource?.value, namespace)
.then((ruleGroup) => {
const group: RulerRuleGroupDTO = ruleGroup.find(({ name }) => name === groupName) || {
name: groupName,
rules: [] as RulerRuleDTO[],
};
const alertRule: RulerRuleDTO = {
alert: name,
expr: expression,
for: `${forTime}${timeUnit.value}`,
labels: labels.reduce((acc, { key, value }) => {
if (key && value) {
acc[key] = value;
}
return acc;
}, {} as Record<string, string>),
annotations: annotations.reduce((acc, { key, value }) => {
if (key && value) {
acc[key.value] = value;
}
return acc;
}, {} as Record<string, string>),
};
group.rules = group?.rules.concat(alertRule);
return setRulerRuleGroup(dataSource?.value, namespace, group);
})
.then(() => {
console.log('Alert rule saved successfully');
locationService.push('/alerting/list');
})
.catch((error) => console.error(error));
}
};
return (
<Form
onSubmit={handleSubmit}
className={styles.fullWidth}
defaultValues={{ labels: [{ key: '', value: '' }], annotations: [{ key: {}, value: '' }] }}
>
{(formApi) => (
<>
<PageToolbar title="Create alert rule" pageIcon="bell">
<ToolbarButton variant="primary" type="submit">
Save
</ToolbarButton>
<ToolbarButton variant="primary">Save and exit</ToolbarButton>
<a href="/alerting/list">
<ToolbarButton variant="destructive" type="button">
Cancel
</ToolbarButton>
</a>
</PageToolbar>
<div className={styles.formWrapper}>
<AlertTypeSection {...formApi} setFolder={setFolder} />
<Expression {...formApi} />
<AlertConditionsSection {...formApi} />
<AlertDetails {...formApi} />
</div>
</>
)}
</Form>
);
};
export default AlertRuleForm;

View File

@@ -0,0 +1,149 @@
import React, { FC, useState, useEffect } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { Cascader, FieldSet, Field, Input, InputControl, stylesFactory, Select, CascaderOption } from '@grafana/ui';
import { config } from 'app/core/config';
import { css } from '@emotion/css';
import { getAllDataSources } from '../../utils/config';
import { fetchRulerRules } from '../../api/ruler';
import { AlertRuleFormMethods } from './AlertRuleForm';
import { getRulesDataSources } from '../../utils/datasource';
interface Props extends AlertRuleFormMethods {
setFolder: ({ namespace, group }: { namespace: string; group: string }) => void;
}
enum ALERT_TYPE {
THRESHOLD = 'threshold',
SYSTEM = 'system',
HOST = 'host',
}
const alertTypeOptions: SelectableValue[] = [
{
label: 'Threshold',
value: ALERT_TYPE.THRESHOLD,
description: 'Metric alert based on a defined threshold',
},
{
label: 'System or application',
value: ALERT_TYPE.SYSTEM,
description: 'Alert based on a system or application behavior. Based on Prometheus.',
},
];
const AlertTypeSection: FC<Props> = ({ register, control, watch, setFolder, errors }) => {
const styles = getStyles(config.theme);
const alertType = watch('type') as SelectableValue;
const datasource = watch('dataSource') as SelectableValue;
const dataSourceOptions = useDatasourceSelectOptions(alertType);
const folderOptions = useFolderSelectOptions(datasource);
return (
<FieldSet label="Alert type">
<Field
className={styles.formInput}
label="Alert name"
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
<Input ref={register({ required: { value: true, message: 'Must enter an alert name' } })} name="name" />
</Field>
<div className={styles.flexRow}>
<Field label="Alert type" className={styles.formInput} error={errors.type?.message}>
<InputControl as={Select} name="type" options={alertTypeOptions} control={control} />
</Field>
<Field className={styles.formInput} label="Select data source">
<InputControl as={Select} name="dataSource" options={dataSourceOptions} control={control} />
</Field>
</div>
<Field className={styles.formInput}>
<InputControl
as={Cascader}
displayAllSelectedLevels={true}
separator=" > "
name="folder"
options={folderOptions}
control={control}
changeOnSelect={false}
onSelect={(value: string) => {
const [namespace, group] = value.split(' > ');
setFolder({ namespace, group });
}}
/>
</Field>
</FieldSet>
);
};
const useDatasourceSelectOptions = (alertType: SelectableValue) => {
const [datasourceOptions, setDataSourceOptions] = useState<SelectableValue[]>([]);
useEffect(() => {
let options = [] as ReturnType<typeof getAllDataSources>;
if (alertType?.value === ALERT_TYPE.THRESHOLD) {
options = getAllDataSources().filter(({ type }) => type !== 'datasource');
} else if (alertType?.value === ALERT_TYPE.SYSTEM) {
options = getRulesDataSources();
}
setDataSourceOptions(
options.map(({ name, type }) => {
return {
label: name,
value: name,
description: type,
};
})
);
}, [alertType?.value]);
return datasourceOptions;
};
const useFolderSelectOptions = (datasource: SelectableValue) => {
const [folderOptions, setFolderOptions] = useState<CascaderOption[]>([]);
useEffect(() => {
if (datasource?.value) {
fetchRulerRules(datasource?.value)
.then((namespaces) => {
const options: CascaderOption[] = Object.entries(namespaces).map(([namespace, group]) => {
return {
label: namespace,
value: namespace,
items: group.map(({ name }) => {
return { label: name, value: `${namespace} > ${name}` };
}),
};
});
setFolderOptions(options);
})
.catch((error) => {
if (error.status === 404) {
setFolderOptions([{ label: 'No folders found', value: '' }]);
}
});
}
}, [datasource?.value]);
return folderOptions;
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
formInput: css`
width: 400px;
& + & {
margin-left: ${theme.spacing.sm};
}
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
};
});
export default AlertTypeSection;

View File

@@ -0,0 +1,116 @@
import React, { FC } from 'react';
import {
Button,
Field,
FieldArray,
FormAPI,
IconButton,
InputControl,
Label,
Select,
TextArea,
stylesFactory,
} from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { config } from 'app/core/config';
import { css, cx } from '@emotion/css';
interface Props extends FormAPI<any> {}
enum AnnotationOptions {
summary = 'Summary',
description = 'Description',
runbook = 'Runbook url',
}
const AnnotationsField: FC<Props> = ({ control, register }) => {
const styles = getStyles(config.theme);
const annotationOptions = Object.entries(AnnotationOptions).map(([key, value]) => ({ value: key, label: value }));
return (
<>
<Label>Summary and annotations</Label>
<FieldArray name={'annotations'} control={control}>
{({ fields, append, remove }) => {
return (
<div className={styles.flexColumn}>
{fields.map((field, index) => {
return (
<div key={`${field.annotationKey}-${index}`} className={styles.flexRow}>
<Field className={styles.annotationSelect}>
<InputControl
as={Select}
name={`annotations[${index}].key`}
options={annotationOptions}
control={control}
defaultValue={field.key}
/>
</Field>
<Field className={cx(styles.annotationTextArea, styles.flexRowItemMargin)}>
<TextArea
name={`annotations[${index}].value`}
ref={register()}
placeholder={`Text`}
defaultValue={field.value}
/>
</Field>
<IconButton
className={styles.flexRowItemMargin}
aria-label="delete annotation"
name="trash-alt"
onClick={() => {
remove(index);
}}
/>
</div>
);
})}
<Button
className={styles.addAnnotationsButton}
icon="plus-circle"
type="button"
variant="secondary"
size="sm"
onClick={() => {
append({});
}}
>
Add info
</Button>
</div>
);
}}
</FieldArray>
</>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
annotationSelect: css`
width: 120px;
`,
annotationTextArea: css`
width: 450px;
height: 76px;
`,
addAnnotationsButton: css`
flex-grow: 0;
align-self: flex-start;
`,
flexColumn: css`
display: flex;
flex-direction: column;
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
flexRowItemMargin: css`
margin-left: ${theme.spacing.sm};
`,
};
});
export default AnnotationsField;

View File

@@ -0,0 +1,17 @@
import React, { FC } from 'react';
import { Field, FieldSet, Input } from '@grafana/ui';
import { AlertRuleFormMethods } from './AlertRuleForm';
type Props = AlertRuleFormMethods;
const Expression: FC<Props> = ({ register }) => {
return (
<FieldSet label="Create a query (expression) to be alerted on">
<Field>
<Input ref={register()} name="expression" placeholder="Enter a PromQL query here" />
</Field>
</FieldSet>
);
};
export default Expression;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Button, Field, FieldArray, FormAPI, Input, InlineLabel, IconButton, Label, stylesFactory } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { config } from 'app/core/config';
import { css, cx } from '@emotion/css';
interface Props extends Pick<FormAPI<{}>, 'register' | 'control'> {
className?: string;
}
const LabelsField = (props: Props) => {
const styles = getStyles(config.theme);
const { register, control } = props;
return (
<div className={props.className}>
<Label>Custom Labels</Label>
<FieldArray control={control} name="labels">
{({ fields, append, remove }) => {
return (
<>
<div className={styles.flexRow}>
<InlineLabel width={12}>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}>
<Input
ref={register()}
name={`labels[${index}].key`}
placeholder="key"
defaultValue={field.key}
/>
</Field>
<div className={styles.equalSign}>=</div>
<Field className={styles.labelInput}>
<Input
ref={register()}
name={`labels[${index}].value`}
placeholder="value"
defaultValue={field.value}
/>
</Field>
<IconButton
aria-label="delete label"
name="trash-alt"
onClick={() => {
remove(index);
}}
/>
</div>
</div>
);
})}
<Button
className={styles.addLabelButton}
icon="plus-circle"
type="button"
variant="secondary"
size="sm"
onClick={() => {
append({});
}}
>
Add label
</Button>
</div>
</div>
</>
);
}}
</FieldArray>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
flexColumn: css`
display: flex;
flex-direction: column;
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
& + button {
margin-left: ${theme.spacing.xs};
}
`,
addLabelButton: css`
flex-grow: 0;
align-self: flex-start;
`,
centerAlignRow: css`
align-items: baseline;
`,
equalSign: css`
width: ${theme.spacing.lg};
height: ${theme.spacing.lg};
padding: ${theme.spacing.sm};
line-height: ${theme.spacing.sm};
background-color: ${theme.colors.bg2};
margin: 0 ${theme.spacing.xs};
`,
labelInput: css`
width: 200px;
margin-bottom: ${theme.spacing.sm};
& + & {
margin-left: ${theme.spacing.sm};
}
`,
};
});
export default LabelsField;