mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add support for UTF-8 characters in notification policies and silences (#81455)
* Add label matcher validation to support UTF-8 characters
* Add double quotes wrapping and escaping on displating matcher form inputs
* Apply matchers encoding and decoding on the RTKQ layer
* Fix unescaping order
* Revert "Apply matchers encoding and decoding on the RTKQ layer"
This reverts commit 4d963c43b5
.
* Add matchers formatter
* Fix code organization to prevent breaking worker
* Add matcher formatter to Policy and Modal components
* Unquote matchers when finding matching policy instances
* Add tests for quoting and unquoting
* Rename cloud matcher formatter
* Revert unintended change
* Allow empty matcher values
* fix test
This commit is contained in:
parent
790e1feb93
commit
fbdd27c237
@ -54,8 +54,8 @@ const AmRoutes = () => {
|
|||||||
const [contactPointFilter, setContactPointFilter] = useState<string | undefined>();
|
const [contactPointFilter, setContactPointFilter] = useState<string | undefined>();
|
||||||
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
|
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
|
||||||
|
|
||||||
|
const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager();
|
||||||
const { getRouteGroupsMap } = useRouteGroupsMatcher();
|
const { getRouteGroupsMap } = useRouteGroupsMatcher();
|
||||||
const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager();
|
|
||||||
|
|
||||||
const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? '');
|
const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? '');
|
||||||
|
|
||||||
@ -93,9 +93,9 @@ const AmRoutes = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rootRoute && alertGroups) {
|
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
|
// these are computed from the contactPoint and labels matchers filter
|
||||||
const routesMatchingFilters = useMemo(() => {
|
const routesMatchingFilters = useMemo(() => {
|
||||||
|
@ -134,7 +134,7 @@ export const AmRoutesExpandedForm = ({
|
|||||||
error={errors.object_matchers?.[index]?.value?.message}
|
error={errors.object_matchers?.[index]?.value?.message}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
{...register(`object_matchers.${index}.value`, { required: 'Field is required' })}
|
{...register(`object_matchers.${index}.value`)}
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
/>
|
/>
|
||||||
|
@ -6,12 +6,13 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui';
|
import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui';
|
||||||
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
|
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
|
import { MatcherFormatter, matcherFormatter } from '../../utils/matchers';
|
||||||
import { HoverCard } from '../HoverCard';
|
import { HoverCard } from '../HoverCard';
|
||||||
|
|
||||||
type MatchersProps = { matchers: ObjectMatcher[] };
|
type MatchersProps = { matchers: ObjectMatcher[]; formatter?: MatcherFormatter };
|
||||||
|
|
||||||
// renders the first N number of matchers
|
// renders the first N number of matchers
|
||||||
const Matchers: FC<MatchersProps> = ({ matchers }) => {
|
const Matchers: FC<MatchersProps> = ({ matchers, formatter = 'default' }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const NUM_MATCHERS = 5;
|
const NUM_MATCHERS = 5;
|
||||||
@ -24,7 +25,7 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
|
|||||||
<span data-testid="label-matchers">
|
<span data-testid="label-matchers">
|
||||||
<Stack direction="row" gap={1} alignItems="center" wrap={'wrap'}>
|
<Stack direction="row" gap={1} alignItems="center" wrap={'wrap'}>
|
||||||
{firstFew.map((matcher) => (
|
{firstFew.map((matcher) => (
|
||||||
<MatcherBadge key={uniqueId()} matcher={matcher} />
|
<MatcherBadge key={uniqueId()} matcher={matcher} formatter={formatter} />
|
||||||
))}
|
))}
|
||||||
{/* TODO hover state to show all matchers we're not showing */}
|
{/* TODO hover state to show all matchers we're not showing */}
|
||||||
{hasMoreMatchers && (
|
{hasMoreMatchers && (
|
||||||
@ -51,15 +52,16 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
|
|||||||
|
|
||||||
interface MatcherBadgeProps {
|
interface MatcherBadgeProps {
|
||||||
matcher: ObjectMatcher;
|
matcher: ObjectMatcher;
|
||||||
|
formatter?: MatcherFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher: [label, operator, value] }) => {
|
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.matcher(label).wrapper}>
|
<div className={styles.matcher(matcher[0]).wrapper}>
|
||||||
<Stack direction="row" gap={0} alignItems="baseline">
|
<Stack direction="row" gap={0} alignItems="baseline">
|
||||||
{label} {operator} {value}
|
{matcherFormatter[formatter](matcher)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { FormAmRoute } from '../../types/amroutes';
|
import { FormAmRoute } from '../../types/amroutes';
|
||||||
|
import { MatcherFormatter } from '../../utils/matchers';
|
||||||
import { AlertGroup } from '../alert-groups/AlertGroup';
|
import { AlertGroup } from '../alert-groups/AlertGroup';
|
||||||
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
|
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
|
||||||
|
|
||||||
@ -210,6 +211,7 @@ const useAlertGroupsModal = (): [
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [alertGroups, setAlertGroups] = useState<AlertmanagerGroup[]>([]);
|
const [alertGroups, setAlertGroups] = useState<AlertmanagerGroup[]>([]);
|
||||||
const [matchers, setMatchers] = useState<ObjectMatcher[]>([]);
|
const [matchers, setMatchers] = useState<ObjectMatcher[]>([]);
|
||||||
|
const [formatter, setFormatter] = useState<MatcherFormatter>('default');
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
@ -217,13 +219,19 @@ const useAlertGroupsModal = (): [
|
|||||||
setMatchers([]);
|
setMatchers([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => {
|
const handleShow = useCallback(
|
||||||
setAlertGroups(alertGroups);
|
(alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter) => {
|
||||||
if (matchers) {
|
setAlertGroups(alertGroups);
|
||||||
setMatchers(matchers);
|
if (matchers) {
|
||||||
}
|
setMatchers(matchers);
|
||||||
setShowModal(true);
|
}
|
||||||
}, []);
|
if (formatter) {
|
||||||
|
setFormatter(formatter);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const instancesByState = useMemo(() => {
|
const instancesByState = useMemo(() => {
|
||||||
const instances = alertGroups.flatMap((group) => group.alerts);
|
const instances = alertGroups.flatMap((group) => group.alerts);
|
||||||
@ -242,7 +250,7 @@ const useAlertGroupsModal = (): [
|
|||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
<Icon name="x" /> Matchers
|
<Icon name="x" /> Matchers
|
||||||
</Stack>
|
</Stack>
|
||||||
<Matchers matchers={matchers} />
|
<Matchers matchers={matchers} formatter={formatter} />
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -265,7 +273,7 @@ const useAlertGroupsModal = (): [
|
|||||||
</Modal.ButtonRow>
|
</Modal.ButtonRow>
|
||||||
</Modal>
|
</Modal>
|
||||||
),
|
),
|
||||||
[alertGroups, handleDismiss, instancesByState, matchers, showModal]
|
[alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [modalElement, handleShow, handleDismiss];
|
return [modalElement, handleShow, handleDismiss];
|
||||||
|
@ -32,7 +32,8 @@ import { ReceiversState } from 'app/types';
|
|||||||
|
|
||||||
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||||
import { INTEGRATION_ICONS } from '../../types/contact-points';
|
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 { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
|
||||||
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
|
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
|
||||||
import { Authorize } from '../Authorize';
|
import { Authorize } from '../Authorize';
|
||||||
@ -55,7 +56,6 @@ interface PolicyComponentProps {
|
|||||||
provisioned?: boolean;
|
provisioned?: boolean;
|
||||||
inheritedProperties?: Partial<InheritableProperties>;
|
inheritedProperties?: Partial<InheritableProperties>;
|
||||||
routesMatchingFilters?: RouteWithID[];
|
routesMatchingFilters?: RouteWithID[];
|
||||||
// routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
|
|
||||||
|
|
||||||
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
|
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
|
||||||
|
|
||||||
@ -65,7 +65,11 @@ interface PolicyComponentProps {
|
|||||||
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
|
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
|
||||||
onAddPolicy: (route: RouteWithID) => void;
|
onAddPolicy: (route: RouteWithID) => void;
|
||||||
onDeletePolicy: (route: RouteWithID) => void;
|
onDeletePolicy: (route: RouteWithID) => void;
|
||||||
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
|
onShowAlertInstances: (
|
||||||
|
alertGroups: AlertmanagerGroup[],
|
||||||
|
matchers?: ObjectMatcher[],
|
||||||
|
formatter?: MatcherFormatter
|
||||||
|
) => void;
|
||||||
isAutoGenerated?: boolean;
|
isAutoGenerated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +198,7 @@ const Policy = (props: PolicyComponentProps) => {
|
|||||||
<DefaultPolicyIndicator />
|
<DefaultPolicyIndicator />
|
||||||
)
|
)
|
||||||
) : hasMatchers ? (
|
) : hasMatchers ? (
|
||||||
<Matchers matchers={matchers ?? []} />
|
<Matchers matchers={matchers ?? []} formatter={getAmMatcherFormatter(alertManagerSourceName)} />
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.metadata}>No matchers</span>
|
<span className={styles.metadata}>No matchers</span>
|
||||||
)}
|
)}
|
||||||
@ -325,7 +329,11 @@ interface MetadataRowProps {
|
|||||||
matchingAlertGroups?: AlertmanagerGroup[];
|
matchingAlertGroups?: AlertmanagerGroup[];
|
||||||
matchers?: ObjectMatcher[];
|
matchers?: ObjectMatcher[];
|
||||||
isDefaultPolicy: boolean;
|
isDefaultPolicy: boolean;
|
||||||
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
|
onShowAlertInstances: (
|
||||||
|
alertGroups: AlertmanagerGroup[],
|
||||||
|
matchers?: ObjectMatcher[],
|
||||||
|
formatter?: MatcherFormatter
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetadataRow({
|
function MetadataRow({
|
||||||
@ -361,7 +369,8 @@ function MetadataRow({
|
|||||||
<MetaText
|
<MetaText
|
||||||
icon="layers-alt"
|
icon="layers-alt"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
|
matchingAlertGroups &&
|
||||||
|
onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName));
|
||||||
}}
|
}}
|
||||||
data-testid="matching-instances"
|
data-testid="matching-instances"
|
||||||
>
|
>
|
||||||
|
@ -4,18 +4,24 @@ import React from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { MatcherFormatter } from '../../../utils/matchers';
|
||||||
import { Matchers } from '../../notification-policies/Matchers';
|
import { Matchers } from '../../notification-policies/Matchers';
|
||||||
|
|
||||||
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
|
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);
|
const styles = useStyles2(getStyles);
|
||||||
if (isDefaultPolicy(route)) {
|
if (isDefaultPolicy(route)) {
|
||||||
return <div className={styles.defaultPolicy}>Default policy</div>;
|
return <div className={styles.defaultPolicy}>Default policy</div>;
|
||||||
} else if (hasEmptyMatchers(route)) {
|
} else if (hasEmptyMatchers(route)) {
|
||||||
return <div className={styles.textMuted}>No matchers</div>;
|
return <div className={styles.textMuted}>No matchers</div>;
|
||||||
} else {
|
} else {
|
||||||
return <Matchers matchers={route.object_matchers ?? []} />;
|
return <Matchers matchers={route.object_matchers ?? []} formatter={matcherFormatter} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/
|
|||||||
|
|
||||||
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||||
|
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
|
||||||
import { AlertInstanceMatch } from '../../../utils/notification-policies';
|
import { AlertInstanceMatch } from '../../../utils/notification-policies';
|
||||||
import { CollapseToggle } from '../../CollapseToggle';
|
import { CollapseToggle } from '../../CollapseToggle';
|
||||||
import { MetaText } from '../../MetaText';
|
import { MetaText } from '../../MetaText';
|
||||||
@ -58,7 +59,10 @@ function NotificationRouteHeader({
|
|||||||
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
|
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
|
||||||
<Stack gap={1} direction="row" alignItems="center">
|
<Stack gap={1} direction="row" alignItems="center">
|
||||||
Notification policy
|
Notification policy
|
||||||
<NotificationPolicyMatchers route={route} />
|
<NotificationPolicyMatchers
|
||||||
|
route={route}
|
||||||
|
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
@ -3,20 +3,26 @@ import { compact } from 'lodash';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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 { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
|
||||||
import { AlertmanagerAction } from '../../../hooks/useAbilities';
|
import { AlertmanagerAction } from '../../../hooks/useAbilities';
|
||||||
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
|
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 { makeAMLink } from '../../../utils/misc';
|
||||||
import { Authorize } from '../../Authorize';
|
import { Authorize } from '../../Authorize';
|
||||||
import { Matchers } from '../../notification-policies/Matchers';
|
import { Matchers } from '../../notification-policies/Matchers';
|
||||||
|
|
||||||
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
|
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
|
||||||
|
|
||||||
function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, RouteWithPath>; route: RouteWithPath }) {
|
interface Props {
|
||||||
|
routesByIdMap: Map<string, RouteWithPath>;
|
||||||
|
route: RouteWithPath;
|
||||||
|
matcherFormatter: MatcherFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const routePathIds = route.path?.slice(1) ?? [];
|
const routePathIds = route.path?.slice(1) ?? [];
|
||||||
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
|
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
|
||||||
@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, Route
|
|||||||
{hasEmptyMatchers(pathRoute) ? (
|
{hasEmptyMatchers(pathRoute) ? (
|
||||||
<div className={styles.textMuted}>No matchers</div>
|
<div className={styles.textMuted}>No matchers</div>
|
||||||
) : (
|
) : (
|
||||||
<Matchers matchers={pathRoute.object_matchers ?? []} />
|
<Matchers matchers={pathRoute.object_matchers ?? []} formatter={matcherFormatter} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({
|
|||||||
const isDefault = isDefaultPolicy(route);
|
const isDefault = isDefaultPolicy(route);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={GRAFANA_DATASOURCE_NAME}>
|
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}>
|
||||||
<Modal
|
<Modal
|
||||||
className={styles.detailsModal}
|
className={styles.detailsModal}
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
@ -77,7 +83,11 @@ export function NotificationRouteDetailsModal({
|
|||||||
<div className={styles.separator(1)} />
|
<div className={styles.separator(1)} />
|
||||||
{!isDefault && (
|
{!isDefault && (
|
||||||
<>
|
<>
|
||||||
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
|
<PolicyPath
|
||||||
|
route={route}
|
||||||
|
routesByIdMap={routesByIdMap}
|
||||||
|
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={styles.separator(4)} />
|
<div className={styles.separator(4)} />
|
||||||
|
@ -6,6 +6,7 @@ import { Labels } from '../../../../../../types/unified-alerting-dto';
|
|||||||
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
|
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
|
||||||
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
||||||
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
|
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
|
||||||
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||||
import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies';
|
import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies';
|
||||||
|
|
||||||
import { getRoutesByIdMap, RouteWithPath } from './route';
|
import { getRoutesByIdMap, RouteWithPath } from './route';
|
||||||
@ -55,7 +56,9 @@ export const useAlertmanagerNotificationRoutingPreview = (
|
|||||||
if (!rootRoute) {
|
if (!rootRoute) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return await matchInstancesToRoute(rootRoute, potentialInstances);
|
return await matchInstancesToRoute(rootRoute, potentialInstances, {
|
||||||
|
unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME,
|
||||||
|
});
|
||||||
}, [rootRoute, potentialInstances]);
|
}, [rootRoute, potentialInstances]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -6,11 +6,20 @@ import {
|
|||||||
findMatchingAlertGroups,
|
findMatchingAlertGroups,
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
normalizeRoute,
|
normalizeRoute,
|
||||||
|
unquoteRouteMatchers,
|
||||||
} from './utils/notification-policies';
|
} from './utils/notification-policies';
|
||||||
|
|
||||||
|
export interface MatchOptions {
|
||||||
|
unquoteMatchers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const routeGroupsMatcher = {
|
export const routeGroupsMatcher = {
|
||||||
getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map<string, AlertmanagerGroup[]> {
|
getRouteGroupsMap(
|
||||||
const normalizedRootRoute = normalizeRoute(rootRoute);
|
rootRoute: RouteWithID,
|
||||||
|
groups: AlertmanagerGroup[],
|
||||||
|
options?: MatchOptions
|
||||||
|
): Map<string, AlertmanagerGroup[]> {
|
||||||
|
const normalizedRootRoute = getNormalizedRoute(rootRoute, options);
|
||||||
|
|
||||||
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) {
|
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) {
|
||||||
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups);
|
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups);
|
||||||
@ -25,10 +34,14 @@ export const routeGroupsMatcher = {
|
|||||||
return routeGroupsMap;
|
return routeGroupsMap;
|
||||||
},
|
},
|
||||||
|
|
||||||
matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> {
|
matchInstancesToRoute(
|
||||||
|
routeTree: RouteWithID,
|
||||||
|
instancesToMatch: Labels[],
|
||||||
|
options?: MatchOptions
|
||||||
|
): Map<string, AlertInstanceMatch[]> {
|
||||||
const result = new Map<string, AlertInstanceMatch[]>();
|
const result = new Map<string, AlertInstanceMatch[]>();
|
||||||
|
|
||||||
const normalizedRootRoute = normalizeRoute(routeTree);
|
const normalizedRootRoute = getNormalizedRoute(routeTree, options);
|
||||||
|
|
||||||
instancesToMatch.forEach((instance) => {
|
instancesToMatch.forEach((instance) => {
|
||||||
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(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;
|
export type RouteGroupsMatcher = typeof routeGroupsMatcher;
|
||||||
|
@ -6,7 +6,7 @@ import { Labels } from '../../../types/unified-alerting-dto';
|
|||||||
|
|
||||||
import { logError, logInfo } from './Analytics';
|
import { logError, logInfo } from './Analytics';
|
||||||
import { createWorker } from './createRouteGroupsMatcherWorker';
|
import { createWorker } from './createRouteGroupsMatcherWorker';
|
||||||
import type { RouteGroupsMatcher } from './routeGroupsMatcher';
|
import type { MatchOptions, RouteGroupsMatcher } from './routeGroupsMatcher';
|
||||||
|
|
||||||
let routeMatcher: comlink.Remote<RouteGroupsMatcher> | undefined;
|
let routeMatcher: comlink.Remote<RouteGroupsMatcher> | undefined;
|
||||||
|
|
||||||
@ -55,43 +55,49 @@ export function useRouteGroupsMatcher() {
|
|||||||
return () => null;
|
return () => null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => {
|
const getRouteGroupsMap = useCallback(
|
||||||
validateWorker(routeMatcher);
|
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`, {
|
logInfo(`Route Groups Matched in ${timeSpent} ms`, {
|
||||||
matchingTime: timeSpent.toString(),
|
matchingTime: timeSpent.toString(),
|
||||||
alertGroupsCount: alertGroups.length.toString(),
|
alertGroupsCount: alertGroups.length.toString(),
|
||||||
// Counting all nested routes might be too time-consuming, so we only count the first level
|
// Counting all nested routes might be too time-consuming, so we only count the first level
|
||||||
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
|
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
|
const matchInstancesToRoute = useCallback(
|
||||||
validateWorker(routeMatcher);
|
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`, {
|
logInfo(`Instances Matched in ${timeSpent} ms`, {
|
||||||
matchingTime: timeSpent.toString(),
|
matchingTime: timeSpent.toString(),
|
||||||
instancesToMatchCount: instancesToMatch.length.toString(),
|
instancesToMatchCount: instancesToMatch.length.toString(),
|
||||||
// Counting all nested routes might be too time-consuming, so we only count the first level
|
// Counting all nested routes might be too time-consuming, so we only count the first level
|
||||||
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
|
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return { getRouteGroupsMap, matchInstancesToRoute };
|
return { getRouteGroupsMap, matchInstancesToRoute };
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,8 @@ import { Labels } from 'app/types/unified-alerting-dto';
|
|||||||
import { MatcherFieldValue } from '../types/silence-form';
|
import { MatcherFieldValue } from '../types/silence-form';
|
||||||
|
|
||||||
import { getAllDataSources } from './config';
|
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 {
|
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||||
// add default receiver if it does not exist
|
// 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 {
|
export function matcherToOperator(matcher: Matcher): MatcherOperator {
|
||||||
if (matcher.isEqual) {
|
if (matcher.isEqual) {
|
||||||
if (matcher.isRegex) {
|
if (matcher.isRegex) {
|
||||||
@ -177,6 +182,10 @@ export function combineMatcherStrings(...matcherStrings: string[]): string {
|
|||||||
return matchersToString(uniqueMatchers);
|
return matchersToString(uniqueMatchers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAmMatcherFormatter(alertmanagerSourceName?: string): MatcherFormatter {
|
||||||
|
return alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME ? 'default' : 'unquote';
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllAlertmanagerDataSources() {
|
export function getAllAlertmanagerDataSources() {
|
||||||
return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager);
|
return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager);
|
||||||
}
|
}
|
||||||
|
@ -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 { FormAmRoute } from '../types/amroutes';
|
||||||
|
|
||||||
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
|
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
|
||||||
|
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
|
|
||||||
const emptyAmRoute: Route = {
|
const emptyAmRoute: Route = {
|
||||||
receiver: '',
|
receiver: '',
|
||||||
@ -53,6 +54,58 @@ describe('formAmRouteToAmRoute', () => {
|
|||||||
expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']);
|
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', () => {
|
describe('amRouteToFormAmRoute', () => {
|
||||||
@ -101,4 +154,23 @@ describe('amRouteToFormAmRoute', () => {
|
|||||||
expect(formRoute.overrideGrouping).toBe(true);
|
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"\\' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form';
|
|||||||
|
|
||||||
import { matcherToMatcherField } from './alertmanager';
|
import { matcherToMatcherField } from './alertmanager';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
import { normalizeMatchers, parseMatcher } from './matchers';
|
import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers';
|
||||||
import { findExistingRoute } from './routeTree';
|
import { findExistingRoute } from './routeTree';
|
||||||
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
|
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
|
||||||
|
|
||||||
@ -94,7 +94,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
|
|||||||
|
|
||||||
const objectMatchers =
|
const objectMatchers =
|
||||||
route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? [];
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -149,8 +156,10 @@ export const formAmRouteToAmRoute = (
|
|||||||
|
|
||||||
const overrideRepeatInterval = overrideTimings && repeatIntervalValue;
|
const overrideRepeatInterval = overrideTimings && repeatIntervalValue;
|
||||||
const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT;
|
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
|
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]);
|
.map(({ name, operator, value }) => [name, operator, value]);
|
||||||
|
|
||||||
const routes = formAmRoute.routes?.map((subRoute) =>
|
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
|
// 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
|
// does not exist in upstream AlertManager
|
||||||
if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) {
|
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;
|
amRoute.object_matchers = undefined;
|
||||||
} else {
|
} else {
|
||||||
amRoute.object_matchers = normalizeMatchers(amRoute);
|
amRoute.object_matchers = normalizeMatchers(amRoute);
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types';
|
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('Unified Alerting matchers', () => {
|
||||||
describe('getMatcherQueryParams tests', () => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -108,4 +108,41 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
|
|||||||
return matchers;
|
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];
|
export type Label = [string, string];
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { Labels } from 'app/types/unified-alerting-dto';
|
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
|
// 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
|
// So we cannot use null as an indicator of no match
|
||||||
@ -124,6 +124,20 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
|
|||||||
return normalizedRootRoute;
|
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
|
* 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
|
* (and their grouping) for the given route
|
||||||
|
Loading…
Reference in New Issue
Block a user