mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: unified alerting frontend (#32708)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user