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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1378 additions and 499 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ awsconfig
.yarn/
vendor/
/docs/menu.yaml
/requests
# Enterprise emails
/emails/templates/enterprise_*

View File

@ -26,9 +26,11 @@ export interface DataSourcePickerProps {
metrics?: boolean;
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
pluginId?: string;
noDefault?: boolean;
width?: number;
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
}
/**
@ -106,7 +108,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
}
getDataSourceOptions() {
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId } = this.props;
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId, alerting, filter } = this.props;
const options = this.dataSourceSrv
.getList({
tracing,
@ -116,6 +118,8 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
variables,
annotations,
pluginId,
alerting,
filter,
})
.map((ds) => ({
value: ds.name,
@ -149,7 +153,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage="No datasources found"
value={value}
value={value ?? null}
invalid={!!error}
getOptionLabel={(o) => {
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {

View File

@ -40,6 +40,9 @@ export interface GetDataSourceListFilters {
/** Only return data sources that support annotations */
annotations?: boolean;
/** Only filter data sources that support alerting */
alerting?: boolean;
/**
* By default only data sources that can be queried will be returned. Meaning they have tracing,
* metrics, logs or annotations flag set in plugin.json file
@ -54,6 +57,9 @@ export interface GetDataSourceListFilters {
/** filter list by plugin */
pluginId?: string;
/** apply a function to filter */
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
}
let singletonInstance: DataSourceSrv;

View File

@ -14,7 +14,7 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
value?: SelectableValue<T>;
value?: SelectableValue<T> | null;
invalid?: boolean;
}

View File

