Alerting: Rule edit form (#32877)

This commit is contained in:
Domas
2021-04-14 15:57:36 +03:00
committed by GitHub
parent 727a24c1bb
commit 282c62d8bf
47 changed files with 1378 additions and 499 deletions

View File

@@ -1,53 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,41 +1,145 @@
import React, { FC, useState } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { PageToolbar, ToolbarButton, stylesFactory, Form, FormAPI } from '@grafana/ui';
import React, { FC, useEffect } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { PageToolbar, ToolbarButton, useStyles, CustomScrollbar, Spinner, Alert } 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 { AlertTypeStep } from './AlertTypeStep';
import { ConditionsStep } from './ConditionsStep';
import { DetailsStep } from './DetailsStep';
import { QueryStep } from './QueryStep';
import { useForm, FormContext } from 'react-hook-form';
import { fetchRulerRulesNamespace, setRulerRuleGroup } from '../../api/ruler';
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { locationService } from '@grafana/runtime';
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
//import { locationService } from '@grafana/runtime';
import { RuleFormValues } from '../../types/rule-form';
import { SAMPLE_QUERIES } from '../../mocks/grafana-queries';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { initialAsyncRequestState } from '../../utils/redux';
import { useDispatch } from 'react-redux';
import { saveRuleFormAction } from '../../state/actions';
import { cleanUpAction } from 'app/core/actions/cleanUp';
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 }>;
}
const defaultValues: RuleFormValues = Object.freeze({
name: '',
labels: [{ key: '', value: '' }],
annotations: [{ key: '', value: '' }],
dataSourceName: null,
export type AlertRuleFormMethods = FormAPI<AlertRuleFormFields>;
// threshold
folder: null,
queries: SAMPLE_QUERIES, // @TODO remove the sample eventually
condition: '',
noDataState: GrafanaAlertState.NoData,
execErrState: GrafanaAlertState.Alerting,
evaluateEvery: '1m',
evaluateFor: '5m',
const getStyles = stylesFactory((theme: GrafanaTheme) => {
// system
expression: '',
forTime: 1,
forTimeUnit: 'm',
});
export const AlertRuleForm: FC<Props> = () => {
const styles = useStyles(getStyles);
const dispatch = useDispatch();
useEffect(() => {
return () => {
dispatch(cleanUpAction({ stateSelector: (state) => state.unifiedAlerting.ruleForm }));
};
}, [dispatch]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
defaultValues,
});
const { handleSubmit, watch } = formAPI;
const type = watch('type');
const dataSourceName = watch('dataSourceName');
const showStep2 = Boolean(dataSourceName && type);
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
const submit = (values: RuleFormValues) => {
dispatch(
saveRuleFormAction({
...values,
annotations: values.annotations.filter(({ key }) => !!key),
labels: values.labels.filter(({ key }) => !!key),
})
);
};
return (
<FormContext {...formAPI}>
<form onSubmit={handleSubmit(submit)} className={styles.form}>
<PageToolbar title="Create alert rule" pageIcon="bell" className={styles.toolbar}>
<ToolbarButton variant="default" disabled={submitState.loading}>
Cancel
</ToolbarButton>
<ToolbarButton variant="primary" type="submit" disabled={submitState.loading}>
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
Save
</ToolbarButton>
<ToolbarButton variant="primary" disabled={submitState.loading}>
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
Save and exit
</ToolbarButton>
</PageToolbar>
<div className={styles.contentOutter}>
<CustomScrollbar autoHeightMin="100%">
<div className={styles.contentInner}>
{submitState.error && (
<Alert severity="error" title="Error saving rule">
{submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)}
</Alert>
)}
<AlertTypeStep />
{showStep2 && (
<>
<QueryStep />
<ConditionsStep />
<DetailsStep />
</>
)}
</div>
</CustomScrollbar>
</div>
</form>
</FormContext>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
fullWidth: css`
width: 100%;
buttonSpiner: css`
margin-right: ${theme.spacing.sm};
`,
formWrapper: css`
padding: 0 ${theme.spacing.md};
toolbar: css`
padding-top: ${theme.spacing.sm};
padding-bottom: ${theme.spacing.md};
border-bottom: solid 1px ${theme.colors.border2};
`,
form: css`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
`,
contentInner: css`
flex: 1;
padding: ${theme.spacing.md};
`,
contentOutter: css`
background: ${theme.colors.panelBg};
overflow: hidden;
flex: 1;
`,
formInput: css`
width: 400px;
@@ -49,81 +153,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
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

@@ -1,149 +0,0 @@
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,175 @@
import React, { FC, useCallback, useEffect } from 'react';
import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data';
import { Field, Input, InputControl, Select, useStyles } from '@grafana/ui';
import { css } from '@emotion/css';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { DataSourcePicker, DataSourcePickerProps } from '@grafana/runtime';
import { RuleGroupPicker } from '../RuleGroupPicker';
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
import { RuleFolderPicker } from './RuleFolderPicker';
const alertTypeOptions: SelectableValue[] = [
{
label: 'Threshold',
value: RuleFormType.threshold,
description: 'Metric alert based on a defined threshold',
},
{
label: 'System or application',
value: RuleFormType.system,
description: 'Alert based on a system or application behavior. Based on Prometheus.',
},
];
export const AlertTypeStep: FC = () => {
const styles = useStyles(getStyles);
const { register, control, watch, errors, setValue } = useFormContext<RuleFormValues>();
const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName');
useEffect(() => {}, [ruleFormType]);
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
const dataSourceFilter = useCallback(
(ds: DataSourceInstanceSettings): boolean => {
if (ruleFormType === RuleFormType.threshold) {
return !!ds.meta.alerting;
} else {
// filter out only rules sources that support ruler and thus can have alerts edited
return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id);
}
},
[ruleFormType, rulesSourcesWithRuler]
);
return (
<RuleEditorSection stepNo={1} title="Alert type">
<Field
className={styles.formInput}
label="Alert name"
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
<Input
autoFocus={true}
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}
invalid={!!errors.type?.message}
>
<InputControl
as={Select}
name="type"
options={alertTypeOptions}
control={control}
rules={{
required: { value: true, message: 'Please select alert type' },
}}
onChange={(values: SelectableValue[]) => {
const value = values[0]?.value;
// when switching to system alerts, null out data source selection if it's not a rules source with ruler
if (
value === RuleFormType.system &&
dataSourceName &&
!rulesSourcesWithRuler.find(({ name }) => name === dataSourceName)
) {
setValue('dataSourceName', null);
}
return value;
}}
/>
</Field>
<Field
className={styles.formInput}
label="Select data source"
error={errors.dataSourceName?.message}
invalid={!!errors.dataSourceName?.message}
>
<InputControl
as={DataSourcePicker as React.ComponentType<Omit<DataSourcePickerProps, 'current'>>}
valueName="current"
filter={dataSourceFilter}
name="dataSourceName"
noDefault={true}
control={control}
alerting={true}
rules={{
required: { value: true, message: 'Please select a data source' },
}}
onChange={(ds: DataSourceInstanceSettings[]) => {
// reset location if switching data sources, as differnet rules source will have different groups and namespaces
setValue('location', undefined);
return ds[0]?.name ?? null;
}}
/>
</Field>
</div>
{ruleFormType === RuleFormType.system && (
<Field
label="Group"
className={styles.formInput}
error={errors.location?.message}
invalid={!!errors.location?.message}
>
{dataSourceName ? (
<InputControl
as={RuleGroupPicker}
name="location"
control={control}
dataSourceName={dataSourceName}
rules={{
required: { value: true, message: 'Please select a group' },
}}
/>
) : (
<Select placeholder="Select a data source first" onChange={() => {}} disabled={true} />
)}
</Field>
)}
{ruleFormType === RuleFormType.threshold && (
<Field
label="Folder"
className={styles.formInput}
error={errors.folder?.message}
invalid={!!errors.folder?.message}
>
<InputControl
as={RuleFolderPicker}
name="folder"
enableCreateNew={true}
enableReset={true}
rules={{
required: { value: true, message: 'Please select a folder' },
}}
/>
</Field>
)}
</RuleEditorSection>
);
};
const getStyles = (theme: GrafanaTheme) => ({
formInput: css`
width: 330px;
& + & {
margin-left: ${theme.spacing.sm};
}
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
});

