mirror of https://github.com/grafana/grafana
Alerting: Rule edit form (#32877)
parent
727a24c1bb
commit
282c62d8bf
@ -0,0 +1,6 @@ |
||||
import React, { FC } from 'react'; |
||||
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; |
||||
|
||||
const RuleEditor: FC = () => <AlertRuleForm />; |
||||
|
||||
export default RuleEditor; |
||||
@ -0,0 +1,62 @@ |
||||
import { Cascader, CascaderOption } from '@grafana/ui'; |
||||
import React, { FC, useEffect, useMemo } from 'react'; |
||||
import { useDispatch } from 'react-redux'; |
||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector'; |
||||
import { fetchRulerRulesAction } from '../state/actions'; |
||||
|
||||
interface RuleGroupValue { |
||||
namespace: string; |
||||
group: string; |
||||
} |
||||
|
||||
interface Props { |
||||
value?: RuleGroupValue; |
||||
onChange: (value: RuleGroupValue) => void; |
||||
dataSourceName: string; |
||||
} |
||||
|
||||
const stringifyValue = ({ namespace, group }: RuleGroupValue) => namespace + '|||' + group; |
||||
const parseValue = (value: string): RuleGroupValue => { |
||||
const [namespace, group] = value.split('|||'); |
||||
return { namespace, group }; |
||||
}; |
||||
|
||||
export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName }) => { |
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules); |
||||
const dispatch = useDispatch(); |
||||
useEffect(() => { |
||||
dispatch(fetchRulerRulesAction(dataSourceName)); |
||||
}, [dataSourceName, dispatch]); |
||||
|
||||
const rulesConfig = rulerRequests[dataSourceName]?.result; |
||||
|
||||
const options = useMemo((): CascaderOption[] => { |
||||
if (rulesConfig) { |
||||
return Object.entries(rulesConfig).map(([namespace, group]) => { |
||||
return { |
||||
label: namespace, |
||||
value: namespace, |
||||
items: group.map(({ name }) => { |
||||
return { label: name, value: stringifyValue({ namespace, group: name }) }; |
||||
}), |
||||
}; |
||||
}); |
||||
} |
||||
return []; |
||||
}, [rulesConfig]); |
||||
|
||||
return ( |
||||
<Cascader |
||||
placeholder="Select a rule group" |
||||
onSelect={(value) => { |
||||
console.log('selected', value); |
||||
onChange(parseValue(value)); |
||||
}} |
||||
initialValue={value ? stringifyValue(value) : undefined} |
||||
displayAllSelectedLevels={true} |
||||
separator=" > " |
||||
options={options} |
||||
changeOnSelect={false} |
||||
/> |
||||
); |
||||
}; |
||||
@ -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,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); |
||||
} |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
}; |
||||
@ -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} />; |
||||
}; |
||||
@ -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} /> |
||||
); |
||||
@ -0,0 +1,23 @@ |
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; |
||||
import React, { FC } from 'react'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||
import { isGrafanaRulerRule } from '../../utils/rules'; |
||||
import { Expression } from '../Expression'; |
||||
|
||||
interface Props { |
||||
rule: CombinedRule; |
||||
rulesSource: RulesSource; |
||||
} |
||||
|
||||
export const RuleQuery: FC<Props> = ({ rule, rulesSource }) => { |
||||
const { rulerRule } = rule; |
||||
|
||||
if (rulesSource !== GRAFANA_RULES_SOURCE_NAME) { |
||||
return <Expression expression={rule.query} rulesSource={rulesSource} />; |
||||
} |
||||
if (rulerRule && isGrafanaRulerRule(rulerRule)) { |
||||
// @TODO: better grafana queries vizualization
|
||||
return <pre>{JSON.stringify(rulerRule.grafana_alert.data, null, 2)}</pre>; |
||||
} |
||||
return <pre>@TODO: handle grafana prom rule case</pre>; |
||||
}; |
||||
@ -0,0 +1,24 @@ |
||||
import { DataSourceInstanceSettings } from '@grafana/data'; |
||||
import { useEffect, useMemo } from 'react'; |
||||
import { useDispatch } from 'react-redux'; |
||||
import { fetchRulerRulesIfNotFetchedYet } from '../state/actions'; |
||||
import { getAllDataSources } from '../utils/config'; |
||||
import { DataSourceType, getRulesDataSources } from '../utils/datasource'; |
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; |
||||
|
||||
export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { |
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules); |
||||
const dispatch = useDispatch(); |
||||
|
||||
// try fetching rules for each prometheus to see if it has ruler
|
||||
useEffect(() => { |
||||
getAllDataSources() |
||||
.filter((ds) => ds.type === DataSourceType.Prometheus) |
||||
.forEach((ds) => dispatch(fetchRulerRulesIfNotFetchedYet(ds.name))); |
||||
}, [dispatch]); |
||||
|
||||
return useMemo( |
||||
() => getRulesDataSources().filter((ds) => ds.type === DataSourceType.Loki || !!rulerRequests[ds.name]?.result), |
||||
[rulerRequests] |
||||
); |
||||
} |
||||
@ -0,0 +1,59 @@ |
||||
export const SAMPLE_QUERIES = [ |
||||
{ |
||||
refId: 'A', |
||||
queryType: '', |
||||
relativeTimeRange: { |
||||
from: 30, |
||||
to: 0, |
||||
}, |
||||
model: { |
||||
datasource: 'gdev-testdata', |
||||
datasourceUid: '000000004', |
||||
intervalMs: 1000, |
||||
maxDataPoints: 100, |
||||
pulseWave: { |
||||
offCount: 6, |
||||
offValue: 1, |
||||
onCount: 6, |
||||
onValue: 10, |
||||
timeStep: 5, |
||||
}, |
||||
refId: 'A', |
||||
scenarioId: 'predictable_pulse', |
||||
stringInput: '', |
||||
}, |
||||
}, |
||||
{ |
||||
refId: 'B', |
||||
queryType: '', |
||||
relativeTimeRange: { |
||||
from: 0, |
||||
to: 0, |
||||
}, |
||||
model: { |
||||
conditions: [ |
||||
{ |
||||
evaluator: { |
||||
params: [3], |
||||
type: 'gt', |
||||
}, |
||||
operator: { |
||||
type: 'and', |
||||
}, |
||||
query: { |
||||
Params: ['A'], |
||||
}, |
||||
reducer: { |
||||
type: 'last', |
||||
}, |
||||
}, |
||||
], |
||||
datasource: '__expr__', |
||||
datasourceUid: '-100', |
||||
intervalMs: 1000, |
||||
maxDataPoints: 100, |
||||
refId: 'B', |
||||
type: 'classic_conditions', |
||||
}, |
||||
}, |
||||
]; |
||||
@ -0,0 +1,38 @@ |
||||
import { GrafanaQuery, GrafanaAlertState } from 'app/types/unified-alerting-dto'; |
||||
|
||||
export enum RuleFormType { |
||||
threshold = 'threshold', |
||||
system = 'system', |
||||
} |
||||
|
||||
export enum TimeOptions { |
||||
seconds = 's', |
||||
minutes = 'm', |
||||
hours = 'h', |
||||
days = 'd', |
||||
} |
||||
|
||||
export interface RuleFormValues { |
||||
// common
|
||||
name: string; |
||||
type?: RuleFormType; |
||||
dataSourceName: string | null; |
||||
|
||||
labels: Array<{ key: string; value: string }>; |
||||
annotations: Array<{ key: string; value: string }>; |
||||
|
||||
// threshold alerts
|
||||
queries: GrafanaQuery[]; |
||||
condition: string | null; // refId of the query that gets alerted on
|
||||
noDataState: GrafanaAlertState; |
||||
execErrState: GrafanaAlertState; |
||||
folder: { title: string; id: number } | null; |
||||
evaluateEvery: string; |
||||
evaluateFor: string; |
||||
|
||||
// system alerts
|
||||
location?: { namespace: string; group: string }; |
||||
forTime: number; |
||||
forTimeUnit: string; |
||||
expression: string; |
||||
} |
||||
@ -0,0 +1,39 @@ |
||||
import { describeInterval } from '@grafana/data/src/datetime/rangeutil'; |
||||
import { RulerAlertingRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
import { RuleFormValues } from '../types/rule-form'; |
||||
import { arrayToRecord } from './misc'; |
||||
|
||||
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO { |
||||
const { name, expression, forTime, forTimeUnit } = values; |
||||
return { |
||||
alert: name, |
||||
for: `${forTime}${forTimeUnit}`, |
||||
annotations: arrayToRecord(values.annotations || []), |
||||
labels: arrayToRecord(values.labels || []), |
||||
expr: expression, |
||||
}; |
||||
} |
||||
|
||||
function intervalToSeconds(interval: string): number { |
||||
const { sec, count } = describeInterval(interval); |
||||
return sec * count; |
||||
} |
||||
|
||||
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): RulerGrafanaRuleDTO { |
||||
const { name, condition, noDataState, execErrState, evaluateFor, queries } = values; |
||||
if (condition) { |
||||
return { |
||||
grafana_alert: { |
||||
title: name, |
||||
condition, |
||||
for: intervalToSeconds(evaluateFor), // @TODO provide raw string once backend supports it
|
||||
no_data_state: noDataState, |
||||
exec_err_state: execErrState, |
||||
data: queries, |
||||
annotations: arrayToRecord(values.annotations || []), |
||||
labels: arrayToRecord(values.labels || []), |
||||
}, |
||||
}; |
||||
} |
||||
throw new Error('Cannot create rule without specifying alert condition'); |
||||
} |
||||
Loading…
Reference in new issue