From 538b1b196b4ac8aac8db5109f2dc98792e201fe9 Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 19 May 2021 15:30:39 +0300 Subject: [PATCH] Alerting: fix route, silence matchers (#34372) --- .../alerting/unified/AmRoutes.test.tsx | 9 +- .../features/alerting/unified/AmRoutes.tsx | 13 ++- .../alerting/unified/api/alertmanager.ts | 4 +- .../unified/components/AlertLabel.tsx | 7 +- .../amroutes/AmRoutesExpandedForm.tsx | 33 +++++-- .../components/amroutes/AmRoutesTable.tsx | 14 +-- .../components/receivers/ReceiversTable.tsx | 2 +- .../unified/components/silences/Matchers.tsx | 10 +- .../components/silences/MatchersField.tsx | 46 ++++----- .../components/silences/SilencesEditor.tsx | 2 +- .../alerting/unified/state/actions.ts | 2 +- .../alerting/unified/types/amroutes.ts | 9 +- .../alerting/unified/types/silence-form.ts | 4 +- .../unified/utils/alertmanager-config.ts | 30 ------ .../unified/utils/alertmanager.test.ts | 69 ++++++++++++++ .../alerting/unified/utils/alertmanager.ts | 95 +++++++++++++++++++ .../alerting/unified/utils/amroutes.ts | 94 ++++++++++-------- .../plugins/datasource/alertmanager/types.ts | 15 ++- 18 files changed, 311 insertions(+), 147 deletions(-) delete mode 100644 public/app/features/alerting/unified/utils/alertmanager-config.ts create mode 100644 public/app/features/alerting/unified/utils/alertmanager.test.ts create mode 100644 public/app/features/alerting/unified/utils/alertmanager.ts diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx index 75471ed42f7..c33309295ee 100644 --- a/public/app/features/alerting/unified/AmRoutes.test.tsx +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -180,13 +180,14 @@ describe('AmRoutes', () => { expect(rows).toHaveLength(2); subroutes.forEach((route, index) => { - Object.entries({ - ...(route.match ?? {}), - ...(route.match_re ?? {}), - }).forEach(([label, value]) => { + Object.entries(route.match ?? {}).forEach(([label, value]) => { expect(rows[index]).toHaveTextContent(`${label}=${value}`); }); + Object.entries(route.match_re ?? {}).forEach(([label, value]) => { + expect(rows[index]).toHaveTextContent(`${label}=~${value}`); + }); + if (route.group_by) { expect(rows[index]).toHaveTextContent(route.group_by.join(', ')); } diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index 55b6f3ef906..d48d39d411b 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -39,7 +39,7 @@ const AmRoutes: FC = () => { (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; const config = result?.alertmanager_config; - const routes = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]); + const [routes, id2ExistingRoute] = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]); const receivers = stringsToSelectableValues( (config?.receivers ?? []).map((receiver: Receiver) => receiver.name) @@ -59,10 +59,13 @@ const AmRoutes: FC = () => { ); const handleSave = (data: Partial) => { - const newData = formAmRouteToAmRoute({ - ...routes, - ...data, - }); + const newData = formAmRouteToAmRoute( + { + ...routes, + ...data, + }, + id2ExistingRoute + ); if (isRootRouteEditMode) { exitRootRouteEditMode(); diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index dff5f43f1e5..7baadcf0089 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -6,7 +6,7 @@ import { AlertmanagerGroup, Silence, SilenceCreatePayload, - SilenceMatcher, + Matcher, } from 'app/plugins/datasource/alertmanager/types'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -91,7 +91,7 @@ export async function expireSilence(alertmanagerSourceName: string, silenceID: s export async function fetchAlerts( alertmanagerSourceName: string, - matchers?: SilenceMatcher[], + matchers?: Matcher[], silenced = true, active = true, inhibited = true diff --git a/public/app/features/alerting/unified/components/AlertLabel.tsx b/public/app/features/alerting/unified/components/AlertLabel.tsx index 6594a8c82c6..984e57a3492 100644 --- a/public/app/features/alerting/unified/components/AlertLabel.tsx +++ b/public/app/features/alerting/unified/components/AlertLabel.tsx @@ -6,13 +6,14 @@ import { css } from '@emotion/css'; interface Props { labelKey: string; value: string; - isRegex?: boolean; + operator?: string; onRemoveLabel?: () => void; } -export const AlertLabel: FC = ({ labelKey, value, isRegex = false, onRemoveLabel }) => ( +export const AlertLabel: FC = ({ labelKey, value, operator = '=', onRemoveLabel }) => (
- {labelKey}={isRegex && '~'} + {labelKey} + {operator} {value} {!!onRemoveLabel && }
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx index 5325fce4c31..5a4e64b7696 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx @@ -8,6 +8,7 @@ import { FieldArray, Form, HorizontalGroup, + IconButton, Input, InputControl, MultiSelect, @@ -47,9 +48,11 @@ export const AmRoutesExpandedForm: FC = ({ onCancel,
{({ control, register, errors, setValue }) => ( <> + {/* @ts-ignore-check: react-hook-form made me do this */} + {/* @ts-ignore-check: react-hook-form made me do this */} - {({ fields, append }) => ( + {({ fields, append, remove }) => ( <>
Matchers
@@ -57,18 +60,17 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, const localPath = `matchers[${index}]`; return ( - + - = = ({ onCancel, + + + + remove(index)} + > + Remove + ); })} @@ -286,6 +299,10 @@ const getStyles = (theme: GrafanaTheme2) => { nestedPolicies: css` margin-top: ${commonSpacing}; `, + removeButton: css` + margin-left: ${theme.spacing(1)}; + margin-top: ${theme.spacing(2.5)}; + `, buttonGroup: css` margin: ${theme.spacing(6)} 0 ${commonSpacing}; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index e4bbc80893e..a0254651a7e 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -8,10 +8,10 @@ import { prepareItems, removeCustomExpandedContent, } from '../../utils/dynamicTable'; -import { AlertLabels } from '../AlertLabels'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { AmRoutesExpandedForm } from './AmRoutesExpandedForm'; import { AmRoutesExpandedRead } from './AmRoutesExpandedRead'; +import { Matchers } from '../silences/Matchers'; export interface AmRoutesTableProps { isAddMode: boolean; @@ -67,17 +67,7 @@ export const AmRoutesTable: FC = ({ isAddMode, onCancelAdd, id: 'matchingCriteria', label: 'Matching criteria', // eslint-disable-next-line react/display-name - renderCell: (item) => ( - ({ - ...acc, - [matcher.label]: matcher.value, - }), - {} - )} - /> - ), + renderCell: (item) => , size: 10, }, { diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 564b5c69511..ede2423a54c 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -9,7 +9,7 @@ import { ReceiversSection } from './ReceiversSection'; import { makeAMLink } from '../../utils/misc'; import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; -import { isReceiverUsed } from '../../utils/alertmanager-config'; +import { isReceiverUsed } from '../../utils/alertmanager'; import { useDispatch } from 'react-redux'; import { deleteReceiverAction } from '../../state/actions'; diff --git a/public/app/features/alerting/unified/components/silences/Matchers.tsx b/public/app/features/alerting/unified/components/silences/Matchers.tsx index 68813866d71..7b3520cec38 100644 --- a/public/app/features/alerting/unified/components/silences/Matchers.tsx +++ b/public/app/features/alerting/unified/components/silences/Matchers.tsx @@ -2,10 +2,11 @@ import React, { useCallback } from 'react'; import { GrafanaTheme } from '@grafana/data'; import { useStyles } from '@grafana/ui'; import { css } from '@emotion/css'; -import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { AlertLabel } from '../AlertLabel'; +import { matcherToOperator } from '../../utils/alertmanager'; -type MatchersProps = { matchers: SilenceMatcher[]; onRemoveLabel?(index: number): void }; +type MatchersProps = { matchers: Matcher[]; onRemoveLabel?(index: number): void }; export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => { const styles = useStyles(getStyles); @@ -21,13 +22,14 @@ export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => { return (
- {matchers.map(({ name, value, isRegex }: SilenceMatcher, index) => { + {matchers.map((matcher, index) => { + const { name, value } = matcher; return ( removeLabel(index) : undefined} /> ); diff --git a/public/app/features/alerting/unified/components/silences/MatchersField.tsx b/public/app/features/alerting/unified/components/silences/MatchersField.tsx index a31ab86994f..7c2c9bb3c08 100644 --- a/public/app/features/alerting/unified/components/silences/MatchersField.tsx +++ b/public/app/features/alerting/unified/components/silences/MatchersField.tsx @@ -1,16 +1,17 @@ import React, { FC } from 'react'; -import { Button, Field, Input, InlineLabel, useStyles, Checkbox, IconButton } from '@grafana/ui'; -import { GrafanaTheme } from '@grafana/data'; +import { Button, Field, Input, Checkbox, IconButton, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { useFormContext, useFieldArray } from 'react-hook-form'; import { SilenceFormFields } from '../../types/silence-form'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; interface Props { className?: string; } const MatchersField: FC = ({ className }) => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const formApi = useFormContext(); const { register, @@ -24,6 +25,7 @@ const MatchersField: FC = ({ className }) => {
{matchers.map((matcher, index) => { + console.log(matcher); return (
= ({ className }) => { placeholder="name" /> - = = ({ className }) => { placeholder="value" /> - + + + + {matchers.length > 1 && ( = ({ className }) => { icon="plus" variant="secondary" onClick={() => { - append({ name: '', value: '', isRegex: false }); + const newMatcher: Matcher = { name: '', value: '', isRegex: false, isEqual: true }; + append(newMatcher); }} > Add matcher @@ -86,34 +91,29 @@ const MatchersField: FC = ({ className }) => { ); }; -const getStyles = (theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` - margin-top: ${theme.spacing.md}; + margin-top: ${theme.spacing(2)}; `, row: css` display: flex; + align-items: flex-start; flex-direction: row; - align-items: center; - background-color: ${theme.colors.bg2}; - padding: ${theme.spacing.sm} ${theme.spacing.sm} 0 ${theme.spacing.sm}; - `, - equalSign: css` - width: 28px; - justify-content: center; - margin-left: ${theme.spacing.xs}; - margin-bottom: 0; - `, - regexCheckbox: css` - margin-left: ${theme.spacing.md}; + background-color: ${theme.colors.background.secondary}; + padding: ${theme.spacing(1)} ${theme.spacing(1)} 0 ${theme.spacing(1)}; + & > * + * { + margin-left: ${theme.spacing(2)}; + } `, removeButton: css` - margin-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing(1)}; + margin-top: ${theme.spacing(2.5)}; `, matchers: css` max-width: 585px; - margin: ${theme.spacing.sm} 0; - padding-top: ${theme.spacing.xs}; + margin: ${theme.spacing(1)} 0; + padding-top: ${theme.spacing(0.5)}; `, }; }; diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index f747f9bccc2..4101304ce0d 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -62,7 +62,7 @@ const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { createdBy: config.bootData.user.name, duration: '2h', isRegex: false, - matchers: [{ name: '', value: '', isRegex: false }], + matchers: [{ name: '', value: '', isRegex: false, isEqual: true }], matcherName: '', matcherValue: '', timeZone: DefaultTimeZone, diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 00f958963ac..279bd504936 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -45,7 +45,7 @@ import { ruleWithLocationToRuleIdentifier, stringifyRuleIdentifier, } from '../utils/rules'; -import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager-config'; +import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; import { backendSrv } from 'app/core/services/backend_srv'; export const fetchPromRulesAction = createAsyncThunk( diff --git a/public/app/features/alerting/unified/types/amroutes.ts b/public/app/features/alerting/unified/types/amroutes.ts index a6fd17db2c8..d999e979848 100644 --- a/public/app/features/alerting/unified/types/amroutes.ts +++ b/public/app/features/alerting/unified/types/amroutes.ts @@ -1,11 +1,8 @@ -export interface ArrayFieldMatcher { - label: string; - value: string; - isRegex: boolean; -} +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; export interface FormAmRoute { - matchers: ArrayFieldMatcher[]; + id: string; + matchers: Matcher[]; continue: boolean; receiver: string; groupBy: string[]; diff --git a/public/app/features/alerting/unified/types/silence-form.ts b/public/app/features/alerting/unified/types/silence-form.ts index cefd466dbab..0fcc8e22b67 100644 --- a/public/app/features/alerting/unified/types/silence-form.ts +++ b/public/app/features/alerting/unified/types/silence-form.ts @@ -1,4 +1,4 @@ -import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { TimeZone } from '@grafana/data'; export type SilenceFormFields = { @@ -8,7 +8,7 @@ export type SilenceFormFields = { timeZone: TimeZone; duration: string; comment: string; - matchers: SilenceMatcher[]; + matchers: Matcher[]; createdBy: string; matcherName: string; matcherValue: string; diff --git a/public/app/features/alerting/unified/utils/alertmanager-config.ts b/public/app/features/alerting/unified/utils/alertmanager-config.ts deleted file mode 100644 index 289d8187acd..00000000000 --- a/public/app/features/alerting/unified/utils/alertmanager-config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AlertManagerCortexConfig, Route } from 'app/plugins/datasource/alertmanager/types'; - -export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { - // add default receiver if it does not exist - if (!config.alertmanager_config.receivers) { - config.alertmanager_config.receivers = [{ name: 'default ' }]; - } - // add default route if it does not exists - if (!config.alertmanager_config.route) { - config.alertmanager_config.route = { - receiver: config.alertmanager_config.receivers![0].name, - }; - } - if (!config.template_files) { - config.template_files = {}; - } - return config; -} - -function isReceiverUsedInRoute(receiver: string, route: Route): boolean { - return ( - (route.receiver === receiver || route.routes?.some((route) => isReceiverUsedInRoute(receiver, route))) ?? false - ); -} - -export function isReceiverUsed(receiver: string, config: AlertManagerCortexConfig): boolean { - return ( - (config.alertmanager_config.route && isReceiverUsedInRoute(receiver, config.alertmanager_config.route)) ?? false - ); -} diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts new file mode 100644 index 00000000000..40658005173 --- /dev/null +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -0,0 +1,69 @@ +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { parseMatcher, stringifyMatcher } from './alertmanager'; + +describe('Alertmanager utils', () => { + describe('parseMatcher', () => { + it('should parse operators correctly', () => { + expect(parseMatcher('foo=bar')).toEqual({ + name: 'foo', + value: 'bar', + isRegex: false, + isEqual: true, + }); + expect(parseMatcher('foo!=bar')).toEqual({ + name: 'foo', + value: 'bar', + isRegex: false, + isEqual: false, + }); + expect(parseMatcher('foo =~bar')).toEqual({ + name: 'foo', + value: 'bar', + isRegex: true, + isEqual: true, + }); + expect(parseMatcher('foo!~ bar')).toEqual({ + name: 'foo', + value: 'bar', + isRegex: true, + isEqual: false, + }); + }); + + it('should parse escaped values correctly', () => { + expect(parseMatcher('foo=~"bar\\"baz\\""')).toEqual({ + name: 'foo', + value: 'bar"baz"', + isRegex: true, + isEqual: true, + }); + expect(parseMatcher('foo=~bar\\"baz\\"')).toEqual({ + name: 'foo', + value: 'bar"baz"', + isRegex: true, + isEqual: true, + }); + }); + it('should parse multiple operators values correctly', () => { + expect(parseMatcher('foo=~bar=baz!=bad!~br')).toEqual({ + name: 'foo', + value: 'bar=baz!=bad!~br', + isRegex: true, + isEqual: true, + }); + }); + }); + + describe('stringifyMatcher', () => { + it('should stringify matcher correctly', () => { + expect( + stringifyMatcher({ + name: 'foo', + value: 'boo="bar"', + isRegex: true, + isEqual: false, + }) + ).toEqual('foo!~"boo=\\"bar\\""'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts new file mode 100644 index 00000000000..b4ba30c18ac --- /dev/null +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -0,0 +1,95 @@ +import { AlertManagerCortexConfig, MatcherOperator, Route, Matcher } from 'app/plugins/datasource/alertmanager/types'; + +export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { + // add default receiver if it does not exist + if (!config.alertmanager_config.receivers) { + config.alertmanager_config.receivers = [{ name: 'default ' }]; + } + // add default route if it does not exists + if (!config.alertmanager_config.route) { + config.alertmanager_config.route = { + receiver: config.alertmanager_config.receivers![0].name, + }; + } + if (!config.template_files) { + config.template_files = {}; + } + return config; +} + +function isReceiverUsedInRoute(receiver: string, route: Route): boolean { + return ( + (route.receiver === receiver || route.routes?.some((route) => isReceiverUsedInRoute(receiver, route))) ?? false + ); +} + +export function isReceiverUsed(receiver: string, config: AlertManagerCortexConfig): boolean { + return ( + (config.alertmanager_config.route && isReceiverUsedInRoute(receiver, config.alertmanager_config.route)) ?? false + ); +} + +export function matcherToOperator(matcher: Matcher): MatcherOperator { + if (matcher.isEqual) { + if (matcher.isRegex) { + return MatcherOperator.regex; + } else { + return MatcherOperator.equal; + } + } else if (matcher.isRegex) { + return MatcherOperator.notRegex; + } else { + return MatcherOperator.notEqual; + } +} + +const matcherOperators = [ + MatcherOperator.regex, + MatcherOperator.notRegex, + MatcherOperator.notEqual, + MatcherOperator.equal, +]; + +function unescapeMatcherValue(value: string) { + let trimmed = value.trim().replace(/\\"/g, '"'); + if (trimmed.startsWith('"') && trimmed.endsWith('"') && !trimmed.endsWith('\\"')) { + trimmed = trimmed.substr(1, trimmed.length - 2); + } + return trimmed.replace(/\\"/g, '"'); +} + +function escapeMatcherValue(value: string) { + return '"' + value.replace(/"/g, '\\"') + '"'; +} + +export function stringifyMatcher(matcher: Matcher): string { + return `${matcher.name}${matcherToOperator(matcher)}${escapeMatcherValue(matcher.value)}`; +} + +export function parseMatcher(matcher: string): Matcher { + const trimmed = matcher.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + throw new Error(`PromQL matchers not supported yet, sorry! PromQL matcher found: ${trimmed}`); + } + const operatorsFound = matcherOperators + .map((op): [MatcherOperator, number] => [op, trimmed.indexOf(op)]) + .filter(([_, idx]) => idx > -1) + .sort((a, b) => a[1] - b[1]); + + if (!operatorsFound.length) { + throw new Error(`Invalid matcher: ${trimmed}`); + } + const [operator, idx] = operatorsFound[0]; + const name = trimmed.substr(0, idx).trim(); + const value = unescapeMatcherValue(trimmed.substr(idx + operator.length).trim()); + if (!name) { + throw new Error(`Invalid matcher: ${trimmed}`); + } + + return { + name, + value, + isRegex: operator === MatcherOperator.regex || operator === MatcherOperator.notRegex, + isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex, + }; +} diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 84cd277e2ae..f1add63b7bc 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -1,22 +1,21 @@ import { SelectableValue } from '@grafana/data'; import { Validate } from 'react-hook-form'; -import { Route } from 'app/plugins/datasource/alertmanager/types'; -import { FormAmRoute, ArrayFieldMatcher } from '../types/amroutes'; +import { Matcher, Route } from 'app/plugins/datasource/alertmanager/types'; +import { FormAmRoute } from '../types/amroutes'; import { parseInterval, timeOptions } from './time'; +import { parseMatcher, stringifyMatcher } from './alertmanager'; const defaultValueAndType: [string, string] = ['', timeOptions[0].value]; -const matchersToArrayFieldMatchers = ( - matchers: Record | undefined, - isRegex: boolean -): ArrayFieldMatcher[] => +const matchersToArrayFieldMatchers = (matchers: Record | undefined, isRegex: boolean): Matcher[] => Object.entries(matchers ?? {}).reduce( - (acc, [label, value]) => [ + (acc, [name, value]) => [ ...acc, { - label, + name, value, isRegex: isRegex, + isEqual: true, }, ], [] @@ -43,13 +42,15 @@ const selectableValueToString = (selectableValue: SelectableValue): stri const selectableValuesToStrings = (arr: Array> | undefined): string[] => (arr ?? []).map(selectableValueToString); -export const emptyArrayFieldMatcher: ArrayFieldMatcher = { - label: '', +export const emptyArrayFieldMatcher: Matcher = { + name: '', value: '', isRegex: false, + isEqual: true, }; export const emptyRoute: FormAmRoute = { + id: '', matchers: [emptyArrayFieldMatcher], groupBy: [], routes: [], @@ -63,50 +64,59 @@ export const emptyRoute: FormAmRoute = { repeatIntervalValueType: timeOptions[0].value, }; -export const amRouteToFormAmRoute = (route: Route | undefined): FormAmRoute => { +//returns route, and a record mapping id to existing route route +export const amRouteToFormAmRoute = (route: Route | undefined): [FormAmRoute, Record] => { if (!route || Object.keys(route).length === 0) { - return emptyRoute; + return [emptyRoute, {}]; } const [groupWaitValue, groupWaitValueType] = intervalToValueAndType(route.group_wait); const [groupIntervalValue, groupIntervalValueType] = intervalToValueAndType(route.group_interval); const [repeatIntervalValue, repeatIntervalValueType] = intervalToValueAndType(route.repeat_interval); - return { - matchers: [ - ...matchersToArrayFieldMatchers(route.match, false), - ...matchersToArrayFieldMatchers(route.match_re, true), - ], - continue: route.continue ?? false, - receiver: route.receiver ?? '', - groupBy: route.group_by ?? [], - groupWaitValue, - groupWaitValueType, - groupIntervalValue, - groupIntervalValueType, - repeatIntervalValue, - repeatIntervalValueType, - routes: (route.routes ?? []).map(amRouteToFormAmRoute), + const id = String(Math.random()); + const id2route = { + [id]: route, }; + const formRoutes: FormAmRoute[] = []; + route.routes?.forEach((subRoute) => { + const [subFormRoute, subId2Route] = amRouteToFormAmRoute(subRoute); + formRoutes.push(subFormRoute); + Object.assign(id2route, subId2Route); + }); + + return [ + { + id, + matchers: [ + ...(route.matchers?.map(parseMatcher) ?? []), + ...matchersToArrayFieldMatchers(route.match, false), + ...matchersToArrayFieldMatchers(route.match_re, true), + ], + continue: route.continue ?? false, + receiver: route.receiver ?? '', + groupBy: route.group_by ?? [], + groupWaitValue, + groupWaitValueType, + groupIntervalValue, + groupIntervalValueType, + repeatIntervalValue, + repeatIntervalValueType, + routes: formRoutes, + }, + id2route, + ]; }; -export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute): Route => { +export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute, id2ExistingRoute: Record): Route => { + const existing: Route | undefined = id2ExistingRoute[formAmRoute.id]; const amRoute: Route = { + ...(existing ?? {}), continue: formAmRoute.continue, group_by: formAmRoute.groupBy, - ...Object.values(formAmRoute.matchers).reduce( - (acc, { label, value, isRegex }) => { - const target = acc[isRegex ? 'match_re' : 'match']; - - target![label] = value; - - return acc; - }, - { - match: {}, - match_re: {}, - } as Pick - ), + matchers: formAmRoute.matchers.length ? formAmRoute.matchers.map(stringifyMatcher) : undefined, + match: undefined, + match_re: undefined, group_wait: formAmRoute.groupWaitValue ? `${formAmRoute.groupWaitValue}${formAmRoute.groupWaitValueType}` : undefined, @@ -116,7 +126,7 @@ export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute): Route => { repeat_interval: formAmRoute.repeatIntervalValue ? `${formAmRoute.repeatIntervalValue}${formAmRoute.repeatIntervalValueType}` : undefined, - routes: formAmRoute.routes.map(formAmRouteToAmRoute), + routes: formAmRoute.routes.map((subRoute) => formAmRouteToAmRoute(subRoute, id2ExistingRoute)), }; if (formAmRoute.receiver) { diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index fff29b0f6ac..add15e180bc 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -98,6 +98,7 @@ export type Route = { receiver?: string; group_by?: string[]; continue?: boolean; + matchers?: string[]; match?: Record; match_re?: Record; group_wait?: string; @@ -142,10 +143,11 @@ export type AlertmanagerConfig = { receivers?: Receiver[]; }; -export type SilenceMatcher = { +export type Matcher = { name: string; value: string; isRegex: boolean; + isEqual: boolean; }; export enum SilenceState { @@ -160,9 +162,16 @@ export enum AlertState { Suppressed = 'suppressed', } +export enum MatcherOperator { + equal = '=', + notEqual = '!=', + regex = '=~', + notRegex = '!~', +} + export type Silence = { id: string; - matchers?: SilenceMatcher[]; + matchers?: Matcher[]; startsAt: string; endsAt: string; updatedAt: string; @@ -175,7 +184,7 @@ export type Silence = { export type SilenceCreatePayload = { id?: string; - matchers?: SilenceMatcher[]; + matchers?: Matcher[]; startsAt: string; endsAt: string; createdBy: string;