View File

@@ -0,0 +1,63 @@
import { SelectableValue } from '@grafana/data';
import { Input, Select } from '@grafana/ui';
import React, { FC, useMemo, useState } from 'react';
enum AnnotationOptions {
description = 'Description',
dashboard = 'Dashboard',
summary = 'Summary',
runbook = 'Runbook URL',
}
interface Props {
onChange: (value: string) => void;
existingKeys: string[];
value?: string;
width?: number;
className?: string;
}
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => {
const [isCustom, setIsCustom] = useState(false);
const annotationOptions = useMemo(
(): SelectableValue[] => [
...Object.entries(AnnotationOptions)
.filter(([optKey]) => !existingKeys.includes(optKey)) // remove keys already taken in other annotations
.map(([key, value]) => ({ value: key, label: value })),
{ value: '__add__', label: '+ Custom name' },
],
[existingKeys]
);
if (isCustom) {
return (
<Input
width={width}
autoFocus={true}
value={value || ''}
placeholder="key"
className={className}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
/>
);
} else {
return (
<Select
width={width}
options={annotationOptions}
value={value}
className={className}
onChange={(val: SelectableValue) => {
const value = val?.value;
if (value === '__add__') {
setIsCustom(true);
} else {
onChange(value);
}
}}
/>
);
}
};

