diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 2d29c11cdb3..5309c537cfc 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -54,8 +54,8 @@ const AmRoutes = () => { const [contactPointFilter, setContactPointFilter] = useState(); const [labelMatchersFilter, setLabelMatchersFilter] = useState([]); + const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager(); const { getRouteGroupsMap } = useRouteGroupsMatcher(); - const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager(); const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? ''); @@ -93,9 +93,9 @@ const AmRoutes = () => { useEffect(() => { if (rootRoute && alertGroups) { - triggerGetRouteGroupsMap(rootRoute, alertGroups); + triggerGetRouteGroupsMap(rootRoute, alertGroups, { unquoteMatchers: !isGrafanaAlertmanager }); } - }, [rootRoute, alertGroups, triggerGetRouteGroupsMap]); + }, [rootRoute, alertGroups, triggerGetRouteGroupsMap, isGrafanaAlertmanager]); // these are computed from the contactPoint and labels matchers filter const routesMatchingFilters = useMemo(() => { diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index f7eabb4896d..0d2ca067d4e 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -134,7 +134,7 @@ export const AmRoutesExpandedForm = ({ error={errors.object_matchers?.[index]?.value?.message} > diff --git a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx index f379658d2c7..458dc4a0258 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx @@ -6,12 +6,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui'; import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherFormatter, matcherFormatter } from '../../utils/matchers'; import { HoverCard } from '../HoverCard'; -type MatchersProps = { matchers: ObjectMatcher[] }; +type MatchersProps = { matchers: ObjectMatcher[]; formatter?: MatcherFormatter }; // renders the first N number of matchers -const Matchers: FC = ({ matchers }) => { +const Matchers: FC = ({ matchers, formatter = 'default' }) => { const styles = useStyles2(getStyles); const NUM_MATCHERS = 5; @@ -24,7 +25,7 @@ const Matchers: FC = ({ matchers }) => { {firstFew.map((matcher) => ( - + ))} {/* TODO hover state to show all matchers we're not showing */} {hasMoreMatchers && ( @@ -51,15 +52,16 @@ const Matchers: FC = ({ matchers }) => { interface MatcherBadgeProps { matcher: ObjectMatcher; + formatter?: MatcherFormatter; } -const MatcherBadge: FC = ({ matcher: [label, operator, value] }) => { +const MatcherBadge: FC = ({ matcher, formatter = 'default' }) => { const styles = useStyles2(getStyles); return ( -
+
- {label} {operator} {value} + {matcherFormatter[formatter](matcher)}
); diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index 099642f87d3..9985b7261f3 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -11,6 +11,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; +import { MatcherFormatter } from '../../utils/matchers'; import { AlertGroup } from '../alert-groups/AlertGroup'; import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp'; @@ -210,6 +211,7 @@ const useAlertGroupsModal = (): [ const [showModal, setShowModal] = useState(false); const [alertGroups, setAlertGroups] = useState([]); const [matchers, setMatchers] = useState([]); + const [formatter, setFormatter] = useState('default'); const handleDismiss = useCallback(() => { setShowModal(false); @@ -217,13 +219,19 @@ const useAlertGroupsModal = (): [ setMatchers([]); }, []); - const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => { - setAlertGroups(alertGroups); - if (matchers) { - setMatchers(matchers); - } - setShowModal(true); - }, []); + const handleShow = useCallback( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter) => { + setAlertGroups(alertGroups); + if (matchers) { + setMatchers(matchers); + } + if (formatter) { + setFormatter(formatter); + } + setShowModal(true); + }, + [] + ); const instancesByState = useMemo(() => { const instances = alertGroups.flatMap((group) => group.alerts); @@ -242,7 +250,7 @@ const useAlertGroupsModal = (): [ Matchers - + } > @@ -265,7 +273,7 @@ const useAlertGroupsModal = (): [ ), - [alertGroups, handleDismiss, instancesByState, matchers, showModal] + [alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal] ); return [modalElement, handleShow, handleDismiss]; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index b6ba61d28c5..c2069f80cf7 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -32,7 +32,8 @@ import { ReceiversState } from 'app/types'; import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { INTEGRATION_ICONS } from '../../types/contact-points'; -import { normalizeMatchers } from '../../utils/matchers'; +import { getAmMatcherFormatter } from '../../utils/alertmanager'; +import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies'; import { Authorize } from '../Authorize'; @@ -55,7 +56,6 @@ interface PolicyComponentProps { provisioned?: boolean; inheritedProperties?: Partial; routesMatchingFilters?: RouteWithID[]; - // routeAlertGroupsMap?: Map; matchingInstancesPreview?: { groupsMap?: Map; enabled: boolean }; @@ -65,7 +65,11 @@ interface PolicyComponentProps { onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void; onAddPolicy: (route: RouteWithID) => void; onDeletePolicy: (route: RouteWithID) => void; - onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; isAutoGenerated?: boolean; } @@ -194,7 +198,7 @@ const Policy = (props: PolicyComponentProps) => { ) ) : hasMatchers ? ( - + ) : ( No matchers )} @@ -325,7 +329,11 @@ interface MetadataRowProps { matchingAlertGroups?: AlertmanagerGroup[]; matchers?: ObjectMatcher[]; isDefaultPolicy: boolean; - onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; } function MetadataRow({ @@ -361,7 +369,8 @@ function MetadataRow({ { - matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); + matchingAlertGroups && + onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName)); }} data-testid="matching-instances" > diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx index ad459deedb0..cb7c4492d1c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx @@ -4,18 +4,24 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; +import { MatcherFormatter } from '../../../utils/matchers'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) { +interface Props { + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) { const styles = useStyles2(getStyles); if (isDefaultPolicy(route)) { return
Default policy
; } else if (hasEmptyMatchers(route)) { return
No matchers
; } else { - return ; + return ; } } diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx index 1a7f9a13944..a95e1bbb8a6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx @@ -9,6 +9,7 @@ import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/ import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; import { AlertInstanceMatch } from '../../../utils/notification-policies'; import { CollapseToggle } from '../../CollapseToggle'; import { MetaText } from '../../MetaText'; @@ -58,7 +59,10 @@ function NotificationRouteHeader({
onExpandRouteClick(!expandRoute)} className={styles.expandable}> Notification policy - +
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx index f66e525f8a1..8de20bc227d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx @@ -3,20 +3,26 @@ import { compact } from 'lodash'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Icon, Modal, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Modal, Stack, useStyles2 } from '@grafana/ui'; import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; -import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; import { AlertmanagerAction } from '../../../hooks/useAbilities'; import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; -import { GRAFANA_DATASOURCE_NAME } from '../../../utils/datasource'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; +import { MatcherFormatter } from '../../../utils/matchers'; import { makeAMLink } from '../../../utils/misc'; import { Authorize } from '../../Authorize'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map; route: RouteWithPath }) { +interface Props { + routesByIdMap: Map; + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) { const styles = useStyles2(getStyles); const routePathIds = route.path?.slice(1) ?? []; const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; @@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: MapNo matchers
) : ( - + )} @@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({ const isDefault = isDefaultPolicy(route); return ( - + {!isDefault && ( <> - + )}
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts index 0762ae77aa2..a8df723da12 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -6,6 +6,7 @@ import { Labels } from '../../../../../../types/unified-alerting-dto'; import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from '../../../utils/amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies'; import { getRoutesByIdMap, RouteWithPath } from './route'; @@ -55,7 +56,9 @@ export const useAlertmanagerNotificationRoutingPreview = ( if (!rootRoute) { return; } - return await matchInstancesToRoute(rootRoute, potentialInstances); + return await matchInstancesToRoute(rootRoute, potentialInstances, { + unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME, + }); }, [rootRoute, potentialInstances]); return { diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.ts b/public/app/features/alerting/unified/routeGroupsMatcher.ts index 80b951ffc14..bce330c7f78 100644 --- a/public/app/features/alerting/unified/routeGroupsMatcher.ts +++ b/public/app/features/alerting/unified/routeGroupsMatcher.ts @@ -6,11 +6,20 @@ import { findMatchingAlertGroups, findMatchingRoutes, normalizeRoute, + unquoteRouteMatchers, } from './utils/notification-policies'; +export interface MatchOptions { + unquoteMatchers?: boolean; +} + export const routeGroupsMatcher = { - getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map { - const normalizedRootRoute = normalizeRoute(rootRoute); + getRouteGroupsMap( + rootRoute: RouteWithID, + groups: AlertmanagerGroup[], + options?: MatchOptions + ): Map { + const normalizedRootRoute = getNormalizedRoute(rootRoute, options); function addRouteGroups(route: RouteWithID, acc: Map) { const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); @@ -25,10 +34,14 @@ export const routeGroupsMatcher = { return routeGroupsMap; }, - matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map { + matchInstancesToRoute( + routeTree: RouteWithID, + instancesToMatch: Labels[], + options?: MatchOptions + ): Map { const result = new Map(); - const normalizedRootRoute = normalizeRoute(routeTree); + const normalizedRootRoute = getNormalizedRoute(routeTree, options); instancesToMatch.forEach((instance) => { const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance)); @@ -47,4 +60,8 @@ export const routeGroupsMatcher = { }, }; +function getNormalizedRoute(route: RouteWithID, options?: MatchOptions): RouteWithID { + return options?.unquoteMatchers ? unquoteRouteMatchers(normalizeRoute(route)) : normalizeRoute(route); +} + export type RouteGroupsMatcher = typeof routeGroupsMatcher; diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index 2b57e4e0d34..a48d3e4c611 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -6,7 +6,7 @@ import { Labels } from '../../../types/unified-alerting-dto'; import { logError, logInfo } from './Analytics'; import { createWorker } from './createRouteGroupsMatcherWorker'; -import type { RouteGroupsMatcher } from './routeGroupsMatcher'; +import type { MatchOptions, RouteGroupsMatcher } from './routeGroupsMatcher'; let routeMatcher: comlink.Remote | undefined; @@ -55,43 +55,49 @@ export function useRouteGroupsMatcher() { return () => null; }, []); - const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => { - validateWorker(routeMatcher); + const getRouteGroupsMap = useCallback( + async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups); + const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Route Groups Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - alertGroupsCount: alertGroups.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Route Groups Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + alertGroupsCount: alertGroups.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); - const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { - validateWorker(routeMatcher); + const matchInstancesToRoute = useCallback( + async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Instances Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - instancesToMatchCount: instancesToMatch.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Instances Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + instancesToMatchCount: instancesToMatch.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); return { getRouteGroupsMap, matchInstancesToRoute }; } diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index e81b9195ffc..2738b703844 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -15,7 +15,8 @@ import { Labels } from 'app/types/unified-alerting-dto'; import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; -import { DataSourceType } from './datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { MatcherFormatter, unquoteWithUnescape } from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -53,6 +54,10 @@ export function renameMuteTimings(newMuteTimingName: string, oldMuteTimingName: }; } +export function unescapeObjectMatchers(matchers: ObjectMatcher[]): ObjectMatcher[] { + return matchers.map(([name, operator, value]) => [name, operator, unquoteWithUnescape(value)]); +} + export function matcherToOperator(matcher: Matcher): MatcherOperator { if (matcher.isEqual) { if (matcher.isRegex) { @@ -177,6 +182,10 @@ export function combineMatcherStrings(...matcherStrings: string[]): string { return matchersToString(uniqueMatchers); } +export function getAmMatcherFormatter(alertmanagerSourceName?: string): MatcherFormatter { + return alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME ? 'default' : 'unquote'; +} + export function getAllAlertmanagerDataSources() { return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager); } diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 3c66e772d0b..dbb71f012c2 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -1,8 +1,9 @@ -import { Route } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; const emptyAmRoute: Route = { receiver: '', @@ -53,6 +54,58 @@ describe('formAmRouteToAmRoute', () => { expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']); }); }); + + it('should quote and escape matcher values', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual([ + 'foo="bar"', + 'foo="bar\\"baz"', + 'foo="bar\\\\baz"', + 'foo="\\\\bar\\\\baz\\"\\\\"', + ]); + }); + + it('should allow matchers with empty values for cloud AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual(['foo=""']); + }); + + it('should allow matchers with empty values for Grafana AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute(GRAFANA_RULES_SOURCE_NAME, route, { id: 'root' }); + + // Assert + expect(amRoute.object_matchers).toStrictEqual([['foo', MatcherOperator.equal, '']]); + }); }); describe('amRouteToFormAmRoute', () => { @@ -101,4 +154,23 @@ describe('amRouteToFormAmRoute', () => { expect(formRoute.overrideGrouping).toBe(true); }); }); + + it('should unquote and unescape matchers values', () => { + // Arrange + const amRoute = buildAmRoute({ + matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'], + }); + + // Act + const formRoute = amRouteToFormAmRoute(amRoute); + + // Assert + expect(formRoute.object_matchers).toStrictEqual([ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ]); + }); }); diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 9fc29f0c3d0..f3724401587 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { normalizeMatchers, parseMatcher } from './matchers'; +import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration, safeParseDurationstr } from './time'; @@ -94,7 +94,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo const objectMatchers = route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? []; - const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? []; + const matchers = + route.matchers + ?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) + .map(({ name, operator, value }) => ({ + name, + operator, + value: unquoteWithUnescape(value), + })) ?? []; return { id, @@ -149,8 +156,10 @@ export const formAmRouteToAmRoute = ( const overrideRepeatInterval = overrideTimings && repeatIntervalValue; const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT; + + // Empty matcher values are valid. Such matchers require specified label to not exists const object_matchers: ObjectMatcher[] | undefined = formAmRoute.object_matchers - ?.filter((route) => route.name && route.value && route.operator) + ?.filter((route) => route.name && route.operator && route.value !== null && route.value !== undefined) .map(({ name, operator, value }) => [name, operator, value]); const routes = formAmRoute.routes?.map((subRoute) => @@ -176,7 +185,9 @@ export const formAmRouteToAmRoute = ( // Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this // does not exist in upstream AlertManager if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { - amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`); + amRoute.matchers = formAmRoute.object_matchers?.map( + ({ name, operator, value }) => `${name}${operator}${quoteWithEscape(value)}` + ); amRoute.object_matchers = undefined; } else { amRoute.object_matchers = normalizeMatchers(amRoute); diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 43b3cbc2320..9f879789a22 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,6 +1,12 @@ import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; -import { getMatcherQueryParams, normalizeMatchers, parseQueryParamMatchers } from './matchers'; +import { + getMatcherQueryParams, + normalizeMatchers, + parseQueryParamMatchers, + quoteWithEscape, + unquoteWithUnescape, +} from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -61,3 +67,37 @@ describe('Unified Alerting matchers', () => { }); }); }); + +describe('quoteWithEscape', () => { + const samples: string[][] = [ + ['bar', '"bar"'], + ['b"ar"', '"b\\"ar\\""'], + ['b\\ar\\', '"b\\\\ar\\\\"'], + ['wa{r}ni$ng!', '"wa{r}ni$ng!"'], + ]; + + it.each(samples)('should escape and quote %s to %s', (raw, quoted) => { + const quotedMatcher = quoteWithEscape(raw); + expect(quotedMatcher).toBe(quoted); + }); +}); + +describe('unquoteWithUnescape', () => { + const samples: string[][] = [ + ['bar', 'bar'], + ['"bar"', 'bar'], + ['"b\\"ar\\""', 'b"ar"'], + ['"b\\\\ar\\\\"', 'b\\ar\\'], + ['"wa{r}ni$ng!"', 'wa{r}ni$ng!'], + ]; + + it.each(samples)('should unquote and unescape %s to %s', (quoted, raw) => { + const unquotedMatcher = unquoteWithUnescape(quoted); + expect(unquotedMatcher).toBe(raw); + }); + + it('should not unescape unquoted string', () => { + const unquoted = unquoteWithUnescape('un\\"quo\\\\ted'); + expect(unquoted).toBe('un\\"quo\\\\ted'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index aa1b7187f85..7b8c7ef4a05 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -108,4 +108,41 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => { return matchers; }; +/** + * Quotes string and escapes double quote and backslash characters + */ +export function quoteWithEscape(input: string) { + const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`); + return `"${escaped}"`; +} + +/** + * Unquotes and unescapes a string **if it has been quoted** + */ +export function unquoteWithUnescape(input: string) { + if (!/^"(.*)"$/.test(input)) { + return input; + } + + return input + .replace(/^"(.*)"$/, '$1') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} + +export const matcherFormatter = { + default: ([name, operator, value]: ObjectMatcher): string => { + // Value can be an empty string which we want to display as "" + const formattedValue = value || ''; + return `${name} ${operator} ${formattedValue}`; + }, + unquote: ([name, operator, value]: ObjectMatcher): string => { + // Unquoted value can be an empty string which we want to display as "" + const unquotedValue = unquoteWithUnescape(value) || '""'; + return `${name} ${operator} ${unquotedValue}`; + }, +} as const; + +export type MatcherFormatter = keyof typeof matcherFormatter; + export type Label = [string, string]; diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index d25de2728fa..a0fed72bd66 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -9,7 +9,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { Label, normalizeMatchers } from './matchers'; +import { Label, normalizeMatchers, unquoteWithUnescape } from './matchers'; // If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true // So we cannot use null as an indicator of no match @@ -124,6 +124,20 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { return normalizedRootRoute; } +export function unquoteRouteMatchers(route: RouteWithID): RouteWithID { + function unquoteRoute(route: RouteWithID) { + route.object_matchers = route.object_matchers?.map(([name, operator, value]) => { + return [name, operator, unquoteWithUnescape(value)]; + }); + route.routes?.forEach(unquoteRoute); + } + + const unwrappedRootRoute = structuredClone(route); + unquoteRoute(unwrappedRootRoute); + + return unwrappedRootRoute; +} + /** * find all of the groups that have instances that match the route, thay way we can find all instances * (and their grouping) for the given route