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