Add list of labels in the policy route path that produces the policy matchers to match potential instances

This commit is contained in:
Sonia Aguilar 2023-06-14 13:32:51 +02:00
parent 8c539da81b
commit ee73ae9cf9
9 changed files with 181 additions and 57 deletions

View File

@ -12,7 +12,7 @@ import { HoverCard } from '../HoverCard';
type MatchersProps = { matchers: ObjectMatcher[] };
// renders the first N number of matchers
const Matchers: FC<MatchersProps> = ({ matchers }) => {
const Matchers: FC<MatchersProps> = React.forwardRef<HTMLInputElement, MatchersProps>(({ matchers }, ref) => {
const styles = useStyles2(getStyles);
const NUM_MATCHERS = 5;
@ -22,7 +22,7 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
const hasMoreMatchers = rest.length > 0;
return (
<span data-testid="label-matchers">
<span data-testid="label-matchers" ref={ref}>
<Stack direction="row" gap={1} alignItems="center">
{firstFew.map((matcher) => (
<MatcherBadge key={uniqueId()} matcher={matcher} />
@ -48,7 +48,9 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
</Stack>
</span>
);
};
});
Matchers.displayName = 'Matchers';
interface MatcherBadgeProps {
matcher: ObjectMatcher;

View File

@ -22,10 +22,8 @@ function NotificationPreviewByAlertManager({
}) {
const styles = useStyles2(getStyles);
const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview(
alertManagerSource.name,
potentialInstances
);
const { routesByIdMap, receiversByName, matchingMap, matchingMapPath, loading, error } =
useAlertmanagerNotificationRoutingPreview(alertManagerSource.name, potentialInstances);
if (error) {
return (
@ -74,6 +72,8 @@ function NotificationPreviewByAlertManager({
key={routeId}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSource.name}
matchingMap={matchingMap}
matchingMapPath={matchingMapPath}
/>
);
})}

View File

@ -25,6 +25,8 @@ function NotificationRouteHeader({
alertManagerSourceName,
expandRoute,
onExpandRouteClick,
matchingMap,
matchingMapPath,
}: {
route: RouteWithPath;
receiver: Receiver;
@ -33,6 +35,8 @@ function NotificationRouteHeader({
alertManagerSourceName: string;
expandRoute: boolean;
onExpandRouteClick: (expand: boolean) => void;
matchingMap: Map<string, AlertInstanceMatch[]>;
matchingMapPath: Map<string, AlertInstanceMatch[]>;
}) {
const styles = useStyles2(getStyles);
const [showDetails, setShowDetails] = useState(false);
@ -82,6 +86,8 @@ function NotificationRouteHeader({
receiver={receiver}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSourceName}
matchingMap={matchingMap}
matchingMapPath={matchingMapPath}
/>
)}
</div>
@ -94,6 +100,8 @@ interface NotificationRouteProps {
instanceMatches: AlertInstanceMatch[];
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
matchingMap: Map<string, AlertInstanceMatch[]>;
matchingMapPath: Map<string, AlertInstanceMatch[]>;
}
export function NotificationRoute({
@ -102,26 +110,35 @@ export function NotificationRoute({
receiver,
routesByIdMap,
alertManagerSourceName,
matchingMap,
matchingMapPath,
}: NotificationRouteProps) {
const styles = useStyles2(getStyles);
const [expandRoute, setExpandRoute] = useToggle(false);
const GREY_COLOR_INDEX = 9;
const instanceMatchesUnique = [
...new Map(
instanceMatches.map((matchInstance) => [JSON.stringify(matchInstance.instance), matchInstance])
).values(),
];
return (
<div data-testid="matching-policy-route">
<NotificationRouteHeader
route={route}
receiver={receiver}
routesByIdMap={routesByIdMap}
instancesCount={instanceMatches.length}
instancesCount={instanceMatchesUnique.length}
alertManagerSourceName={alertManagerSourceName}
expandRoute={expandRoute}
onExpandRouteClick={setExpandRoute}
matchingMap={matchingMap}
matchingMapPath={matchingMapPath}
/>
{expandRoute && (
<Stack gap={1} direction="column">
<div className={styles.routeInstances} data-testid="route-matching-instance">
{instanceMatches.map((instanceMatch) => {
{instanceMatchesUnique.map((instanceMatch) => {
const matchArray = Array.from(instanceMatch.labelsMatch.entries());
let matchResult = matchArray.map(([label, matchResult]) => ({
label: `${label[0]}=${label[1]}`,

View File

@ -3,19 +3,56 @@ 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, TagList, Tooltip, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { getNotificationsPermissions } from '../../../utils/access-control';
import { Label } from '../../../utils/matchers';
import { makeAMLink } from '../../../utils/misc';
import { AlertInstanceMatch, LabelMatchResult } from '../../../utils/notification-policies';
import { Authorize } from '../../Authorize';
import { Matchers } from '../../notification-policies/Matchers';
import { NotificationPolicyMatchers } from './NotificationPolicyMatchers';
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, RouteWithPath>; route: RouteWithPath }) {
export const LabelsMatching = ({
routeId,
matchingMapPath,
}: {
routeId: string;
matchingMap: Map<string, AlertInstanceMatch[]>;
matchingMapPath: Map<string, AlertInstanceMatch[]>;
}) => {
const matching = matchingMapPath.get(routeId);
if (!matching) {
return null;
}
const matchingInstances: AlertInstanceMatch[] | undefined = matchingMapPath.get(routeId);
// get array of labelsMatch from mapchingInstances
const valuesIterator = matchingInstances?.map((instance) => instance.labelsMatch)?.values() ?? [];
const labelMaps: Array<Map<Label, LabelMatchResult>> = Array.from(valuesIterator);
const labels = labelMaps
.map((m) => Array.from(m.entries() ?? []).filter(([label, result]) => result.match))
.flat()
.map(([label, _]) => label);
// get array of strings from labels, remove duplicated
const labelsStringArray = Array.from(new Set(labels.map((label: Label) => label[0] + '=' + label[1])));
return <TagList tags={labelsStringArray} />;
};
function PolicyPath({
route,
routesByIdMap,
matchingMap,
matchingMapPath,
}: {
routesByIdMap: Map<string, RouteWithPath>;
route: RouteWithPath;
matchingMapPath: Map<string, AlertInstanceMatch[]>;
matchingMap: Map<string, AlertInstanceMatch[]>;
}) {
const styles = useStyles2(getStyles);
const routePathIds = route.path?.slice(1) ?? [];
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
@ -30,7 +67,18 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, Route
{hasEmptyMatchers(pathRoute) ? (
<div className={styles.textMuted}>No matchers</div>
) : (
<Matchers matchers={pathRoute.object_matchers ?? []} />
<Tooltip
placement="bottom"
content={
<LabelsMatching
routeId={pathRoute.id}
matchingMap={matchingMap}
matchingMapPath={matchingMapPath}
/>
}
>
<Matchers matchers={pathRoute.object_matchers ?? []} />
</Tooltip>
)}
</div>
</div>
@ -46,6 +94,8 @@ interface NotificationRouteDetailsModalProps {
receiver: Receiver;
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
matchingMap: Map<string, AlertInstanceMatch[]>;
matchingMapPath: Map<string, AlertInstanceMatch[]>;
}
export function NotificationRouteDetailsModal({
@ -54,6 +104,8 @@ export function NotificationRouteDetailsModal({
receiver,
routesByIdMap,
alertManagerSourceName,
matchingMap,
matchingMapPath,
}: NotificationRouteDetailsModalProps) {
const styles = useStyles2(getStyles);
const isDefault = isDefaultPolicy(route);
@ -84,7 +136,12 @@ export function NotificationRouteDetailsModal({
{!isDefault && (
<>
<div className={cx(styles.textMuted, styles.marginBottom(1))}>Notification policy path</div>
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
<PolicyPath
route={route}
routesByIdMap={routesByIdMap}
matchingMap={matchingMap}
matchingMapPath={matchingMapPath}
/>
</>
)}
<div className={styles.separator(4)} />

View File

@ -49,7 +49,10 @@ export const useAlertmanagerNotificationRoutingPreview = (
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
const {
value: matchingMap = new Map<string, AlertInstanceMatch[]>(),
value: matchingMap = {
result: new Map<string, AlertInstanceMatch[]>(),
resultPath: new Map<string, AlertInstanceMatch[]>(),
},
loading: matchingLoading,
error: matchingError,
} = useAsync(async () => {
@ -62,7 +65,8 @@ export const useAlertmanagerNotificationRoutingPreview = (
return {
routesByIdMap,
receiversByName,
matchingMap: matchingMap,
matchingMap: matchingMap.result,
matchingMapPath: matchingMap.resultPath,
loading: configLoading || matchingLoading,
error: configError ?? matchingError,
};

View File

@ -25,15 +25,23 @@ export const routeGroupsMatcher = {
return routeGroupsMap;
},
matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> {
matchInstancesToRoute(
routeTree: RouteWithID,
instancesToMatch: Labels[]
): { result: Map<string, AlertInstanceMatch[]>; resultPath: Map<string, AlertInstanceMatch[]> } {
const result = new Map<string, AlertInstanceMatch[]>();
const resultPath = new Map<string, AlertInstanceMatch[]>();
const normalizedRootRoute = normalizeRoute(routeTree);
// find matching routes for each instance and add them to the results map and the path map
instancesToMatch.forEach((instance) => {
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
const { matchesResult: matchingRoutes, matchesPath } = findMatchingRoutes(
normalizedRootRoute,
Object.entries(instance)
);
// Only to convert Label[] to Labels[] - needs better approach
matchingRoutes.forEach(({ route, details, labelsMatch }) => {
// Only to convert Label[] to Labels[] - needs better approach
const matchDetails = new Map(
Array.from(details.entries()).map(([matcher, labels]) => [matcher, Object.fromEntries(labels)])
);
@ -45,9 +53,21 @@ export const routeGroupsMatcher = {
result.set(route.id, [{ instance, matchDetails, labelsMatch }]);
}
});
matchesPath.forEach(({ route: routeInPath, details, labelsMatch }) => {
const matchDetailsPath = new Map(
Array.from(details.entries()).map(([matcher, labels]) => [matcher, Object.fromEntries(labels)])
);
const currentRouteInpath = resultPath.get(routeInPath.id);
if (currentRouteInpath) {
currentRouteInpath.push({ instance, matchDetails: matchDetailsPath, labelsMatch });
} else {
resultPath.set(routeInPath.id, [{ instance, matchDetails: matchDetailsPath, labelsMatch }]);
}
});
});
return result;
return { result: result, resultPath: resultPath };
},
};

View File

@ -98,7 +98,7 @@ export function useRouteGroupsMatcher() {
const startTime = performance.now();
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
const { result, resultPath } = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
const timeSpent = performance.now() - startTime;
@ -108,8 +108,7 @@ export function useRouteGroupsMatcher() {
// 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, resultPath };
},
[workerPreviewEnabled]
);

View File

@ -1,6 +1,6 @@
import { MatcherOperator, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { findMatchingRoutes, normalizeRoute, getInheritedProperties } from './notification-policies';
import { findMatchingRoutes, getInheritedProperties, normalizeRoute } from './notification-policies';
import 'core-js/stable/structured-clone';
@ -39,19 +39,19 @@ describe('findMatchingRoutes', () => {
};
it('should match root route with no matching labels', () => {
const matches = findMatchingRoutes(policies, []);
const { matchesResult: matches } = findMatchingRoutes(policies, []);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'ROOT');
});
it('should match parent route with no matching children', () => {
const matches = findMatchingRoutes(policies, [['team', 'operations']]);
const { matchesResult: matches } = findMatchingRoutes(policies, [['team', 'operations']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'A');
});
it('should match child route of matching parent', () => {
const matches = findMatchingRoutes(policies, [
const { matchesResult: matches } = findMatchingRoutes(policies, [
['team', 'operations'],
['region', 'europe'],
]);
@ -60,7 +60,7 @@ describe('findMatchingRoutes', () => {
});
it('should match simple policy', () => {
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
const { matchesResult: matches } = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'C');
});
@ -71,7 +71,7 @@ describe('findMatchingRoutes', () => {
routes: [CATCH_ALL_ROUTE, ...(policies.routes ?? [])],
};
const matches = findMatchingRoutes(policiesWithAll, []);
const { matchesResult: matches } = findMatchingRoutes(policiesWithAll, []);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
});
@ -88,7 +88,7 @@ describe('findMatchingRoutes', () => {
],
};
const matches = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]);
const { matchesResult: matches } = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]);
expect(matches).toHaveLength(2);
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
expect(matches[1].route).toHaveProperty('receiver', 'C');
@ -115,7 +115,7 @@ describe('findMatchingRoutes', () => {
group_interval: '1m',
};
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
const { matchesResult: matches } = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'PARENT');
});

View File

@ -37,7 +37,7 @@ function isLabelMatch(matcher: ObjectMatcher, label: Label) {
return matchFunction(labelValue, matcherValue);
}
interface LabelMatchResult {
export interface LabelMatchResult {
match: boolean;
matchers: ObjectMatcher[];
}
@ -99,36 +99,60 @@ export interface RouteMatchResult<T extends Route> {
// If the current node is not a match, return nothing
// const normalizedMatchers = normalizeMatchers(root);
// Normalization should have happened earlier in the code
function findMatchingRoutes<T extends Route>(root: T, labels: Label[]): Array<RouteMatchResult<T>> {
let matches: Array<RouteMatchResult<T>> = [];
function findMatchingRoutes<T extends Route>(
mainRoot: T,
labels: Label[]
): { matchesResult: Array<RouteMatchResult<T>>; matchesPath: Array<RouteMatchResult<T>> } {
// ----------- recursive function to find matching routes
function findMatchingRoutesRecursive<T extends Route>(
root: T | undefined,
labels: Label[],
matchesPathAcum: Array<RouteMatchResult<T>>
): { matchesResult: Array<RouteMatchResult<T>>; matchesPath: Array<RouteMatchResult<T>> } {
let matches: Array<RouteMatchResult<T>> = [];
if (!root) {
return { matchesResult: [], matchesPath: matchesPathAcum };
}
// If the current node is not a match, return nothing
const matchResult: MatchingResult = matchLabels(root.object_matchers ?? [], labels);
if (!matchResult.matches) {
return { matchesResult: [], matchesPath: matchesPathAcum };
}
// If the current node matches, add current match to the path results and continue with the children
matchesPathAcum.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch });
if (root.routes) {
for (let index = 0; index < root.routes.length; index++) {
let child = root.routes?.[index];
let { matchesResult: matchingChildren, matchesPath: matchesPathInChild } = findMatchingRoutesRecursive(
child,
labels,
matchesPathAcum
);
// TODO how do I solve this typescript thingy? It looks correct to me /shrug
// @ts-ignore
matches = matches.concat(matchingChildren);
// @ts-ignore
matchingChildren.length && matchesPathAcum.concat(matchesPathInChild);
// If the current node is not a match, return nothing
const matchResult = matchLabels(root.object_matchers ?? [], labels);
if (!matchResult.matches) {
return [];
}
// If the current node matches, recurse through child nodes
if (root.routes) {
for (let index = 0; index < root.routes.length; index++) {
let child = root.routes[index];
let matchingChildren = findMatchingRoutes(child, labels);
// TODO how do I solve this typescript thingy? It looks correct to me /shrug
// @ts-ignore
matches = matches.concat(matchingChildren);
// we have matching children and we don't want to continue, so break here
if (matchingChildren.length && !child.continue) {
break;
// we have matching children and we don't want to continue, so break here
if (matchingChildren.length && !child?.continue) {
break;
}
}
}
}
// If no child nodes were matches, the current node itself is a match.
if (matches.length === 0) {
matches.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch });
}
// If no child nodes were matches, the current node itself is a match.
if (matches.length === 0) {
matches.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch });
}
return matches;
const matchesResultUnique = [
...new Map(matches.map((matchInstance) => [JSON.stringify(matchInstance), matchInstance])).values(),
];
return { matchesResult: matchesResultUnique, matchesPath: matchesPathAcum };
}
// ------------ call to the recursive function
return findMatchingRoutesRecursive(mainRoot, labels, []);
}
// This is a performance improvement to normalize matchers only once and use the normalized version later on
@ -162,7 +186,8 @@ function findMatchingAlertGroups(
// find matching alerts in the current group
const matchingAlerts = group.alerts.filter((alert) => {
const labels = Object.entries(alert.labels);
return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute.route === route);
const { matchesResult } = findMatchingRoutes(routeTree, labels);
return matchesResult.some((matchingRoute) => matchingRoute.route === route);
});
// if the groups has any alerts left after matching, add it to the results