@ -19,10 +19,12 @@ export interface Props {
initialTitle?: string;
initialFolderId?: number;
permissionLevel?: 'View' | 'Edit';
allowEmpty?: boolean;
showRoot?: boolean;
}
interface State {
folder: SelectableValue<number>;
folder: SelectableValue<number> | null;
}
export class FolderPicker extends PureComponent<Props, State> {
@ -32,7 +34,7 @@ export class FolderPicker extends PureComponent<Props, State> {
super(props);
this.state = {
folder: {},
folder: null,
};
this.debouncedSearch = debounce(this.getOptions, 300, {
@ -47,6 +49,8 @@ export class FolderPicker extends PureComponent<Props, State> {
initialTitle: '',
enableCreateNew: false,
permissionLevel: 'Edit',
allowEmpty: false,
showRoot: true,
};
componentDidMount = async () => {
@ -54,7 +58,7 @@ export class FolderPicker extends PureComponent<Props, State> {
};
getOptions = async (query: string) => {
const { rootName, enableReset, initialTitle, permissionLevel } = this.props;
const { rootName, enableReset, initialTitle, permissionLevel, showRoot } = this.props;
const params = {
query,
type: 'dash-folder',
@ -64,8 +68,9 @@ export class FolderPicker extends PureComponent<Props, State> {
// TODO: move search to BackendSrv interface
// @ts-ignore
const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[];
const options: Array<SelectableValue<number>> = searchHits.map((hit) => ({ label: hit.title, value: hit.id }));
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) {
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) {
options.unshift({ label: rootName, value: 0 });
}
@ -111,15 +116,15 @@ export class FolderPicker extends PureComponent<Props, State> {
const options = await this.getOptions('');
let folder: SelectableValue<number> = { value: -1 };
let folder: SelectableValue<number> | null = null;
if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) {
folder = options.find((option) => option.value === initialFolderId) || { value: -1 };
folder = options.find((option) => option.value === initialFolderId) || null;
} else if (enableReset && initialTitle) {
folder = resetFolder;
}
if (folder.value === -1) {
if (!folder && !this.props.allowEmpty) {
if (contextSrv.isEditor) {
folder = rootFolder;
} else {
@ -139,8 +144,8 @@ export class FolderPicker extends PureComponent<Props, State> {
},
() => {
// if this is not the same as our initial value notify parent
if (folder.value !== initialFolderId) {
this.props.onChange({ id: folder.value!, title: folder.text });
if (folder && folder.value !== initialFolderId) {
this.props.onChange({ id: folder.value!, title: folder.label! });
}
}
);

View File

@ -7,12 +7,12 @@ exports[`FolderPicker should render 1`] = `
<AsyncSelect
allowCustomValue={false}
defaultOptions={true}
defaultValue={Object {}}
defaultValue={null}
loadOptions={[Function]}
loadingMessage="Loading folders..."
onChange={[Function]}
onCreateOption={[Function]}
value={Object {}}
value={null}
/>
</div>
`;

View File

View File

@ -0,0 +1,6 @@
import React, { FC } from 'react';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
const RuleEditor: FC = () => <AlertRuleForm />;
export default RuleEditor;

View File

@ -113,7 +113,7 @@ describe('RuleList', () => {
} else if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
return Promise.resolve([
mockPromRuleNamespace({
name: '',
name: 'foofolder',
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groups: [
mockPromRuleGroup({
@ -132,7 +132,7 @@ describe('RuleList', () => {
const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(5);
expect(groups[0]).toHaveTextContent('grafana-group');
expect(groups[0]).toHaveTextContent('foofolder');
expect(groups[1]).toHaveTextContent('default > group-1');
expect(groups[2]).toHaveTextContent('default > group-1');
expect(groups[3]).toHaveTextContent('default > group-2');

View File

@ -7,7 +7,7 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA';
import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRules } from './state/actions';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
import {
getAllRulesSourceNames,
getRulesDataSources,
@ -27,8 +27,8 @@ export const RuleList: FC = () => {
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
dispatch(fetchAllPromAndRulerRules());
const interval = setInterval(() => dispatch(fetchAllPromAndRulerRules()), RULE_LIST_POLL_INTERVAL_MS);
dispatch(fetchAllPromAndRulerRulesAction());
const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};

View File

@ -9,10 +9,15 @@ export async function setRulerRuleGroup(
namespace: string,
group: RulerRuleGroupDTO
): Promise<void> {
await getBackendSrv().post(
`/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
group
);
await await getBackendSrv()
.fetch<unknown>({
method: 'POST',
url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
data: group,
showErrorAlert: false,
showSuccessAlert: false,
})
.toPromise();
}
// fetch all ruler rule namespaces and included groups

View File

@ -11,7 +11,7 @@ import { DataSourceType, isCloudRulesSource } from '../utils/datasource';
import { Well } from './Well';
interface Props {
query: string;
expression: string;
rulesSource: RulesSource;
}
@ -34,7 +34,7 @@ export const HighlightedQuery: FC<{ language: 'promql' | 'logql'; expr: string }
return <Editor plugins={plugins} value={slateValue} readOnly={true} />;
};
export const RuleQuery: FC<Props> = ({ query, rulesSource }) => {
export const Expression: FC<Props> = ({ expression: query, rulesSource }) => {
const styles = useStyles(getStyles);
return (

View File

@ -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}
/>
);
};

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} />
);

View File

@ -3,13 +3,13 @@ import React, { FC } from 'react';
import { useStyles } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { RuleQuery } from '../RuleQuery';
import { isAlertingRule } from '../../utils/rules';
import { isCloudRulesSource } from '../../utils/datasource';
import { Annotation } from '../Annotation';
import { AlertLabels } from '../AlertLabels';
import { AlertInstancesTable } from './AlertInstancesTable';
import { DetailsField } from './DetailsField';
import { RuleQuery } from './RuleQuery';
interface Props {
rule: CombinedRule;
@ -33,7 +33,7 @@ export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
</DetailsField>
)}
<DetailsField label="Expression" className={cx({ [styles.exprRow]: !!annotations.length })} horizontal={true}>
<RuleQuery query={rule.query} rulesSource={rulesSource} />
<RuleQuery rule={rule} rulesSource={rulesSource} />
</DetailsField>
{annotations.map(([key, value]) => (
<DetailsField key={key} label={key} horizontal={true}>

View File

@ -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>;
};

View File

@ -67,6 +67,8 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
actionIcons.push(<ActionIcon key="manage-perms" icon="lock" tooltip="manage permissions" />);
}
const groupName = isCloudRulesSource(rulesSource) ? `${namespace} > ${group.name}` : namespace;
return (
<div className={styles.wrapper} data-testid="rule-group">
<div className={styles.header} data-testid="rule-group-header">
@ -82,10 +84,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
<img className={styles.dataSourceIcon} src={rulesSource.meta.info.logos.small} />
</Tooltip>
)}
<h6 className={styles.heading}>
{namespace && `${namespace} > `}
{group.name}
</h6>
<h6 className={styles.heading}>{groupName}</h6>
<div className={styles.spacer} />
<div className={styles.headerStats}>
{group.rules.length} {pluralize('rule', group.rules.length)}

View File

@ -1,8 +1,8 @@
import { CombinedRule, CombinedRuleNamespace, Rule, RuleNamespace } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useMemo, useRef } from 'react';
import { getAllRulesSources, isCloudRulesSource } from '../utils/datasource';
import { isAlertingRule, isAlertingRulerRule } from '../utils/rules';
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
interface CacheValue {
@ -49,13 +49,21 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
annotations: rule.annotations || {},
rulerRule: rule,
}
: {
: isRecordingRulerRule(rule)
? {
name: rule.record,
query: rule.expr,
labels: rule.labels || {},
annotations: {},
rulerRule: rule,
}
: {
name: rule.grafana_alert.title,
query: '',
labels: rule.grafana_alert.labels || {},
annotations: rule.grafana_alert.annotations || {},
rulerRule: rule,
}
),
})),
};
@ -99,6 +107,17 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
});
const result = Object.values(namespaces);
if (isGrafanaRulesSource(rulesSource)) {
// merge all groups in case of grafana
result.forEach((namespace) => {
namespace.groups = [
{
name: 'default',
rules: namespace.groups.flatMap((g) => g.rules).sort((a, b) => a.name.localeCompare(b.name)),
},
];
});
}
cache.current[rulesSourceName] = { promRules, rulerRules, result };
return result;
})

View File

@ -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]
);
}

View File

@ -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',
},
},
];

View File

@ -1,14 +1,24 @@
import { AppEvents } from '@grafana/data';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { appEvents } from 'app/core/core';
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
import { ThunkResult } from 'app/types';
import { RuleLocation, RuleNamespace } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
import { fetchRules } from '../api/prometheus';
import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesNamespace, setRulerRuleGroup } from '../api/ruler';
import { getAllRulesSourceNames, isCloudRulesSource } from '../utils/datasource';
import {
deleteRulerRulesGroup,
fetchRulerRules,
fetchRulerRulesGroup,
fetchRulerRulesNamespace,
setRulerRuleGroup,
} from '../api/ruler';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../utils/datasource';
import { withSerializedError } from '../utils/redux';
import { hashRulerRule } from '../utils/rules';
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
import { hashRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules',
@ -35,7 +45,18 @@ export const fetchSilencesAction = createAsyncThunk(
}
);
export function fetchAllPromAndRulerRules(force = false): ThunkResult<void> {
// this will only trigger ruler rules fetch if rules are not loaded yet and request is not in flight
export function fetchRulerRulesIfNotFetchedYet(dataSourceName: string): ThunkResult<void> {
return (dispatch, getStore) => {
const { rulerRules } = getStore().unifiedAlerting;
const resp = rulerRules[dataSourceName];
if (!resp?.result && !(resp && isRulerNotSupportedResponse(resp)) && !resp?.loading) {
dispatch(fetchRulerRulesAction(dataSourceName));
}
};
}
export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<void> {
return (dispatch, getStore) => {
const { promRules, rulerRules } = getStore().unifiedAlerting;
getAllRulesSourceNames().map((name) => {
@ -78,3 +99,71 @@ export function deleteRuleAction(ruleLocation: RuleLocation): ThunkResult<void>
return dispatch(fetchRulerRulesAction(ruleSourceName));
};
}
async function saveLotexRule(values: RuleFormValues): Promise<void> {
const { dataSourceName, location } = values;
if (dataSourceName && location) {
const existingGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
const rule = formValuesToRulerAlertingRuleDTO(values);
// @TODO handle "update" case
const payload: RulerRuleGroupDTO = existingGroup
? {
...existingGroup,
rules: [...existingGroup.rules, rule],
}
: {
name: location.group,
rules: [rule],
};
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
} else {
throw new Error('Data source and location must be specified');
}
}
async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
const { folder, evaluateEvery } = values;
if (folder) {
const existingNamespace = await fetchRulerRulesNamespace(GRAFANA_RULES_SOURCE_NAME, folder.title);
// set group name to rule name, but be super paranoid and check that this group does not already exist
let group = values.name;
let idx = 1;
while (!!existingNamespace.find((g) => g.name === group)) {
group = `${values.name}-${++idx}`;
}
const rule = formValuesToRulerGrafanaRuleDTO(values);
const payload: RulerRuleGroupDTO = {
name: group,
interval: evaluateEvery,
rules: [rule],
};
await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload);
} else {
throw new Error('Folder must be specified');
}
}
export const saveRuleFormAction = createAsyncThunk(
'unifiedalerting/saveRuleForm',
(values: RuleFormValues): Promise<void> =>
withSerializedError(
(async () => {
const { type } = values;
// in case of system (cortex/loki)
if (type === RuleFormType.system) {
await saveLotexRule(values);
// in case of grafana managed
} else if (type === RuleFormType.threshold) {
await saveGrafanaRule(values);
} else {
throw new Error('Unexpected rule form type');
}
appEvents.emit(AppEvents.alertSuccess, ['Rule saved.']);
})()
)
);

View File

@ -1,10 +1,11 @@
import { combineReducers } from 'redux';
import { createAsyncMapSlice } from '../utils/redux';
import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
import {
fetchAlertManagerConfigAction,
fetchPromRulesAction,
fetchRulerRulesAction,
fetchSilencesAction,
saveRuleFormAction,
} from './actions';
export const reducer = combineReducers({
@ -17,6 +18,9 @@ export const reducer = combineReducers({
).reducer,
silences: createAsyncMapSlice('silences', fetchSilencesAction, (alertManagerSourceName) => alertManagerSourceName)
.reducer,
ruleForm: combineReducers({
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
}),
});
export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@ -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;
}

View File

@ -51,6 +51,12 @@ export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSour
return rulesSource !== GRAFANA_RULES_SOURCE_NAME;
}
export function isGrafanaRulesSource(
rulesSource: RulesSource | string
): rulesSource is typeof GRAFANA_RULES_SOURCE_NAME {
return rulesSource === GRAFANA_RULES_SOURCE_NAME;
}
export function getDataSourceByName(name: string): DataSourceInstanceSettings<DataSourceJsonData> | undefined {
return getAllDataSources().find((source) => source.name === name);
}

View File

@ -26,3 +26,10 @@ export function hash(value: string): number {
}
return hash;
}
export function arrayToRecord(items: Array<{ key: string; value: string }>): Record<string, string> {
return items.reduce<Record<string, string>>((rec, { key, value }) => {
rec[key] = value;
return rec;
}, {});
}

View File

@ -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');
}

View File

@ -1,6 +1,7 @@
import {
PromRuleType,
RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
RulerRecordingRuleDTO,
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
@ -25,6 +26,10 @@ export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecording
return 'record' in rule;
}
export function isGrafanaRulerRule(rule: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
return 'grafana_alert' in rule;
}
export function alertInstanceKey(alert: Alert): string {
return JSON.stringify(alert.labels);
}

View File

@ -164,6 +164,9 @@ export class DatasourceSrv implements DataSourceService {
if (filters.metrics && !x.meta.metrics) {
return false;
}
if (filters.alerting && !x.meta.alerting) {
return false;
}
if (filters.tracing && !x.meta.tracing) {
return false;
}
@ -173,6 +176,9 @@ export class DatasourceSrv implements DataSourceService {
if (filters.pluginId && x.meta.id !== filters.pluginId) {
return false;
}
if (filters.filter && !filters.filter(x)) {
return false;
}
if (
!filters.all &&
x.meta.metrics !== true &&
@ -212,7 +218,7 @@ export class DatasourceSrv implements DataSourceService {
return 0;
});
if (!filters.pluginId) {
if (!filters.pluginId && !filters.alerting) {
if (filters.mixed) {
base.push(this.getInstanceSettings('-- Mixed --')!);
}

View File

@ -393,10 +393,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/alerting/new',
pageClass: 'page-alerting',
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm'
)
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
),
},
{
@ -410,10 +407,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/alerting/:id/edit',
pageClass: 'page-alerting',
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm'
)
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
),
},
{

View File

@ -69,7 +69,6 @@ export interface PromResponse<T> {
export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[] }>;
// Ruler rule DTOs
interface RulerRuleBaseDTO {
expr: string;
labels?: Labels;
@ -85,7 +84,49 @@ export interface RulerAlertingRuleDTO extends RulerRuleBaseDTO {
annotations?: Annotations;
}
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO;
export enum GrafanaAlertState {
Alerting = 'Alerting',
NoData = 'NoData',
KeepLastState = 'KeepLastState',
OK = 'OK',
}
export interface GrafanaQueryModel {
datasource: string;
datasourceUid: string;
refId: string;
[key: string]: any;
}
export interface GrafanaQuery {
refId: string;
queryType: string;
relativeTimeRange: {
from: number;
to: number;
};
model: GrafanaQueryModel;
}
export interface GrafanaRuleDefinition {
uid?: string;
title: string;
condition: string;
for: number; //@TODO Sofia will update to accept string
no_data_state: GrafanaAlertState;
exec_err_state: GrafanaAlertState;
data: GrafanaQuery[];
annotations: Annotations;
labels: Labels;
}
export interface RulerGrafanaRuleDTO {
grafana_alert: GrafanaRuleDefinition;
// labels?: Labels; @TODO to be discussed
// annotations?: Annotations;
}
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | RulerGrafanaRuleDTO;
export type RulerRuleGroupDTO = {
name: string;