View File

@@ -1,31 +1,20 @@
import React, { FC } from 'react';
import {
Button,
Field,
FieldArray,
FormAPI,
IconButton,
InputControl,
Label,
Select,
TextArea,
stylesFactory,
} from '@grafana/ui';
import React, { FC, useCallback } from 'react';
import { Button, Field, FieldArray, InputControl, Label, TextArea, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { config } from 'app/core/config';
import { css, cx } from '@emotion/css';
import { useFormContext } from 'react-hook-form';
import { RuleFormValues } from '../../types/rule-form';
import { AnnotationKeyInput } from './AnnotationKeyInput';
interface Props extends FormAPI<any> {}
const AnnotationsField: FC = () => {
const styles = useStyles(getStyles);
const { control, register, watch, errors } = useFormContext<RuleFormValues>();
const annotations = watch('annotations');
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 }));
const existingKeys = useCallback(
(index: number): string[] => annotations.filter((_, idx) => idx !== index).map(({ key }) => key),
[annotations]
);
return (
<>
@@ -34,43 +23,50 @@ const AnnotationsField: FC<Props> = ({ control, register }) => {
{({ 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);
}}
{fields.map((field, index) => (
<div key={`${field.annotationKey}-${index}`} className={styles.flexRow}>
<Field
className={styles.field}
invalid={!!errors.annotations?.[index]?.key?.message}
error={errors.annotations?.[index]?.key?.message}
>
<InputControl
as={AnnotationKeyInput}
width={15}
name={`annotations[${index}].key`}
existingKeys={existingKeys(index)}
control={control}
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
/>
</div>
);
})}
</Field>
<Field
className={cx(styles.flexRowItemMargin, styles.field)}
invalid={!!errors.annotations?.[index]?.value?.message}
error={errors.annotations?.[index]?.value?.message}
>
<TextArea
name={`annotations[${index}].value`}
className={styles.annotationTextArea}
ref={register({ required: { value: !!annotations[index]?.key, message: 'Required.' } })}
placeholder={`value`}
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"
size="sm"
onClick={() => {
append({});
}}
@@ -85,32 +81,31 @@ const AnnotationsField: FC<Props> = ({ control, register }) => {
);
};
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};
`,
};
const getStyles = (theme: GrafanaTheme) => ({
annotationTextArea: css`
width: 450px;
height: 76px;
`,
addAnnotationsButton: css`
flex-grow: 0;
align-self: flex-start;
margin-left: 124px;
`,
flexColumn: css`
display: flex;
flex-direction: column;
`,
field: css`
margin-bottom: ${theme.spacing.xs};
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
flexRowItemMargin: css`
margin-left: ${theme.spacing.xs};
`,
});
export default AnnotationsField;

View File

