mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: editing existing rules via UI (#33005)
This commit is contained in:
parent
cb6fe5e65b
commit
826d82fe95
@ -122,6 +122,9 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
folder = options.find((option) => option.value === initialFolderId) || null;
|
folder = options.find((option) => option.value === initialFolderId) || null;
|
||||||
} else if (enableReset && initialTitle) {
|
} else if (enableReset && initialTitle) {
|
||||||
folder = resetFolder;
|
folder = resetFolder;
|
||||||
|
} else if (initialTitle && initialFolderId === -1) {
|
||||||
|
// @TODO temporary, we don't know the id for alerting rule folder in some cases
|
||||||
|
folder = options.find((option) => option.label === initialTitle) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!folder && !this.props.allowEmpty) {
|
if (!folder && !this.props.allowEmpty) {
|
||||||
|
15
public/app/core/hooks/useCleanup.ts
Normal file
15
public/app/core/hooks/useCleanup.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { cleanUpAction, StateSelector } from '../actions/cleanUp';
|
||||||
|
|
||||||
|
export function useCleanup<T>(stateSelector: StateSelector<T>) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
//bit of a hack to unburden user from having to wrap stateSelcetor in a useCallback. Otherwise cleanup would happen on every render
|
||||||
|
const selectorRef = useRef(stateSelector);
|
||||||
|
selectorRef.current = stateSelector;
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(cleanUpAction({ stateSelector: selectorRef.current }));
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
}
|
@ -1,6 +1,69 @@
|
|||||||
import React, { FC } from 'react';
|
import { Alert, Button, InfoBox, LoadingPlaceholder } from '@grafana/ui';
|
||||||
|
import Page from 'app/core/components/Page/Page';
|
||||||
|
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||||
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||||
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
||||||
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
|
import { fetchExistingRuleAction } from './state/actions';
|
||||||
|
import { parseRuleIdentifier } from './utils/rules';
|
||||||
|
|
||||||
const RuleEditor: FC = () => <AlertRuleForm />;
|
interface ExistingRuleEditorProps {
|
||||||
|
identifier: RuleIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
|
||||||
|
useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule);
|
||||||
|
const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dispatched) {
|
||||||
|
dispatch(fetchExistingRuleAction(identifier));
|
||||||
|
}
|
||||||
|
}, [dispatched, dispatch, identifier]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Page.Contents>
|
||||||
|
<LoadingPlaceholder text="Loading rule..." />
|
||||||
|
</Page.Contents>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Page.Contents>
|
||||||
|
<Alert severity="error" title="Failed to load rule">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Page.Contents>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<Page.Contents>
|
||||||
|
<InfoBox severity="warning" title="Rule not found">
|
||||||
|
<p>Sorry! This rule does not exist.</p>
|
||||||
|
<a href="/alerting/list">
|
||||||
|
<Button>To rule list</Button>
|
||||||
|
</a>
|
||||||
|
</InfoBox>
|
||||||
|
</Page.Contents>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AlertRuleForm existing={result} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
|
||||||
|
|
||||||
|
const RuleEditor: FC<RuleEditorProps> = ({ match }) => {
|
||||||
|
const id = match.params.id;
|
||||||
|
if (id) {
|
||||||
|
const identifier = parseRuleIdentifier(decodeURIComponent(id));
|
||||||
|
return <ExistingRuleEditor key={id} identifier={identifier} />;
|
||||||
|
}
|
||||||
|
return <AlertRuleForm />;
|
||||||
|
};
|
||||||
|
|
||||||
export default RuleEditor;
|
export default RuleEditor;
|
||||||
|
@ -14,6 +14,9 @@ export async function fetchRules(dataSourceName: string): Promise<RuleNamespace[
|
|||||||
|
|
||||||
const nsMap: { [key: string]: RuleNamespace } = {};
|
const nsMap: { [key: string]: RuleNamespace } = {};
|
||||||
response.data.data.groups.forEach((group) => {
|
response.data.data.groups.forEach((group) => {
|
||||||
|
group.rules.forEach((rule) => {
|
||||||
|
rule.query = rule.query || ''; // @TODO temp fix, backend response ism issing query. remove once it's there
|
||||||
|
});
|
||||||
if (!nsMap[group.file]) {
|
if (!nsMap[group.file]) {
|
||||||
nsMap[group.file] = {
|
nsMap[group.file] = {
|
||||||
dataSourceName,
|
dataSourceName,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||||
import { getDatasourceAPIId } from '../utils/datasource';
|
import { getDatasourceAPIId } from '../utils/datasource';
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
|
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
|
||||||
@ -7,7 +7,7 @@ import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
|
|||||||
export async function setRulerRuleGroup(
|
export async function setRulerRuleGroup(
|
||||||
dataSourceName: string,
|
dataSourceName: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
group: RulerRuleGroupDTO
|
group: PostableRulerRuleGroupDTO
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await await getBackendSrv()
|
await await getBackendSrv()
|
||||||
.fetch<unknown>({
|
.fetch<unknown>({
|
||||||
|
@ -5,7 +5,7 @@ import { Select } from '@grafana/ui';
|
|||||||
import { getAllDataSources } from '../utils/config';
|
import { getAllDataSources } from '../utils/config';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChange: (alertManagerSourceName?: string) => void;
|
onChange: (alertManagerSourceName: string) => void;
|
||||||
current?: string;
|
current?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ export const AlertManagerPicker: FC<Props> = ({ onChange, current }) => {
|
|||||||
isMulti={false}
|
isMulti={false}
|
||||||
isClearable={false}
|
isClearable={false}
|
||||||
backspaceRemovesValue={false}
|
backspaceRemovesValue={false}
|
||||||
onChange={(value) => onChange(value.value)}
|
onChange={(value) => value.value && onChange(value.value)}
|
||||||
options={options}
|
options={options}
|
||||||
maxMenuHeight={500}
|
maxMenuHeight={500}
|
||||||
noOptionsMessage="No datasources found"
|
noOptionsMessage="No datasources found"
|
||||||
|
@ -45,6 +45,7 @@ export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName })
|
|||||||
return [];
|
return [];
|
||||||
}, [rulesConfig]);
|
}, [rulesConfig]);
|
||||||
|
|
||||||
|
// @TODO replace cascader with separate dropdowns
|
||||||
return (
|
return (
|
||||||
<Cascader
|
<Cascader
|
||||||
placeholder="Select a rule group"
|
placeholder="Select a rule group"
|
||||||
@ -55,6 +56,7 @@ export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName })
|
|||||||
initialValue={value ? stringifyValue(value) : undefined}
|
initialValue={value ? stringifyValue(value) : undefined}
|
||||||
displayAllSelectedLevels={true}
|
displayAllSelectedLevels={true}
|
||||||
separator=" > "
|
separator=" > "
|
||||||
|
key={JSON.stringify(options)}
|
||||||
options={options}
|
options={options}
|
||||||
changeOnSelect={false}
|
changeOnSelect={false}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { PageToolbar, ToolbarButton, useStyles, CustomScrollbar, Spinner, Alert } from '@grafana/ui';
|
import { PageToolbar, ToolbarButton, useStyles, CustomScrollbar, Spinner, Alert, InfoBox } from '@grafana/ui';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
import { AlertTypeStep } from './AlertTypeStep';
|
import { AlertTypeStep } from './AlertTypeStep';
|
||||||
@ -9,98 +9,105 @@ import { DetailsStep } from './DetailsStep';
|
|||||||
import { QueryStep } from './QueryStep';
|
import { QueryStep } from './QueryStep';
|
||||||
import { useForm, FormContext } from 'react-hook-form';
|
import { useForm, FormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||||
//import { locationService } from '@grafana/runtime';
|
|
||||||
import { RuleFormValues } from '../../types/rule-form';
|
|
||||||
import { SAMPLE_QUERIES } from '../../mocks/grafana-queries';
|
|
||||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||||
import { initialAsyncRequestState } from '../../utils/redux';
|
import { initialAsyncRequestState } from '../../utils/redux';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import { saveRuleFormAction } from '../../state/actions';
|
import { saveRuleFormAction } from '../../state/actions';
|
||||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||||
|
import { rulerRuleToFormValues, defaultFormValues } from '../../utils/rule-form';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
type Props = {};
|
type Props = {
|
||||||
|
existing?: RuleWithLocation;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultValues: RuleFormValues = Object.freeze({
|
export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||||
name: '',
|
|
||||||
labels: [{ key: '', value: '' }],
|
|
||||||
annotations: [{ key: '', value: '' }],
|
|
||||||
dataSourceName: null,
|
|
||||||
|
|
||||||
// threshold
|
|
||||||
folder: null,
|
|
||||||
queries: SAMPLE_QUERIES, // @TODO remove the sample eventually
|
|
||||||
condition: '',
|
|
||||||
noDataState: GrafanaAlertState.NoData,
|
|
||||||
execErrState: GrafanaAlertState.Alerting,
|
|
||||||
evaluateEvery: '1m',
|
|
||||||
evaluateFor: '5m',
|
|
||||||
|
|
||||||
// system
|
|
||||||
expression: '',
|
|
||||||
forTime: 1,
|
|
||||||
forTimeUnit: 'm',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AlertRuleForm: FC<Props> = () => {
|
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
const defaultValues: RuleFormValues = useMemo(() => {
|
||||||
return () => {
|
if (existing) {
|
||||||
dispatch(cleanUpAction({ stateSelector: (state) => state.unifiedAlerting.ruleForm }));
|
return rulerRuleToFormValues(existing);
|
||||||
};
|
}
|
||||||
}, [dispatch]);
|
return defaultFormValues;
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
const formAPI = useForm<RuleFormValues>({
|
const formAPI = useForm<RuleFormValues>({
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, watch } = formAPI;
|
const { handleSubmit, watch, errors } = formAPI;
|
||||||
|
|
||||||
|
const hasErrors = !!Object.values(errors).filter((x) => !!x).length;
|
||||||
|
|
||||||
const type = watch('type');
|
const type = watch('type');
|
||||||
const dataSourceName = watch('dataSourceName');
|
const dataSourceName = watch('dataSourceName');
|
||||||
|
|
||||||
const showStep2 = Boolean(dataSourceName && type);
|
const showStep2 = Boolean(type && (type === RuleFormType.threshold || !!dataSourceName));
|
||||||
|
|
||||||
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
|
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
|
||||||
|
useCleanup((state) => state.unifiedAlerting.ruleForm.saveRule);
|
||||||
|
|
||||||
const submit = (values: RuleFormValues) => {
|
const submit = (values: RuleFormValues, exitOnSave: boolean) => {
|
||||||
|
console.log('submit', values);
|
||||||
dispatch(
|
dispatch(
|
||||||
saveRuleFormAction({
|
saveRuleFormAction({
|
||||||
...values,
|
values: {
|
||||||
annotations: values.annotations.filter(({ key }) => !!key),
|
...values,
|
||||||
labels: values.labels.filter(({ key }) => !!key),
|
annotations: values.annotations?.filter(({ key }) => !!key) ?? [],
|
||||||
|
labels: values.labels?.filter(({ key }) => !!key) ?? [],
|
||||||
|
},
|
||||||
|
existing,
|
||||||
|
exitOnSave,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContext {...formAPI}>
|
<FormContext {...formAPI}>
|
||||||
<form onSubmit={handleSubmit(submit)} className={styles.form}>
|
<form onSubmit={handleSubmit((values) => submit(values, false))} className={styles.form}>
|
||||||
<PageToolbar title="Create alert rule" pageIcon="bell" className={styles.toolbar}>
|
<PageToolbar title="Create alert rule" pageIcon="bell" className={styles.toolbar}>
|
||||||
<ToolbarButton variant="default" disabled={submitState.loading}>
|
<Link to="/alerting/list">
|
||||||
Cancel
|
<ToolbarButton variant="default" disabled={submitState.loading} type="button">
|
||||||
</ToolbarButton>
|
Cancel
|
||||||
<ToolbarButton variant="primary" type="submit" disabled={submitState.loading}>
|
</ToolbarButton>
|
||||||
|
</Link>
|
||||||
|
<ToolbarButton
|
||||||
|
variant="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit((values) => submit(values, false))}
|
||||||
|
disabled={submitState.loading}
|
||||||
|
>
|
||||||
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
||||||
Save
|
Save
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton variant="primary" disabled={submitState.loading}>
|
<ToolbarButton
|
||||||
|
variant="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit((values) => submit(values, true))}
|
||||||
|
disabled={submitState.loading}
|
||||||
|
>
|
||||||
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
||||||
Save and exit
|
Save and exit
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
<div className={styles.contentOutter}>
|
<div className={styles.contentOutter}>
|
||||||
<CustomScrollbar autoHeightMin="100%">
|
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
|
||||||
<div className={styles.contentInner}>
|
<div className={styles.contentInner}>
|
||||||
|
{hasErrors && (
|
||||||
|
<InfoBox severity="error">
|
||||||
|
There are errors in the form below. Please fix them and try saving again.
|
||||||
|
</InfoBox>
|
||||||
|
)}
|
||||||
{submitState.error && (
|
{submitState.error && (
|
||||||
<Alert severity="error" title="Error saving rule">
|
<Alert severity="error" title="Error saving rule">
|
||||||
{submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)}
|
{submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<AlertTypeStep />
|
<AlertTypeStep editingExistingRule={!!existing} />
|
||||||
{showStep2 && (
|
{showStep2 && (
|
||||||
<>
|
<>
|
||||||
<QueryStep />
|
<QueryStep />
|
||||||
|
@ -24,7 +24,11 @@ const alertTypeOptions: SelectableValue[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AlertTypeStep: FC = () => {
|
interface Props {
|
||||||
|
editingExistingRule: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
const { register, control, watch, errors, setValue } = useFormContext<RuleFormValues>();
|
const { register, control, watch, errors, setValue } = useFormContext<RuleFormValues>();
|
||||||
@ -64,6 +68,7 @@ export const AlertTypeStep: FC = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
<div className={styles.flexRow}>
|
<div className={styles.flexRow}>
|
||||||
<Field
|
<Field
|
||||||
|
disabled={editingExistingRule}
|
||||||
label="Alert type"
|
label="Alert type"
|
||||||
className={styles.formInput}
|
className={styles.formInput}
|
||||||
error={errors.type?.message}
|
error={errors.type?.message}
|
||||||
@ -91,30 +96,32 @@ export const AlertTypeStep: FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
{ruleFormType === RuleFormType.system && (
|
||||||
className={styles.formInput}
|
<Field
|
||||||
label="Select data source"
|
className={styles.formInput}
|
||||||
error={errors.dataSourceName?.message}
|
label="Select data source"
|
||||||
invalid={!!errors.dataSourceName?.message}
|
error={errors.dataSourceName?.message}
|
||||||
>
|
invalid={!!errors.dataSourceName?.message}
|
||||||
<InputControl
|
>
|
||||||
as={DataSourcePicker as React.ComponentType<Omit<DataSourcePickerProps, 'current'>>}
|
<InputControl
|
||||||
valueName="current"
|
as={(DataSourcePicker as unknown) as React.ComponentType<Omit<DataSourcePickerProps, 'current'>>}
|
||||||
filter={dataSourceFilter}
|
valueName="current"
|
||||||
name="dataSourceName"
|
filter={dataSourceFilter}
|
||||||
noDefault={true}
|
name="dataSourceName"
|
||||||
control={control}
|
noDefault={true}
|
||||||
alerting={true}
|
control={control}
|
||||||
rules={{
|
alerting={true}
|
||||||
required: { value: true, message: 'Please select a data source' },
|
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
|
onChange={(ds: DataSourceInstanceSettings[]) => {
|
||||||
setValue('location', undefined);
|
// reset location if switching data sources, as differnet rules source will have different groups and namespaces
|
||||||
return ds[0]?.name ?? null;
|
setValue('location', undefined);
|
||||||
}}
|
return ds[0]?.name ?? null;
|
||||||
/>
|
}}
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{ruleFormType === RuleFormType.system && (
|
{ruleFormType === RuleFormType.system && (
|
||||||
<Field
|
<Field
|
||||||
|
@ -19,7 +19,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => {
|
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => {
|
||||||
const [isCustom, setIsCustom] = useState(false);
|
const isCustomByDefault = !!value && !Object.keys(AnnotationOptions).includes(value); // custom by default if value does not match any of available options
|
||||||
|
const [isCustom, setIsCustom] = useState(isCustomByDefault);
|
||||||
|
|
||||||
const annotationOptions = useMemo(
|
const annotationOptions = useMemo(
|
||||||
(): SelectableValue[] => [
|
(): SelectableValue[] => [
|
||||||
|
@ -2,7 +2,7 @@ import { GrafanaTheme, rangeUtil } from '@grafana/data';
|
|||||||
import { ConfirmModal, useStyles } from '@grafana/ui';
|
import { ConfirmModal, useStyles } from '@grafana/ui';
|
||||||
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
|
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
|
||||||
import React, { FC, Fragment, useState } from 'react';
|
import React, { FC, Fragment, useState } from 'react';
|
||||||
import { hashRulerRule, isAlertingRule } from '../../utils/rules';
|
import { getRuleIdentifier, isAlertingRule, stringifyRuleIdentifier } from '../../utils/rules';
|
||||||
import { CollapseToggle } from '../CollapseToggle';
|
import { CollapseToggle } from '../CollapseToggle';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { TimeToNow } from '../TimeToNow';
|
import { TimeToNow } from '../TimeToNow';
|
||||||
@ -44,12 +44,7 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
|
|||||||
const deleteRule = () => {
|
const deleteRule = () => {
|
||||||
if (ruleToDelete) {
|
if (ruleToDelete) {
|
||||||
dispatch(
|
dispatch(
|
||||||
deleteRuleAction({
|
deleteRuleAction(getRuleIdentifier(getRulesSourceName(rulesSource), namespace, group.name, ruleToDelete))
|
||||||
ruleSourceName: getRulesSourceName(rulesSource),
|
|
||||||
groupName: group.name,
|
|
||||||
namespace,
|
|
||||||
ruleHash: hashRulerRule(ruleToDelete),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
setRuleToDelete(undefined);
|
setRuleToDelete(undefined);
|
||||||
}
|
}
|
||||||
@ -134,7 +129,17 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
|
|||||||
href={createExploreLink(rulesSource.name, rule.query)}
|
href={createExploreLink(rulesSource.name, rule.query)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!!rulerRule && <ActionIcon icon="pen" tooltip="edit rule" />}
|
{!!rulerRule && (
|
||||||
|
<ActionIcon
|
||||||
|
icon="pen"
|
||||||
|
tooltip="edit rule"
|
||||||
|
href={`/alerting/${encodeURIComponent(
|
||||||
|
stringifyRuleIdentifier(
|
||||||
|
getRuleIdentifier(getRulesSourceName(rulesSource), namespace, group.name, rulerRule)
|
||||||
|
)
|
||||||
|
)}/edit`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!!rulerRule && (
|
{!!rulerRule && (
|
||||||
<ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rulerRule)} />
|
<ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rulerRule)} />
|
||||||
)}
|
)}
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
import { CombinedRule, CombinedRuleNamespace, Rule, RuleNamespace } from 'app/types/unified-alerting';
|
import {
|
||||||
|
CombinedRule,
|
||||||
|
CombinedRuleGroup,
|
||||||
|
CombinedRuleNamespace,
|
||||||
|
Rule,
|
||||||
|
RuleNamespace,
|
||||||
|
RulesSource,
|
||||||
|
} from 'app/types/unified-alerting';
|
||||||
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
|
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
|
||||||
@ -88,9 +95,7 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(group.rules ?? []).forEach((rule) => {
|
(group.rules ?? []).forEach((rule) => {
|
||||||
const existingRule = combinedGroup!.rules.find((existingRule) => {
|
const existingRule = getExistingRuleInGroup(rule, combinedGroup!, rulesSource);
|
||||||
return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule);
|
|
||||||
});
|
|
||||||
if (existingRule) {
|
if (existingRule) {
|
||||||
existingRule.promRule = rule;
|
existingRule.promRule = rule;
|
||||||
} else {
|
} else {
|
||||||
@ -126,6 +131,18 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
|
|||||||
}, [promRulesResponses, rulerRulesResponses]);
|
}, [promRulesResponses, rulerRulesResponses]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExistingRuleInGroup(
|
||||||
|
rule: Rule,
|
||||||
|
group: CombinedRuleGroup,
|
||||||
|
rulesSource: RulesSource
|
||||||
|
): CombinedRule | undefined {
|
||||||
|
return isGrafanaRulesSource(rulesSource)
|
||||||
|
? group!.rules.find((existingRule) => existingRule.name === rule.name) // assume grafana groups have only the one rule. check name anyway because paranoid
|
||||||
|
: group!.rules.find((existingRule) => {
|
||||||
|
return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule): boolean {
|
function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule): boolean {
|
||||||
if (combinedRule.name === rule.name) {
|
if (combinedRule.name === rule.name) {
|
||||||
return (
|
return (
|
||||||
|
@ -134,6 +134,9 @@ export class MockDataSourceSrv implements DataSourceSrv {
|
|||||||
* Get settings and plugin metadata by name or uid
|
* Get settings and plugin metadata by name or uid
|
||||||
*/
|
*/
|
||||||
getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined {
|
getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined {
|
||||||
return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) || { meta: { info: { logos: {} } } };
|
return (
|
||||||
|
DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) ||
|
||||||
|
(({ meta: { info: { logos: {} } } } as unknown) as DataSourceInstanceSettings)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { AppEvents } from '@grafana/data';
|
import { AppEvents } from '@grafana/data';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { ThunkResult } from 'app/types';
|
import { ThunkResult } from 'app/types';
|
||||||
import { RuleLocation, RuleNamespace } from 'app/types/unified-alerting';
|
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
import {
|
||||||
|
PostableRulerRuleGroupDTO,
|
||||||
|
RulerGrafanaRuleDTO,
|
||||||
|
RulerRuleGroupDTO,
|
||||||
|
RulerRulesConfigDTO,
|
||||||
|
} from 'app/types/unified-alerting-dto';
|
||||||
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
|
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
|
||||||
import { fetchRules } from '../api/prometheus';
|
import { fetchRules } from '../api/prometheus';
|
||||||
import {
|
import {
|
||||||
@ -15,10 +21,18 @@ import {
|
|||||||
setRulerRuleGroup,
|
setRulerRuleGroup,
|
||||||
} from '../api/ruler';
|
} from '../api/ruler';
|
||||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../utils/datasource';
|
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
||||||
import { withSerializedError } from '../utils/redux';
|
import { withSerializedError } from '../utils/redux';
|
||||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||||
import { hashRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
|
import {
|
||||||
|
getRuleIdentifier,
|
||||||
|
hashRulerRule,
|
||||||
|
isGrafanaRuleIdentifier,
|
||||||
|
isGrafanaRulerRule,
|
||||||
|
isRulerNotSupportedResponse,
|
||||||
|
ruleWithLocationToRuleIdentifier,
|
||||||
|
stringifyRuleIdentifier,
|
||||||
|
} from '../utils/rules';
|
||||||
|
|
||||||
export const fetchPromRulesAction = createAsyncThunk(
|
export const fetchPromRulesAction = createAsyncThunk(
|
||||||
'unifiedalerting/fetchPromRules',
|
'unifiedalerting/fetchPromRules',
|
||||||
@ -70,62 +84,163 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<void
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRuleAction(ruleLocation: RuleLocation): ThunkResult<void> {
|
async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> {
|
||||||
|
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||||
|
const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME);
|
||||||
|
// find namespace and group that contains the uid for the rule
|
||||||
|
for (const [namespace, groups] of Object.entries(namespaces)) {
|
||||||
|
for (const group of groups) {
|
||||||
|
const rule = group.rules.find(
|
||||||
|
(rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid
|
||||||
|
);
|
||||||
|
if (rule) {
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||||
|
namespace: namespace,
|
||||||
|
rule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { ruleSourceName, namespace, groupName, ruleHash } = ruleIdentifier;
|
||||||
|
const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
|
||||||
|
if (group) {
|
||||||
|
const rule = group.rules.find((rule) => hashRulerRule(rule) === ruleHash);
|
||||||
|
if (rule) {
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
ruleSourceName,
|
||||||
|
namespace,
|
||||||
|
rule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchExistingRuleAction = createAsyncThunk(
|
||||||
|
'unifiedalerting/fetchExistingRule',
|
||||||
|
(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> =>
|
||||||
|
withSerializedError(findExistingRule(ruleIdentifier))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function deleteRule(ruleWithLocation: RuleWithLocation): Promise<void> {
|
||||||
|
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
|
||||||
|
// in case of GRAFANA, each group implicitly only has one rule. delete the group.
|
||||||
|
if (isGrafanaRulesSource(ruleSourceName)) {
|
||||||
|
await deleteRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, namespace, group.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// in case of CLOUD
|
||||||
|
// it was the last rule, delete the entire group
|
||||||
|
if (group.rules.length === 1) {
|
||||||
|
await deleteRulerRulesGroup(ruleSourceName, namespace, group.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// post the group with rule removed
|
||||||
|
await setRulerRuleGroup(ruleSourceName, namespace, {
|
||||||
|
...group,
|
||||||
|
rules: group.rules.filter((r) => r !== rule),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult<void> {
|
||||||
/*
|
/*
|
||||||
* fetch the rules group from backend, delete group if it is found and+
|
* fetch the rules group from backend, delete group if it is found and+
|
||||||
* reload ruler rules
|
* reload ruler rules
|
||||||
*/
|
*/
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
const { namespace, groupName, ruleSourceName, ruleHash } = ruleLocation;
|
const ruleWithLocation = await findExistingRule(ruleIdentifier);
|
||||||
//const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
|
if (!ruleWithLocation) {
|
||||||
const groups = await fetchRulerRulesNamespace(ruleSourceName, namespace);
|
throw new Error('Rule not found.');
|
||||||
const group = groups.find((group) => group.name === groupName);
|
|
||||||
if (!group) {
|
|
||||||
throw new Error('Failed to delete rule: group not found.');
|
|
||||||
}
|
}
|
||||||
const existingRule = group.rules.find((rule) => hashRulerRule(rule) === ruleHash);
|
await deleteRule(ruleWithLocation);
|
||||||
if (!existingRule) {
|
// refetch rules for this rules source
|
||||||
throw new Error('Failed to delete rule: group not found.');
|
return dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName));
|
||||||
}
|
|
||||||
// for cloud datasources, delete group if this rule is the last rule
|
|
||||||
if (group.rules.length === 1 && isCloudRulesSource(ruleSourceName)) {
|
|
||||||
await deleteRulerRulesGroup(ruleSourceName, namespace, groupName);
|
|
||||||
} else {
|
|
||||||
await setRulerRuleGroup(ruleSourceName, namespace, {
|
|
||||||
...group,
|
|
||||||
rules: group.rules.filter((rule) => rule !== existingRule),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dispatch(fetchRulerRulesAction(ruleSourceName));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLotexRule(values: RuleFormValues): Promise<void> {
|
async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
|
||||||
const { dataSourceName, location } = values;
|
const { dataSourceName, location } = values;
|
||||||
|
const formRule = formValuesToRulerAlertingRuleDTO(values);
|
||||||
if (dataSourceName && location) {
|
if (dataSourceName && location) {
|
||||||
const existingGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
|
// if we're updating a rule...
|
||||||
const rule = formValuesToRulerAlertingRuleDTO(values);
|
if (existing) {
|
||||||
|
// refetch it so we always have the latest greatest
|
||||||
|
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
|
||||||
|
if (!freshExisting) {
|
||||||
|
throw new Error('Rule not found.');
|
||||||
|
}
|
||||||
|
// if namespace or group was changed, delete the old rule
|
||||||
|
if (freshExisting.namespace !== location.namespace || freshExisting.group.name !== location.group) {
|
||||||
|
await deleteRule(freshExisting);
|
||||||
|
} else {
|
||||||
|
// if same namespace or group, update the group replacing the old rule with new
|
||||||
|
const payload = {
|
||||||
|
...freshExisting.group,
|
||||||
|
rules: freshExisting.group.rules.map((existingRule) =>
|
||||||
|
existingRule === freshExisting.rule ? formRule : existingRule
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
||||||
|
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// @TODO handle "update" case
|
// if creating new rule or existing rule was in a different namespace/group, create new rule in target group
|
||||||
const payload: RulerRuleGroupDTO = existingGroup
|
|
||||||
|
const targetGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
|
||||||
|
|
||||||
|
const payload: RulerRuleGroupDTO = targetGroup
|
||||||
? {
|
? {
|
||||||
...existingGroup,
|
...targetGroup,
|
||||||
rules: [...existingGroup.rules, rule],
|
rules: [...targetGroup.rules, formRule],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: location.group,
|
name: location.group,
|
||||||
rules: [rule],
|
rules: [formRule],
|
||||||
};
|
};
|
||||||
|
|
||||||
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
||||||
|
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Data source and location must be specified');
|
throw new Error('Data source and location must be specified');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
|
async function saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
|
||||||
const { folder, evaluateEvery } = values;
|
const { folder, evaluateEvery } = values;
|
||||||
|
const formRule = formValuesToRulerGrafanaRuleDTO(values);
|
||||||
if (folder) {
|
if (folder) {
|
||||||
|
// updating an existing rule...
|
||||||
|
if (existing) {
|
||||||
|
// refetch it to be sure we have the latest
|
||||||
|
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
|
||||||
|
if (!freshExisting) {
|
||||||
|
throw new Error('Rule not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if folder has changed, delete the old one
|
||||||
|
if (freshExisting.namespace !== folder.title) {
|
||||||
|
await deleteRule(freshExisting);
|
||||||
|
// if same folder, repost the group with updated rule
|
||||||
|
} else {
|
||||||
|
const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!;
|
||||||
|
formRule.grafana_alert.uid = uid;
|
||||||
|
await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, freshExisting.namespace, {
|
||||||
|
name: freshExisting.group.name,
|
||||||
|
interval: evaluateEvery,
|
||||||
|
rules: [formRule],
|
||||||
|
});
|
||||||
|
return { uid };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if creating new rule or folder was changed, create rule in a new group
|
||||||
|
|
||||||
const existingNamespace = await fetchRulerRulesNamespace(GRAFANA_RULES_SOURCE_NAME, folder.title);
|
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
|
// set group name to rule name, but be super paranoid and check that this group does not already exist
|
||||||
@ -135,14 +250,21 @@ async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
|
|||||||
group = `${values.name}-${++idx}`;
|
group = `${values.name}-${++idx}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rule = formValuesToRulerGrafanaRuleDTO(values);
|
const payload: PostableRulerRuleGroupDTO = {
|
||||||
|
|
||||||
const payload: RulerRuleGroupDTO = {
|
|
||||||
name: group,
|
name: group,
|
||||||
interval: evaluateEvery,
|
interval: evaluateEvery,
|
||||||
rules: [rule],
|
rules: [formRule],
|
||||||
};
|
};
|
||||||
await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload);
|
await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload);
|
||||||
|
|
||||||
|
// now refetch this group to get the uid, hah
|
||||||
|
const result = await fetchRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, group);
|
||||||
|
const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid;
|
||||||
|
if (newUid) {
|
||||||
|
return { uid: newUid };
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch created rule.');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Folder must be specified');
|
throw new Error('Folder must be specified');
|
||||||
}
|
}
|
||||||
@ -150,20 +272,40 @@ async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
|
|||||||
|
|
||||||
export const saveRuleFormAction = createAsyncThunk(
|
export const saveRuleFormAction = createAsyncThunk(
|
||||||
'unifiedalerting/saveRuleForm',
|
'unifiedalerting/saveRuleForm',
|
||||||
(values: RuleFormValues): Promise<void> =>
|
({
|
||||||
|
values,
|
||||||
|
existing,
|
||||||
|
exitOnSave,
|
||||||
|
}: {
|
||||||
|
values: RuleFormValues;
|
||||||
|
existing?: RuleWithLocation;
|
||||||
|
exitOnSave: boolean;
|
||||||
|
}): Promise<void> =>
|
||||||
withSerializedError(
|
withSerializedError(
|
||||||
(async () => {
|
(async () => {
|
||||||
const { type } = values;
|
const { type } = values;
|
||||||
// in case of system (cortex/loki)
|
// in case of system (cortex/loki)
|
||||||
|
let identifier: RuleIdentifier;
|
||||||
if (type === RuleFormType.system) {
|
if (type === RuleFormType.system) {
|
||||||
await saveLotexRule(values);
|
identifier = await saveLotexRule(values, existing);
|
||||||
// in case of grafana managed
|
// in case of grafana managed
|
||||||
} else if (type === RuleFormType.threshold) {
|
} else if (type === RuleFormType.threshold) {
|
||||||
await saveGrafanaRule(values);
|
identifier = await saveGrafanaRule(values, existing);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unexpected rule form type');
|
throw new Error('Unexpected rule form type');
|
||||||
}
|
}
|
||||||
appEvents.emit(AppEvents.alertSuccess, ['Rule saved.']);
|
if (exitOnSave) {
|
||||||
|
locationService.push('/alerting/list');
|
||||||
|
} else {
|
||||||
|
// redirect to edit page
|
||||||
|
const newLocation = `/alerting/${encodeURIComponent(stringifyRuleIdentifier(identifier))}/edit`;
|
||||||
|
if (locationService.getLocation().pathname !== newLocation) {
|
||||||
|
locationService.replace(newLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appEvents.emit(AppEvents.alertSuccess, [
|
||||||
|
existing ? `Rule "${values.name}" updated.` : `Rule "${values.name}" saved.`,
|
||||||
|
]);
|
||||||
})()
|
})()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -2,6 +2,7 @@ import { combineReducers } from 'redux';
|
|||||||
import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
|
import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
|
||||||
import {
|
import {
|
||||||
fetchAlertManagerConfigAction,
|
fetchAlertManagerConfigAction,
|
||||||
|
fetchExistingRuleAction,
|
||||||
fetchPromRulesAction,
|
fetchPromRulesAction,
|
||||||
fetchRulerRulesAction,
|
fetchRulerRulesAction,
|
||||||
fetchSilencesAction,
|
fetchSilencesAction,
|
||||||
@ -20,6 +21,7 @@ export const reducer = combineReducers({
|
|||||||
.reducer,
|
.reducer,
|
||||||
ruleForm: combineReducers({
|
ruleForm: combineReducers({
|
||||||
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
|
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
|
||||||
|
existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -42,3 +42,7 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterSta
|
|||||||
|
|
||||||
return { queryString, alertState, dataSource };
|
return { queryString, alertState, dataSource };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function recordToArray(record: Record<string, string>): Array<{ key: string; value: string }> {
|
||||||
|
return Object.entries(record).map(([key, value]) => ({ key, value }));
|
||||||
|
}
|
||||||
|
@ -1,7 +1,38 @@
|
|||||||
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
import { describeInterval, secondsToHms } from '@grafana/data/src/datetime/rangeutil';
|
||||||
import { RulerAlertingRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
import { RuleFormValues } from '../types/rule-form';
|
import {
|
||||||
import { arrayToRecord } from './misc';
|
Annotations,
|
||||||
|
GrafanaAlertState,
|
||||||
|
Labels,
|
||||||
|
PostableRuleGrafanaRuleDTO,
|
||||||
|
RulerAlertingRuleDTO,
|
||||||
|
} from 'app/types/unified-alerting-dto';
|
||||||
|
import { SAMPLE_QUERIES } from '../mocks/grafana-queries';
|
||||||
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
|
import { isGrafanaRulesSource } from './datasource';
|
||||||
|
import { arrayToRecord, recordToArray } from './misc';
|
||||||
|
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
|
||||||
|
|
||||||
|
export const defaultFormValues: RuleFormValues = Object.freeze({
|
||||||
|
name: '',
|
||||||
|
labels: [{ key: '', value: '' }],
|
||||||
|
annotations: [{ key: '', value: '' }],
|
||||||
|
dataSourceName: null,
|
||||||
|
|
||||||
|
// threshold
|
||||||
|
folder: null,
|
||||||
|
queries: SAMPLE_QUERIES, // @TODO remove the sample eventually
|
||||||
|
condition: '',
|
||||||
|
noDataState: GrafanaAlertState.NoData,
|
||||||
|
execErrState: GrafanaAlertState.Alerting,
|
||||||
|
evaluateEvery: '1m',
|
||||||
|
evaluateFor: '5m',
|
||||||
|
|
||||||
|
// system
|
||||||
|
expression: '',
|
||||||
|
forTime: 1,
|
||||||
|
forTimeUnit: 'm',
|
||||||
|
});
|
||||||
|
|
||||||
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO {
|
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO {
|
||||||
const { name, expression, forTime, forTimeUnit } = values;
|
const { name, expression, forTime, forTimeUnit } = values;
|
||||||
@ -14,12 +45,24 @@ export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerA
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseInterval(value: string): [number, string] {
|
||||||
|
const match = value.match(/(\d+)(\w+)/);
|
||||||
|
if (match) {
|
||||||
|
return [Number(match[1]), match[2]];
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid interval description: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
function intervalToSeconds(interval: string): number {
|
function intervalToSeconds(interval: string): number {
|
||||||
const { sec, count } = describeInterval(interval);
|
const { sec, count } = describeInterval(interval);
|
||||||
return sec * count;
|
return sec * count;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): RulerGrafanaRuleDTO {
|
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
|
||||||
|
return [...recordToArray(item || {}), { key: '', value: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
|
||||||
const { name, condition, noDataState, execErrState, evaluateFor, queries } = values;
|
const { name, condition, noDataState, execErrState, evaluateFor, queries } = values;
|
||||||
if (condition) {
|
if (condition) {
|
||||||
return {
|
return {
|
||||||
@ -37,3 +80,52 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): RulerGr
|
|||||||
}
|
}
|
||||||
throw new Error('Cannot create rule without specifying alert condition');
|
throw new Error('Cannot create rule without specifying alert condition');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
|
||||||
|
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
|
||||||
|
if (isGrafanaRulesSource(ruleSourceName)) {
|
||||||
|
if (isGrafanaRulerRule(rule)) {
|
||||||
|
const ga = rule.grafana_alert;
|
||||||
|
return {
|
||||||
|
...defaultFormValues,
|
||||||
|
name: ga.title,
|
||||||
|
type: RuleFormType.threshold,
|
||||||
|
dataSourceName: ga.data[0]?.model.datasource,
|
||||||
|
evaluateFor: secondsToHms(ga.for),
|
||||||
|
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
||||||
|
noDataState: ga.no_data_state,
|
||||||
|
execErrState: ga.exec_err_state,
|
||||||
|
queries: ga.data,
|
||||||
|
condition: ga.condition,
|
||||||
|
annotations: listifyLabelsOrAnnotations(ga.annotations),
|
||||||
|
labels: listifyLabelsOrAnnotations(ga.labels),
|
||||||
|
folder: { title: namespace, id: -1 },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Unexpected type of rule for grafana rules source');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isAlertingRulerRule(rule)) {
|
||||||
|
const [forTime, forTimeUnit] = rule.for
|
||||||
|
? parseInterval(rule.for)
|
||||||
|
: [defaultFormValues.forTime, defaultFormValues.forTimeUnit];
|
||||||
|
return {
|
||||||
|
...defaultFormValues,
|
||||||
|
name: rule.alert,
|
||||||
|
type: RuleFormType.system,
|
||||||
|
dataSourceName: ruleSourceName,
|
||||||
|
location: {
|
||||||
|
namespace,
|
||||||
|
group: group.name,
|
||||||
|
},
|
||||||
|
expression: rule.expr,
|
||||||
|
forTime,
|
||||||
|
forTimeUnit,
|
||||||
|
annotations: listifyLabelsOrAnnotations(rule.annotations),
|
||||||
|
labels: listifyLabelsOrAnnotations(rule.labels),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Editing recording rules not supported (yet)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
|
Annotations,
|
||||||
|
Labels,
|
||||||
PromRuleType,
|
PromRuleType,
|
||||||
RulerAlertingRuleDTO,
|
RulerAlertingRuleDTO,
|
||||||
RulerGrafanaRuleDTO,
|
RulerGrafanaRuleDTO,
|
||||||
RulerRecordingRuleDTO,
|
RulerRecordingRuleDTO,
|
||||||
RulerRuleDTO,
|
RulerRuleDTO,
|
||||||
} from 'app/types/unified-alerting-dto';
|
} from 'app/types/unified-alerting-dto';
|
||||||
import { Alert, AlertingRule, RecordingRule, Rule } from 'app/types/unified-alerting';
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertingRule,
|
||||||
|
CloudRuleIdentifier,
|
||||||
|
GrafanaRuleIdentifier,
|
||||||
|
RecordingRule,
|
||||||
|
Rule,
|
||||||
|
RuleIdentifier,
|
||||||
|
RuleWithLocation,
|
||||||
|
} from 'app/types/unified-alerting';
|
||||||
import { AsyncRequestState } from './redux';
|
import { AsyncRequestState } from './redux';
|
||||||
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
||||||
import { hash } from './misc';
|
import { hash } from './misc';
|
||||||
@ -38,6 +49,86 @@ export function isRulerNotSupportedResponse(resp: AsyncRequestState<any>) {
|
|||||||
return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG;
|
return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashRulerRule(rule: RulerRuleDTO): number {
|
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
|
||||||
return hash(JSON.stringify(rule));
|
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is used to identify lotex rules, as they do not have a unique identifier
|
||||||
|
export function hashRulerRule(rule: RulerRuleDTO): number {
|
||||||
|
if (isRecordingRulerRule(rule)) {
|
||||||
|
return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)]));
|
||||||
|
} else if (isAlertingRulerRule(rule)) {
|
||||||
|
return hash(
|
||||||
|
JSON.stringify([
|
||||||
|
rule.alert,
|
||||||
|
rule.expr,
|
||||||
|
hashLabelsOrAnnotations(rule.annotations),
|
||||||
|
hashLabelsOrAnnotations(rule.labels),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('only recording and alerting ruler rules can be hashed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGrafanaRuleIdentifier(location: RuleIdentifier): location is GrafanaRuleIdentifier {
|
||||||
|
return 'uid' in location;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCloudRuleIdentifier(location: RuleIdentifier): location is CloudRuleIdentifier {
|
||||||
|
return 'ruleSourceName' in location;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeDollars(value: string): string {
|
||||||
|
return value.replace(/\$/g, '_DOLLAR_');
|
||||||
|
}
|
||||||
|
function unesacapeDollars(value: string): string {
|
||||||
|
return value.replace(/\_DOLLAR\_/g, '$');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyRuleIdentifier(location: RuleIdentifier): string {
|
||||||
|
if (isGrafanaRuleIdentifier(location)) {
|
||||||
|
return location.uid;
|
||||||
|
}
|
||||||
|
return [location.ruleSourceName, location.namespace, location.groupName, location.ruleHash]
|
||||||
|
.map(String)
|
||||||
|
.map(escapeDollars)
|
||||||
|
.join('$');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRuleIdentifier(location: string): RuleIdentifier {
|
||||||
|
const parts = location.split('$');
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { uid: location };
|
||||||
|
} else if (parts.length === 4) {
|
||||||
|
const [ruleSourceName, namespace, groupName, ruleHash] = parts.map(unesacapeDollars);
|
||||||
|
return { ruleSourceName, namespace, groupName, ruleHash: Number(ruleHash) };
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to parse rule location: ${location}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuleIdentifier(
|
||||||
|
ruleSourceName: string,
|
||||||
|
namespace: string,
|
||||||
|
groupName: string,
|
||||||
|
rule: RulerRuleDTO
|
||||||
|
): RuleIdentifier {
|
||||||
|
if (isGrafanaRulerRule(rule)) {
|
||||||
|
return { uid: rule.grafana_alert.uid! };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ruleSourceName,
|
||||||
|
namespace,
|
||||||
|
groupName,
|
||||||
|
ruleHash: hashRulerRule(rule),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleWithLocationToRuleIdentifier(ruleWithLocation: RuleWithLocation): RuleIdentifier {
|
||||||
|
return getRuleIdentifier(
|
||||||
|
ruleWithLocation.ruleSourceName,
|
||||||
|
ruleWithLocation.namespace,
|
||||||
|
ruleWithLocation.group.name,
|
||||||
|
ruleWithLocation.rule
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -94,6 +94,7 @@ export enum GrafanaAlertState {
|
|||||||
export interface GrafanaQueryModel {
|
export interface GrafanaQueryModel {
|
||||||
datasource: string;
|
datasource: string;
|
||||||
datasourceUid: string;
|
datasourceUid: string;
|
||||||
|
|
||||||
refId: string;
|
refId: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
@ -108,7 +109,7 @@ export interface GrafanaQuery {
|
|||||||
model: GrafanaQueryModel;
|
model: GrafanaQueryModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrafanaRuleDefinition {
|
export interface PostableGrafanaRuleDefinition {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
title: string;
|
title: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
@ -119,6 +120,10 @@ export interface GrafanaRuleDefinition {
|
|||||||
annotations: Annotations;
|
annotations: Annotations;
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
}
|
}
|
||||||
|
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||||
|
uid: string;
|
||||||
|
namespace_uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RulerGrafanaRuleDTO {
|
export interface RulerGrafanaRuleDTO {
|
||||||
grafana_alert: GrafanaRuleDefinition;
|
grafana_alert: GrafanaRuleDefinition;
|
||||||
@ -126,12 +131,20 @@ export interface RulerGrafanaRuleDTO {
|
|||||||
// annotations?: Annotations;
|
// annotations?: Annotations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostableRuleGrafanaRuleDTO {
|
||||||
|
grafana_alert: PostableGrafanaRuleDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | RulerGrafanaRuleDTO;
|
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | RulerGrafanaRuleDTO;
|
||||||
|
|
||||||
export type RulerRuleGroupDTO = {
|
export type PostableRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | PostableRuleGrafanaRuleDTO;
|
||||||
|
|
||||||
|
export type RulerRuleGroupDTO<R = RulerRuleDTO> = {
|
||||||
name: string;
|
name: string;
|
||||||
interval?: string;
|
interval?: string;
|
||||||
rules: RulerRuleDTO[];
|
rules: R[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PostableRulerRuleGroupDTO = RulerRuleGroupDTO<PostableRuleDTO>;
|
||||||
|
|
||||||
export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] };
|
export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] };
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
/* Prometheus internal models */
|
/* Prometheus internal models */
|
||||||
|
|
||||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||||
import { PromAlertingRuleState, PromRuleType, RulerRuleDTO, Labels, Annotations } from './unified-alerting-dto';
|
import {
|
||||||
|
PromAlertingRuleState,
|
||||||
|
PromRuleType,
|
||||||
|
RulerRuleDTO,
|
||||||
|
Labels,
|
||||||
|
Annotations,
|
||||||
|
RulerRuleGroupDTO,
|
||||||
|
} from './unified-alerting-dto';
|
||||||
|
|
||||||
export type Alert = {
|
export type Alert = {
|
||||||
activeAt: string;
|
activeAt: string;
|
||||||
@ -85,7 +92,14 @@ export interface CombinedRuleNamespace {
|
|||||||
groups: CombinedRuleGroup[];
|
groups: CombinedRuleGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleLocation {
|
export interface RuleWithLocation {
|
||||||
|
ruleSourceName: string;
|
||||||
|
namespace: string;
|
||||||
|
group: RulerRuleGroupDTO;
|
||||||
|
rule: RulerRuleDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudRuleIdentifier {
|
||||||
ruleSourceName: string;
|
ruleSourceName: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
@ -97,3 +111,8 @@ export interface RuleFilterState {
|
|||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
alertState?: string;
|
alertState?: string;
|
||||||
}
|
}
|
||||||
|
export interface GrafanaRuleIdentifier {
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier;
|
||||||
|
Loading…
Reference in New Issue
Block a user