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 [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
|
||||
|
||||
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(() => {
|
||||
|
@ -134,7 +134,7 @@ export const AmRoutesExpandedForm = ({
|
||||
error={errors.object_matchers?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`object_matchers.${index}.value`, { required: 'Field is required' })}
|
||||
{...register(`object_matchers.${index}.value`)}
|
||||
defaultValue={field.value}
|
||||
placeholder="value"
|
||||
/>
|
||||
|
@ -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<MatchersProps> = ({ matchers }) => {
|
||||
const Matchers: FC<MatchersProps> = ({ matchers, formatter = 'default' }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const NUM_MATCHERS = 5;
|
||||
@ -24,7 +25,7 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
|
||||
<span data-testid="label-matchers">
|
||||
<Stack direction="row" gap={1} alignItems="center" wrap={'wrap'}>
|
||||
{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 */}
|
||||
{hasMoreMatchers && (
|
||||
@ -51,15 +52,16 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
|
||||
|
||||
interface MatcherBadgeProps {
|
||||
matcher: ObjectMatcher;
|
||||
formatter?: MatcherFormatter;
|
||||
}
|
||||
|
||||
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher: [label, operator, value] }) => {
|
||||
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.matcher(label).wrapper}>
|
||||
<div className={styles.matcher(matcher[0]).wrapper}>
|
||||
<Stack direction="row" gap={0} alignItems="baseline">
|
||||
{label} {operator} {value}
|
||||
{matcherFormatter[formatter](matcher)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
@ -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<AlertmanagerGroup[]>([]);
|
||||
const [matchers, setMatchers] = useState<ObjectMatcher[]>([]);
|
||||
const [formatter, setFormatter] = useState<MatcherFormatter>('default');
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowModal(false);
|
||||
@ -217,13 +219,19 @@ const useAlertGroupsModal = (): [
|
||||
setMatchers([]);
|
||||
}, []);
|
||||
|
||||
const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => {
|
||||
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 = (): [
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Icon name="x" /> Matchers
|
||||
</Stack>
|
||||
<Matchers matchers={matchers} />
|
||||
<Matchers matchers={matchers} formatter={formatter} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
@ -265,7 +273,7 @@ const useAlertGroupsModal = (): [
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
),
|
||||
[alertGroups, handleDismiss, instancesByState, matchers, showModal]
|
||||
[alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal]
|
||||
);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss];
|
||||
|
@ -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<InheritableProperties>;
|
||||
routesMatchingFilters?: RouteWithID[];
|
||||
// routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
|
||||
|
||||
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; 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) => {
|
||||
<DefaultPolicyIndicator />
|
||||
)
|
||||
) : hasMatchers ? (
|
||||
<Matchers matchers={matchers ?? []} />
|
||||
<Matchers matchers={matchers ?? []} formatter={getAmMatcherFormatter(alertManagerSourceName)} />
|
||||
) : (
|
||||
<span className={styles.metadata}>No matchers</span>
|
||||
)}
|
||||
@ -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({
|
||||
<MetaText
|
||||
icon="layers-alt"
|
||||
onClick={() => {
|
||||
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
|
||||
matchingAlertGroups &&
|
||||
onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName));
|
||||
}}
|
||||
data-testid="matching-instances"
|
||||
>
|
||||
|
@ -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 <div className={styles.defaultPolicy}>Default policy</div>;
|
||||
} else if (hasEmptyMatchers(route)) {
|
||||
return <div className={styles.textMuted}>No matchers</div>;
|
||||
} 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 { 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({
|
||||
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
|
||||
<Stack gap={1} direction="row" alignItems="center">
|
||||
Notification policy
|
||||
<NotificationPolicyMatchers route={route} />
|
||||
<NotificationPolicyMatchers
|
||||
route={route}
|
||||
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<Spacer />
|
||||
|
@ -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<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 routePathIds = route.path?.slice(1) ?? [];
|
||||
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
|
||||
@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, Route
|
||||
{hasEmptyMatchers(pathRoute) ? (
|
||||
<div className={styles.textMuted}>No matchers</div>
|
||||
) : (
|
||||
<Matchers matchers={pathRoute.object_matchers ?? []} />
|
||||
<Matchers matchers={pathRoute.object_matchers ?? []} formatter={matcherFormatter} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({
|
||||
const isDefault = isDefaultPolicy(route);
|
||||
|
||||
return (
|
||||
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={GRAFANA_DATASOURCE_NAME}>
|
||||
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}>
|
||||
<Modal
|
||||
className={styles.detailsModal}
|
||||
isOpen={true}
|
||||
@ -77,7 +83,11 @@ export function NotificationRouteDetailsModal({
|
||||
<div className={styles.separator(1)} />
|
||||
{!isDefault && (
|
||||
<>
|
||||
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
|
||||
<PolicyPath
|
||||
route={route}
|
||||
routesByIdMap={routesByIdMap}
|
||||
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.separator(4)} />
|
||||
|
@ -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 {
|
||||
|
@ -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<string, AlertmanagerGroup[]> {
|
||||
const normalizedRootRoute = normalizeRoute(rootRoute);
|
||||
getRouteGroupsMap(
|
||||
rootRoute: RouteWithID,
|
||||
groups: AlertmanagerGroup[],
|
||||
options?: MatchOptions
|
||||
): Map<string, AlertmanagerGroup[]> {
|
||||
const normalizedRootRoute = getNormalizedRoute(rootRoute, options);
|
||||
|
||||
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) {
|
||||
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups);
|
||||
@ -25,10 +34,14 @@ export const routeGroupsMatcher = {
|
||||
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 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;
|
||||
|
@ -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<RouteGroupsMatcher> | undefined;
|
||||
|
||||
@ -55,12 +55,13 @@ export function useRouteGroupsMatcher() {
|
||||
return () => null;
|
||||
}, []);
|
||||
|
||||
const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => {
|
||||
const getRouteGroupsMap = useCallback(
|
||||
async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[], options?: MatchOptions) => {
|
||||
validateWorker(routeMatcher);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups);
|
||||
const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups, options);
|
||||
|
||||
const timeSpent = performance.now() - startTime;
|
||||
|
||||
@ -72,14 +73,17 @@ export function useRouteGroupsMatcher() {
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
|
||||
const matchInstancesToRoute = useCallback(
|
||||
async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => {
|
||||
validateWorker(routeMatcher);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
|
||||
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options);
|
||||
|
||||
const timeSpent = performance.now() - startTime;
|
||||
|
||||
@ -91,7 +95,9 @@ export function useRouteGroupsMatcher() {
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { getRouteGroupsMap, matchInstancesToRoute };
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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"\\' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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];
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user