diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index eb3c62d6091..8212131eb20 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -122,6 +122,9 @@ export class FolderPicker extends PureComponent { 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) { diff --git a/public/app/core/hooks/useCleanup.ts b/public/app/core/hooks/useCleanup.ts new file mode 100644 index 00000000000..b7940c9d474 --- /dev/null +++ b/public/app/core/hooks/useCleanup.ts @@ -0,0 +1,15 @@ +import { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { cleanUpAction, StateSelector } from '../actions/cleanUp'; + +export function useCleanup(stateSelector: StateSelector) { + 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]); +} diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 44aaa6e47f6..d036db5971c 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -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 = () => ; +interface ExistingRuleEditorProps { + identifier: RuleIdentifier; +} + +const ExistingRuleEditor: FC = ({ 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 ( + + + + ); + } + if (error) { + return ( + + + {error.message} + + + ); + } + if (!result) { + return ( + + +

Sorry! This rule does not exist.

+ + + +
+
+ ); + } + return ; +}; + +type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; + +const RuleEditor: FC = ({ match }) => { + const id = match.params.id; + if (id) { + const identifier = parseRuleIdentifier(decodeURIComponent(id)); + return ; + } + return ; +}; export default RuleEditor; diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 99df8cf57ae..d8379dc9d41 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -14,6 +14,9 @@ export async function fetchRules(dataSourceName: string): Promise { + 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, diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 4135d5241c0..536b7ce754f 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -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 { await await getBackendSrv() .fetch({ diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index cc594fcc7da..f8e1c6d390b 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -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 = ({ 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" diff --git a/public/app/features/alerting/unified/components/RuleGroupPicker.tsx b/public/app/features/alerting/unified/components/RuleGroupPicker.tsx index e01bfec7961..6b553a3e976 100644 --- a/public/app/features/alerting/unified/components/RuleGroupPicker.tsx +++ b/public/app/features/alerting/unified/components/RuleGroupPicker.tsx @@ -45,6 +45,7 @@ export const RuleGroupPicker: FC = ({ value, onChange, dataSourceName }) return []; }, [rulesConfig]); + // @TODO replace cascader with separate dropdowns return ( = ({ value, onChange, dataSourceName }) initialValue={value ? stringifyValue(value) : undefined} displayAllSelectedLevels={true} separator=" > " + key={JSON.stringify(options)} options={options} changeOnSelect={false} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 04a3d94a84f..2e44a5d1f95 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -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'; +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({ - 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 = () => { +export const AlertRuleForm: FC = ({ 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({ 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 ( -
+ submit(values, false))} className={styles.form}> - - Cancel - - + + + Cancel + + + submit(values, false))} + disabled={submitState.loading} + > {submitState.loading && } Save - + submit(values, true))} + disabled={submitState.loading} + > {submitState.loading && } Save and exit
- +
+ {hasErrors && ( + + There are errors in the form below. Please fix them and try saving again. + + )} {submitState.error && ( {submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)} )} - + {showStep2 && ( <> diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index ace99eb898b..85facfa6a70 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -24,7 +24,11 @@ const alertTypeOptions: SelectableValue[] = [ }, ]; -export const AlertTypeStep: FC = () => { +interface Props { + editingExistingRule: boolean; +} + +export const AlertTypeStep: FC = ({ editingExistingRule }) => { const styles = useStyles(getStyles); const { register, control, watch, errors, setValue } = useFormContext(); @@ -64,6 +68,7 @@ export const AlertTypeStep: FC = () => {
{ }} /> - - >} - 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; - }} - /> - + {ruleFormType === RuleFormType.system && ( + + >} + 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; + }} + /> + + )}
{ruleFormType === RuleFormType.system && ( = ({ 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[] => [ diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index bf50417d21f..d7f91e30935 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -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 = ({ 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 = ({ group, rulesSource, namespace }) => { href={createExploreLink(rulesSource.name, rule.query)} /> )} - {!!rulerRule && } + {!!rulerRule && ( + + )} {!!rulerRule && ( setRuleToDelete(rulerRule)} /> )} diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 6e72a873cb8..9cb82fb8615 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -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 ( diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 4271faef2c6..e5ab000d4de 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -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) + ); } } diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 9a452b35f19..237373315ad 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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 { +async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise { + 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 => + withSerializedError(findExistingRule(ruleIdentifier)) +); + +async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { + 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 { /* * 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 { +async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { 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 { +async function saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { 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 { 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 { export const saveRuleFormAction = createAsyncThunk( 'unifiedalerting/saveRuleForm', - (values: RuleFormValues): Promise => + ({ + values, + existing, + exitOnSave, + }: { + values: RuleFormValues; + existing?: RuleWithLocation; + exitOnSave: boolean; + }): Promise => 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.`, + ]); })() ) ); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 12934a8b84e..d4b9e93cefd 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -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, }), }); diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 25910a249a9..e20898d4c46 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -42,3 +42,7 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterSta return { queryString, alertState, dataSource }; }; + +export function recordToArray(record: Record): Array<{ key: string; value: string }> { + return Object.entries(record).map(([key, value]) => ({ key, value })); +} diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index a7e33cac21c..d85d66e5707 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -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)'); + } + } +} diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 0e9063c6044..9ba40a4605b 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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) { return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG; } -export function hashRulerRule(rule: RulerRuleDTO): number { - return hash(JSON.stringify(rule)); +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 { + 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 + ); } diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 1c221d7964d..8abfda7da51 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -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 = { name: string; interval?: string; - rules: RulerRuleDTO[]; + rules: R[]; }; +export type PostableRulerRuleGroupDTO = RulerRuleGroupDTO; + export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] }; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 4f4b46fa42f..6367d21de76 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -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;