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;
|
||||
} 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) {
|
||||
|
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 { 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';
|
||||
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<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;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
@ -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…
Reference in New Issue
Block a user