@@ -0,0 +1,54 @@
import { SelectableValue } from '@grafana/data';
import { Field, InputControl, Select } from '@grafana/ui';
import React, { FC, useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { RuleFormValues } from '../../types/rule-form';
export const ConditionField: FC = () => {
const { watch, setValue, errors } = useFormContext<RuleFormValues>();
const queries = watch('queries');
const condition = watch('condition');
const options = useMemo(
(): SelectableValue[] =>
queries
.filter((q) => !!q.refId)
.map((q) => ({
value: q.refId,
label: q.refId,
})),
[queries]
);
// if option no longer exists, reset it
useEffect(() => {
if (condition && !options.find(({ value }) => value === condition)) {
setValue('condition', null);
}
}, [condition, options, setValue]);
return (
<Field
label="Condition"
description="The query or expression that will be alerted on"
error={errors.condition?.message}
invalid={!!errors.condition?.message}
>
<InputControl
width={42}
name="condition"
as={Select}
onChange={(values: SelectableValue[]) => values[0]?.value ?? null}
options={options}
rules={{
required: {
value: true,
message: 'Please select the condition to alert on',
},
}}
noOptionsMessage="No queries defined"
/>
</Field>
);
};

View File

@@ -0,0 +1,149 @@
import React, { FC, useState } from 'react';
import { Field, Input, Select, useStyles, InputControl, InlineLabel, Switch } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext, ValidationOptions } from 'react-hook-form';
import { RuleFormType, RuleFormValues, TimeOptions } from '../../types/rule-form';
import { ConditionField } from './ConditionField';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
const timeRangeValidationOptions: ValidationOptions = {
required: {
value: true,
message: 'Required.',
},
pattern: {
value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`),
message: `Must be of format "(number)(unit)", for example "1m". Available units: ${Object.values(TimeOptions).join(
', '
)}`,
},
};
const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
label: key[0].toUpperCase() + key.slice(1),
value: value,
}));
export const ConditionsStep: FC = () => {
const styles = useStyles(getStyles);
const [showErrorHandling, setShowErrorHandling] = useState(false);
const { register, control, watch, errors } = useFormContext<RuleFormValues>();
const type = watch('type');
return (
<RuleEditorSection stepNo={3} title="Define alert conditions">
{type === RuleFormType.threshold && (
<>
<ConditionField />
<Field label="Evaluate">
<div className={styles.flexRow}>
<InlineLabel width={16} tooltip="How often the alert will be evaluated to see if it fires">
Evaluate every
</InlineLabel>
<Field
className={styles.inlineField}
error={errors.evaluateEvery?.message}
invalid={!!errors.evaluateEvery?.message}
>
<Input width={8} ref={register(timeRangeValidationOptions)} name="evaluateEvery" />
</Field>
<InlineLabel
width={7}
tooltip='Once condition is breached, alert will go into pending state. If it is pending for longer than the "for" value, it will become a firing alert.'
>
for
</InlineLabel>
<Field
className={styles.inlineField}
error={errors.evaluateFor?.message}
invalid={!!errors.evaluateFor?.message}
>
<Input width={8} ref={register(timeRangeValidationOptions)} name="evaluateFor" />
</Field>
</div>
</Field>
<Field label="Configure no data and error handling" horizontal={true} className={styles.switchField}>
<Switch value={showErrorHandling} onChange={() => setShowErrorHandling(!showErrorHandling)} />
</Field>
{showErrorHandling && (
<>
<Field label="Alert state if no data or all values are null">
<InputControl
as={GrafanaAlertStatePicker}
name="noDataState"
width={42}
onChange={(values) => values[0]?.value}
/>
</Field>
<Field label="Alert state if execution error or timeout">
<InputControl
as={GrafanaAlertStatePicker}
name="execErrState"
width={42}
onChange={(values) => values[0]?.value}
/>
</Field>
</>
)}
</>
)}
{type === RuleFormType.system && (
<>
<Field label="For" description="Expression has to be true for this long for the alert to be fired.">
<div className={styles.flexRow}>
<Field invalid={!!errors.forTime?.message} error={errors.forTime?.message} className={styles.inlineField}>
<Input
ref={register({ pattern: { value: /^\d+$/, message: 'Must be a postive integer.' } })}
name="forTime"
width={8}
/>
</Field>
<InputControl
name="forTimeUnit"
as={Select}
options={timeOptions}
control={control}
width={15}
className={styles.timeUnit}
onChange={(values) => values[0]?.value}
/>
</div>
</Field>
</>
)}
</RuleEditorSection>
);
};
const getStyles = (theme: GrafanaTheme) => ({
inlineField: css`
margin-bottom: 0;
`,
flexRow: css`
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: flex-start;
align-items: flex-start;
`,
numberInput: css`
width: 200px;
& + & {
margin-left: ${theme.spacing.sm};
}
`,
timeUnit: css`
margin-left: ${theme.spacing.xs};
`,
switchField: css`
display: inline-flex;
flex-direction: row-reverse;
margin-top: ${theme.spacing.md};
& > div:first-child {
margin-left: ${theme.spacing.sm};
}
`,
});

View File

@@ -0,0 +1,17 @@
import React, { FC } from 'react';
import LabelsField from './LabelsField';
import AnnotationsField from './AnnotationsField';
import { RuleEditorSection } from './RuleEditorSection';
export const DetailsStep: FC = () => {
return (
<RuleEditorSection
stepNo={4}
title="Add details for your alert"
description="Write a summary and add labels to help you better manage your alerts"
>
<AnnotationsField />
<LabelsField />
</RuleEditorSection>
);
};

View File

@@ -1,17 +0,0 @@
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,19 @@
import { TextArea } from '@grafana/ui';
import React, { FC } from 'react';
interface Props {
value?: string;
onChange: (value: string) => void;
dataSourceName: string; // will be a prometheus or loki datasource
}
// @TODO implement proper prom/loki query editor here
export const ExpressionEditor: FC<Props> = ({ value, onChange, dataSourceName }) => {
return (
<TextArea
placeholder="Enter a promql expression"
value={value}
onChange={(evt) => onChange((evt.target as HTMLTextAreaElement).value)}
/>
);
};

View File

@@ -0,0 +1,16 @@
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { SelectBaseProps } from '@grafana/ui/src/components/Select/types';
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
import React, { FC } from 'react';
type Props = Omit<SelectBaseProps<GrafanaAlertState>, 'options'>;
const options: SelectableValue[] = [
{ value: GrafanaAlertState.Alerting, label: 'Alerting' },
{ value: GrafanaAlertState.NoData, label: 'No Data' },
{ value: GrafanaAlertState.KeepLastState, label: 'Keep Last State' },
{ value: GrafanaAlertState.OK, label: 'OK' },
];
export const GrafanaAlertStatePicker: FC<Props> = (props) => <Select options={options} {...props} />;

View File

@@ -0,0 +1,27 @@
import { TextArea } from '@grafana/ui';
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
import React, { FC, useState } from 'react';
interface Props {
value?: GrafanaQuery[];
onChange: (value: GrafanaQuery[]) => void;
}
// @TODO replace with actual query editor once it's done
export const GrafanaQueryEditor: FC<Props> = ({ value, onChange }) => {
const [content, setContent] = useState(JSON.stringify(value || [], null, 2));
const onChangeHandler = (e: React.FormEvent<HTMLTextAreaElement>) => {
const val = (e.target as HTMLTextAreaElement).value;
setContent(val);
try {
const parsed = JSON.parse(val);
if (parsed && Array.isArray(parsed)) {
console.log('queries changed');
onChange(parsed);
}
} catch (e) {
console.log('invalid json');
}
};
return <TextArea rows={20} value={content} onChange={onChangeHandler} />;
};

View File

@@ -1,50 +1,62 @@
import React from 'react';
import { Button, Field, FieldArray, FormAPI, Input, InlineLabel, IconButton, Label, stylesFactory } from '@grafana/ui';
import React, { FC } from 'react';
import { Button, Field, FieldArray, Input, InlineLabel, Label, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { config } from 'app/core/config';
import { css, cx } from '@emotion/css';
import { useFormContext } from 'react-hook-form';
import { RuleFormValues } from '../../types/rule-form';
interface Props extends Pick<FormAPI<{}>, 'register' | 'control'> {
interface Props {
className?: string;
}
const LabelsField = (props: Props) => {
const styles = getStyles(config.theme);
const { register, control } = props;
const LabelsField: FC<Props> = ({ className }) => {
const styles = useStyles(getStyles);
const { register, control, watch, errors } = useFormContext<RuleFormValues>();
const labels = watch('labels');
return (
<div className={props.className}>
<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={12}>Labels</InlineLabel>
<InlineLabel width={15}>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}>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.key?.message}
error={errors.labels?.[index]?.key?.message}
>
<Input
ref={register()}
ref={register({ required: { value: !!labels[index]?.value, message: 'Required.' } })}
name={`labels[${index}].key`}
placeholder="key"
defaultValue={field.key}
/>
</Field>
<div className={styles.equalSign}>=</div>
<Field className={styles.labelInput}>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.value?.message}
error={errors.labels?.[index]?.value?.message}
>
<Input
ref={register()}
ref={register({ required: { value: !!labels[index]?.key, message: 'Required.' } })}
name={`labels[${index}].value`}
placeholder="value"
defaultValue={field.value}
/>
</Field>
<IconButton
<Button
className={styles.deleteLabelButton}
aria-label="delete label"
name="trash-alt"
icon="trash-alt"
variant="secondary"
onClick={() => {
remove(index);
}}
@@ -58,7 +70,6 @@ const LabelsField = (props: Props) => {
icon="plus-circle"
type="button"
variant="secondary"
size="sm"
onClick={() => {
append({});
}}
@@ -75,8 +86,11 @@ const LabelsField = (props: Props) => {
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
margin-top: ${theme.spacing.md};
`,
flexColumn: css`
display: flex;
flex-direction: column;
@@ -90,6 +104,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-left: ${theme.spacing.xs};
}
`,
deleteLabelButton: css`
margin-left: ${theme.spacing.xs};
align-self: flex-start;
`,
addLabelButton: css`
flex-grow: 0;
align-self: flex-start;
@@ -98,21 +116,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
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};
align-self: flex-start;
width: 28px;
justify-content: center;
margin-left: ${theme.spacing.xs};
`,
labelInput: css`
width: 200px;
width: 207px;
margin-bottom: ${theme.spacing.sm};
& + & {
margin-left: ${theme.spacing.sm};
}
`,
};
});
};
export default LabelsField;

