Alerting: editing existing rules via UI (#33005)

pull/33065/head
Domas 4 years ago committed by GitHub
parent cb6fe5e65b
commit 826d82fe95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      public/app/core/components/Select/FolderPicker.tsx
  2. 15
      public/app/core/hooks/useCleanup.ts
  3. 67
      public/app/features/alerting/unified/RuleEditor.tsx
  4. 3
      public/app/features/alerting/unified/api/prometheus.ts
  5. 4
      public/app/features/alerting/unified/api/ruler.ts
  6. 4
      public/app/features/alerting/unified/components/AlertManagerPicker.tsx
  7. 2
      public/app/features/alerting/unified/components/RuleGroupPicker.tsx
  8. 107
      public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx
  9. 57
      public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
  10. 3
      public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx
  11. 21
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  12. 25
      public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
  13. 5
      public/app/features/alerting/unified/mocks.ts
  14. 226
      public/app/features/alerting/unified/state/actions.ts
  15. 2
      public/app/features/alerting/unified/state/reducers.ts
  16. 4
      public/app/features/alerting/unified/utils/misc.ts
  17. 102
      public/app/features/alerting/unified/utils/rule-form.ts
  18. 95
      public/app/features/alerting/unified/utils/rules.ts
  19. 19
      public/app/types/unified-alerting-dto.ts
  20. 23
      public/app/types/unified-alerting.ts

@ -122,6 +122,9 @@ export class FolderPicker extends PureComponent<Props, State> {
folder = options.find((option) => option.value === initialFolderId) || null;
} else if (enableReset && initialTitle) {
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) {

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

@ -14,6 +14,9 @@ export async function fetchRules(dataSourceName: string): Promise<RuleNamespace[
const nsMap: { [key: string]: RuleNamespace } = {};
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]) {
nsMap[group.file] = {
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 { getBackendSrv } from '@grafana/runtime';
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
@ -7,7 +7,7 @@ import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
export async function setRulerRuleGroup(
dataSourceName: string,
namespace: string,
group: RulerRuleGroupDTO
group: PostableRulerRuleGroupDTO
): Promise<void> {
await await getBackendSrv()
.fetch<unknown>({

@ -5,7 +5,7 @@ import { Select } from '@grafana/ui';
import { getAllDataSources } from '../utils/config';
interface Props {
onChange: (alertManagerSourceName?: string) => void;
onChange: (alertManagerSourceName: string) => void;
current?: string;
}
@ -35,7 +35,7 @@ export const AlertManagerPicker: FC<Props> = ({ onChange, current }) => {
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={(value) => onChange(value.value)}
onChange={(value) => value.value && onChange(value.value)}
options={options}
maxMenuHeight={500}
noOptionsMessage="No datasources found"

@ -45,6 +45,7 @@ export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName })
return [];
}, [rulesConfig]);
// @TODO replace cascader with separate dropdowns
return (
<Cascader
placeholder="Select a rule group"
@ -55,6 +56,7 @@ export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName })
initialValue={value ? stringifyValue(value) : undefined}
displayAllSelectedLevels={true}
separator=" > "
key={JSON.stringify(options)}
options={options}
changeOnSelect={false}
/>

@ -1,6 +1,6 @@
import React, { FC, useEffect } from 'react';
import React, { FC, useMemo } from 'react';
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 { AlertTypeStep } from './AlertTypeStep';
@ -9,98 +9,105 @@ import { DetailsStep } from './DetailsStep';
import { QueryStep } from './QueryStep';
import { useForm, FormContext } from 'react-hook-form';
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 { RuleFormType, RuleFormValues } from '../../types/rule-form';
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 = {};
const defaultValues: 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',
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';
// system
expression: '',
forTime: 1,
forTimeUnit: 'm',
});
type Props = {
existing?: RuleWithLocation;
};
export const AlertRuleForm: FC<Props> = () => {
export const AlertRuleForm: FC<Props> = ({ existing }) => {
const styles = useStyles(getStyles);
const dispatch = useDispatch();
useEffect(() => {
return () => {
dispatch(cleanUpAction({ stateSelector: (state) => state.unifiedAlerting.ruleForm }));
};
}, [dispatch]);
const defaultValues: RuleFormValues = useMemo(() => {
if (existing) {
return rulerRuleToFormValues(existing);
}
return defaultFormValues;
}, [existing]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
defaultValues,
});
const { handleSubmit, watch } = formAPI;
const { handleSubmit, watch, errors } = formAPI;
const hasErrors = !!Object.values(errors).filter((x) => !!x).length;
const type = watch('type');
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;
useCleanup((state) => state.unifiedAlerting.ruleForm.saveRule);
const submit = (values: RuleFormValues) => {
const submit = (values: RuleFormValues, exitOnSave: boolean) => {
console.log('submit', values);
dispatch(
saveRuleFormAction({
...values,
annotations: values.annotations.filter(({ key }) => !!key),
labels: values.labels.filter(({ key }) => !!key),
values: {
...values,
annotations: values.annotations?.filter(({ key }) => !!key) ?? [],
labels: values.labels?.filter(({ key }) => !!key) ?? [],
},
existing,
exitOnSave,
})
);
};
return (
<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}>
<ToolbarButton variant="default" disabled={submitState.loading}>
Cancel
</ToolbarButton>
<ToolbarButton variant="primary" type="submit" disabled={submitState.loading}>
<Link to="/alerting/list">
<ToolbarButton variant="default" disabled={submitState.loading} type="button">
Cancel
</ToolbarButton>
</Link>
<ToolbarButton
variant="primary"
type="button"
onClick={handleSubmit((values) => submit(values, false))}
disabled={submitState.loading}
>
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
Save
</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} />}
Save and exit
</ToolbarButton>
</PageToolbar>
<div className={styles.contentOutter}>
<CustomScrollbar autoHeightMin="100%">
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<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 && (
<Alert severity="error" title="Error saving rule">
{submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)}
</Alert>
)}
<AlertTypeStep />
<AlertTypeStep editingExistingRule={!!existing} />
{showStep2 && (
<>
<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 { register, control, watch, errors, setValue } = useFormContext<RuleFormValues>();
@ -64,6 +68,7 @@ export const AlertTypeStep: FC = () => {
</Field>
<div className={styles.flexRow}>
<Field
disabled={editingExistingRule}
label="Alert type"
className={styles.formInput}
error={errors.type?.message}
@ -91,30 +96,32 @@ export const AlertTypeStep: FC = () => {
}}
/>
</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>
{ruleFormType === RuleFormType.system && (
<Field
className={styles.formInput}
label="Select data source"
error={errors.dataSourceName?.message}
invalid={!!errors.dataSourceName?.message}
>
<InputControl
as={(DataSourcePicker as unknown) 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

@ -19,7 +19,8 @@ interface Props {
}
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(
(): SelectableValue[] => [

@ -2,7 +2,7 @@ import { GrafanaTheme, rangeUtil } from '@grafana/data';
import { ConfirmModal, useStyles } from '@grafana/ui';
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
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 { css, cx } from '@emotion/css';
import { TimeToNow } from '../TimeToNow';
@ -44,12 +44,7 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
const deleteRule = () => {
if (ruleToDelete) {
dispatch(
deleteRuleAction({
ruleSourceName: getRulesSourceName(rulesSource),
groupName: group.name,
namespace,
ruleHash: hashRulerRule(ruleToDelete),
})
deleteRuleAction(getRuleIdentifier(getRulesSourceName(rulesSource), namespace, group.name, ruleToDelete))
);
setRuleToDelete(undefined);
}
@ -134,7 +129,17 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
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 && (
<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 { useMemo, useRef } from 'react';
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
@ -88,9 +95,7 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
}
(group.rules ?? []).forEach((rule) => {
const existingRule = combinedGroup!.rules.find((existingRule) => {
return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule);
});
const existingRule = getExistingRuleInGroup(rule, combinedGroup!, rulesSource);
if (existingRule) {
existingRule.promRule = rule;
} else {
@ -126,6 +131,18 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
}, [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 {
if (combinedRule.name === rule.name) {
return (

@ -134,6 +134,9 @@ export class MockDataSourceSrv implements DataSourceSrv {
* Get settings and plugin metadata by name or uid
*/
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 { locationService } from '@grafana/runtime';
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 { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
import {
PostableRulerRuleGroupDTO,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
import { fetchRules } from '../api/prometheus';
import {
@ -15,10 +21,18 @@ import {
setRulerRuleGroup,
} from '../api/ruler';
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 { 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(
'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+
* reload ruler rules
*/
return async (dispatch) => {
const { namespace, groupName, ruleSourceName, ruleHash } = ruleLocation;
//const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
const groups = await fetchRulerRulesNamespace(ruleSourceName, namespace);
const group = groups.find((group) => group.name === groupName);
if (!group) {
throw new Error('Failed to delete rule: group not found.');
const ruleWithLocation = await findExistingRule(ruleIdentifier);
if (!ruleWithLocation) {
throw new Error('Rule not found.');
}
const existingRule = group.rules.find((rule) => hashRulerRule(rule) === ruleHash);
if (!existingRule) {
throw new Error('Failed to delete rule: group not found.');
}
// 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));
await deleteRule(ruleWithLocation);
// refetch rules for this rules source
return dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName));
};
}
async function saveLotexRule(values: RuleFormValues): Promise<void> {
async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
const { dataSourceName, location } = values;
const formRule = formValuesToRulerAlertingRuleDTO(values);
if (dataSourceName && location) {
const existingGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
const rule = formValuesToRulerAlertingRuleDTO(values);
// if we're updating a rule...
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
const payload: RulerRuleGroupDTO = existingGroup
// if creating new rule or existing rule was in a different namespace/group, create new rule in target group
const targetGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
const payload: RulerRuleGroupDTO = targetGroup
? {
...existingGroup,
rules: [...existingGroup.rules, rule],
...targetGroup,
rules: [...targetGroup.rules, formRule],
}
: {
name: location.group,
rules: [rule],
rules: [formRule],
};
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule);
} else {
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 formRule = formValuesToRulerGrafanaRuleDTO(values);
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);
// 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}`;
}
const rule = formValuesToRulerGrafanaRuleDTO(values);
const payload: RulerRuleGroupDTO = {
const payload: PostableRulerRuleGroupDTO = {
name: group,
interval: evaluateEvery,
rules: [rule],
rules: [formRule],
};
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 {
throw new Error('Folder must be specified');
}
@ -150,20 +272,40 @@ async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
export const saveRuleFormAction = createAsyncThunk(
'unifiedalerting/saveRuleForm',
(values: RuleFormValues): Promise<void> =>
({
values,
existing,
exitOnSave,
}: {
values: RuleFormValues;
existing?: RuleWithLocation;
exitOnSave: boolean;
}): Promise<void> =>
withSerializedError(
(async () => {
const { type } = values;
// in case of system (cortex/loki)
let identifier: RuleIdentifier;
if (type === RuleFormType.system) {
await saveLotexRule(values);
identifier = await saveLotexRule(values, existing);
// in case of grafana managed
} else if (type === RuleFormType.threshold) {
await saveGrafanaRule(values);
identifier = await saveGrafanaRule(values, existing);
} else {
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 {
fetchAlertManagerConfigAction,
fetchExistingRuleAction,
fetchPromRulesAction,
fetchRulerRulesAction,
fetchSilencesAction,
@ -20,6 +21,7 @@ export const reducer = combineReducers({
.reducer,
ruleForm: combineReducers({
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer,
}),
});

@ -42,3 +42,7 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterSta
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 { RulerAlertingRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { RuleFormValues } from '../types/rule-form';
import { arrayToRecord } from './misc';
import { describeInterval, secondsToHms } from '@grafana/data/src/datetime/rangeutil';
import { RuleWithLocation } from 'app/types/unified-alerting';
import {
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 {
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 {
const { sec, count } = describeInterval(interval);
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;
if (condition) {
return {
@ -37,3 +80,52 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): RulerGr
}
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 {
Annotations,
Labels,
PromRuleType,
RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
RulerRecordingRuleDTO,
RulerRuleDTO,
} 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 { RULER_NOT_SUPPORTED_MSG } from './constants';
import { hash } from './misc';
@ -38,6 +49,86 @@ export function isRulerNotSupportedResponse(resp: AsyncRequestState<any>) {
return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG;
}
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
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 {
return hash(JSON.stringify(rule));
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 {
datasource: string;
datasourceUid: string;
refId: string;
[key: string]: any;
}
@ -108,7 +109,7 @@ export interface GrafanaQuery {
model: GrafanaQueryModel;
}
export interface GrafanaRuleDefinition {
export interface PostableGrafanaRuleDefinition {
uid?: string;
title: string;
condition: string;
@ -119,6 +120,10 @@ export interface GrafanaRuleDefinition {
annotations: Annotations;
labels: Labels;
}
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
uid: string;
namespace_uid: string;
}
export interface RulerGrafanaRuleDTO {
grafana_alert: GrafanaRuleDefinition;
@ -126,12 +131,20 @@ export interface RulerGrafanaRuleDTO {
// annotations?: Annotations;
}
export interface PostableRuleGrafanaRuleDTO {
grafana_alert: PostableGrafanaRuleDefinition;
}
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | RulerGrafanaRuleDTO;
export type RulerRuleGroupDTO = {
export type PostableRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | PostableRuleGrafanaRuleDTO;
export type RulerRuleGroupDTO<R = RulerRuleDTO> = {
name: string;
interval?: string;
rules: RulerRuleDTO[];
rules: R[];
};
export type PostableRulerRuleGroupDTO = RulerRuleGroupDTO<PostableRuleDTO>;
export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] };

@ -1,7 +1,14 @@
/* Prometheus internal models */
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 = {
activeAt: string;
@ -85,7 +92,14 @@ export interface CombinedRuleNamespace {
groups: CombinedRuleGroup[];
}
export interface RuleLocation {
export interface RuleWithLocation {
ruleSourceName: string;
namespace: string;
group: RulerRuleGroupDTO;
rule: RulerRuleDTO;
}
export interface CloudRuleIdentifier {
ruleSourceName: string;
namespace: string;
groupName: string;
@ -97,3 +111,8 @@ export interface RuleFilterState {
dataSource?: string;
alertState?: string;
}
export interface GrafanaRuleIdentifier {
uid: string;
}
export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier;

Loading…
Cancel
Save