View File

@@ -0,0 +1,47 @@
import React, { FC } from 'react';
import { Field, InputControl } from '@grafana/ui';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { ExpressionEditor } from './ExpressionEditor';
import { GrafanaQueryEditor } from './GrafanaQueryEditor';
import { isArray } from 'lodash';
// @TODO get proper query editors in
export const QueryStep: FC = () => {
const { control, watch, errors } = useFormContext<RuleFormValues>();
const type = watch('type');
const dataSourceName = watch('dataSourceName');
return (
<RuleEditorSection stepNo={2} title="Create a query to be alerted on">
{type === RuleFormType.system && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
dataSourceName={dataSourceName}
as={ExpressionEditor}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
/>
</Field>
)}
{type === RuleFormType.threshold && (
<Field
invalid={!!errors.queries}
error={(!!errors.queries && 'Must provide at least one valid query.') || undefined}
>
<InputControl
name="queries"
as={GrafanaQueryEditor}
control={control}
rules={{
validate: (queries) => isArray(queries) && !!queries.length,
}}
/>
</Field>
)}
</RuleEditorSection>
);
};

View File

@@ -0,0 +1,53 @@
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { FieldSet, useStyles } from '@grafana/ui';
import React, { FC } from 'react';
export interface RuleEditorSectionProps {
title: string;
stepNo: number;
description?: string;
}
export const RuleEditorSection: FC<RuleEditorSectionProps> = ({ title, stepNo, children, description }) => {
const styles = useStyles(getStyles);
return (
<div className={styles.parent}>
<div>
<span className={styles.stepNo}>{stepNo}</span>
</div>
<div className={styles.content}>
<FieldSet label={title}>
{description && <p className={styles.description}>{description}</p>}
{children}
</FieldSet>
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme) => ({
parent: css`
display: flex;
flex-direction: row;
`,
description: css`
margin-top: -${theme.spacing.md};
`,
stepNo: css`
display: inline-block;
width: ${theme.spacing.xl};
height: ${theme.spacing.xl};
line-height: ${theme.spacing.xl};
border-radius: ${theme.spacing.md};
text-align: center;
color: ${theme.colors.textStrong};
background-color: ${theme.colors.bg3};
font-size: ${theme.typography.size.lg};
margin-right: ${theme.spacing.md};
`,
content: css`
flex: 1;
`,
});

View File

@@ -0,0 +1,15 @@
import React, { FC } from 'react';
import { FolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/FolderPicker';
export interface Folder {
title: string;
id: number;
}
export interface Props extends Omit<FolderPickerProps, 'initiailTitle' | 'initialFolderId'> {
value?: Folder;
}
export const RuleFolderPicker: FC<Props> = ({ value, ...props }) => (
<FolderPicker showRoot={false} allowEmpty={true} initialTitle={value?.title} initialFolderId={value?.id} {...props} />
);