Alerting: Add route matching functionality to package (#108982)

This commit is contained in:
Gilles De Mey
2025-08-29 14:52:27 +00:00
committed by GitHub
parent 48ad2fe46b
commit a71664c114
19 changed files with 2001 additions and 1 deletions
+3
View File
@@ -25,6 +25,9 @@ exports[`better eslint`] = {
"e2e/utils/support/types.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-data/src/dataframe/ArrayDataFrame.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
+1
View File
@@ -80,6 +80,7 @@
"typescript": "5.9.2"
},
"peerDependencies": {
"@grafana/data": ">=11.6 <= 12.x",
"@grafana/runtime": ">=11.6 <= 12.x",
"@grafana/ui": ">=11.6 <= 12.x",
"@reduxjs/toolkit": "^2.8.0",
@@ -22,5 +22,6 @@ export default [
input: 'src/testing.ts',
plugins,
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')],
treeshake: false,
},
];
@@ -0,0 +1,28 @@
import { faker } from '@faker-js/faker';
import { Factory } from 'fishery';
import { LabelMatcher } from '../../../../matchers/types';
import { Route } from '../../../../notificationPolicies/types';
export const LabelMatcherFactory = Factory.define<LabelMatcher>(() => {
const operators: Array<LabelMatcher['type']> = ['=', '!=', '=~', '!~'];
return {
label: faker.helpers.arrayElement(['service', 'env', 'team', 'severity', 'region', 'instance']),
type: faker.helpers.arrayElement(operators),
value: faker.helpers.arrayElement(['web', 'api', 'prod', 'staging', 'critical', 'warning', 'us-east', 'us-west']),
};
});
export const RouteFactory = Factory.define<Route>(() => ({
continue: faker.datatype.boolean(),
receiver: faker.helpers.arrayElement(['web-team', 'api-team', 'critical-alerts', 'dev-team']),
matchers: LabelMatcherFactory.buildList(faker.number.int({ min: 1, max: 3 })),
group_by: faker.helpers.arrayElements(['alertname', 'service', 'severity'], { min: 1, max: 2 }),
group_wait: faker.helpers.arrayElement(['10s', '30s', '1m']),
group_interval: faker.helpers.arrayElement(['5m', '10m', '15m']),
repeat_interval: faker.helpers.arrayElement(['1h', '4h', '12h']),
active_time_intervals: faker.helpers.arrayElements(['business-hours', 'weekends', 'maintenance'], { min: 1, max: 2 }),
mute_time_intervals: faker.helpers.arrayElements(['lunch-break', 'night-hours'], { min: 1, max: 2 }),
routes: [],
}));
@@ -1,5 +1,6 @@
import { countBy, isEmpty } from 'lodash';
import { Receiver } from '../api/v0alpha1/api.gen';
import { ContactPoint } from '../api/v0alpha1/types';
/**
@@ -12,7 +13,7 @@ import { ContactPoint } from '../api/v0alpha1/types';
* @param contactPoint - The ContactPoint object to describe
* @returns A string description of the ContactPoint's integrations
*/
export function getContactPointDescription(contactPoint: ContactPoint): string {
export function getContactPointDescription(contactPoint: ContactPoint | Receiver): string {
if (isEmpty(contactPoint.spec.integrations)) {
return '<empty contact point>';
}
@@ -0,0 +1,13 @@
import { OverrideProperties } from 'type-fest';
import { RoutingTreeMatcher } from '../api/v0alpha1/api.gen';
export type Label = [string, string];
// type-narrow the matchers the specify exact allowed set of operators
export type LabelMatcher = OverrideProperties<
RoutingTreeMatcher,
{
type: '=' | '!=' | '=~' | '!~';
}
>;
@@ -0,0 +1,93 @@
import { LabelMatcher } from './types';
import { isLabelMatch, matchLabelsSet } from './utils';
describe('isLabelMatch', () => {
it('should match on a set of labels with "=" operator', () => {
const matcher: LabelMatcher = { label: 'foo', type: '=', value: 'bar' };
const label1: [string, string] = ['foo', 'bar'];
const label2: [string, string] = ['foo', 'baz'];
expect(isLabelMatch(matcher, label1)).toBe(true);
expect(isLabelMatch(matcher, label2)).toBe(false);
});
it('should match on a set of labels with "!=" operator', () => {
const matcher: LabelMatcher = { label: 'foo', type: '!=', value: 'bar' };
const label1: [string, string] = ['foo', 'baz'];
const label2: [string, string] = ['foo', 'bar'];
expect(isLabelMatch(matcher, label1)).toBe(true);
expect(isLabelMatch(matcher, label2)).toBe(false);
});
it('should match on a set of labels with "=~" operator', () => {
const matcher: LabelMatcher = { label: 'foo', type: '=~', value: 'ba.' };
const label1: [string, string] = ['foo', 'baz'];
const label2: [string, string] = ['foo', 'bar'];
const label3: [string, string] = ['foo', 'foo'];
expect(isLabelMatch(matcher, label1)).toBe(true);
expect(isLabelMatch(matcher, label2)).toBe(true);
expect(isLabelMatch(matcher, label3)).toBe(false);
});
it('should match on a set of labels with "!~" operator', () => {
const matcher: LabelMatcher = { label: 'foo', type: '!~', value: 'ba.' };
const label1: [string, string] = ['foo', 'baz'];
const label2: [string, string] = ['foo', 'bar'];
const label3: [string, string] = ['foo', 'foo'];
expect(isLabelMatch(matcher, label1)).toBe(false);
expect(isLabelMatch(matcher, label2)).toBe(false);
expect(isLabelMatch(matcher, label3)).toBe(true);
});
});
describe('matchLabelsSet', () => {
it('should match if all matchers are truthy', () => {
const matchers: LabelMatcher[] = [
{ label: 'foo', type: '=', value: 'bar' },
{ label: 'baz', type: '!=', value: 'qux' },
];
const labels: Array<[string, string]> = [
['foo', 'bar'],
['baz', 'quux'],
];
expect(matchLabelsSet(matchers, labels)).toBe(true);
});
it('should not match if a single matcher is falsy', () => {
const matchers: LabelMatcher[] = [
{ label: 'foo', type: '=', value: 'bar' },
{ label: 'baz', type: '!=', value: 'qux' },
];
const labels: Array<[string, string]> = [
['foo', 'baz'],
['baz', 'quux'],
];
expect(matchLabelsSet(matchers, labels)).toBe(false);
});
it('should handle empty value matchers (this means the label should not appear in the set)', () => {
const matchers: LabelMatcher[] = [
{ label: 'foo', type: '=', value: '' },
{ label: 'bar', type: '=', value: 'baz' },
];
const labels: Array<[string, string]> = [['bar', 'baz']];
expect(matchLabelsSet(matchers, labels)).toBe(true);
});
it('should not throw for invalid regex input', () => {
const matchers: LabelMatcher[] = [{ label: 'foo', type: '=~', value: '(' }];
const labels: Array<[string, string]> = [['foo', 'bar']];
expect(() => {
matchLabelsSet(matchers, labels);
}).not.toThrow();
expect(matchLabelsSet(matchers, labels)).toBe(false);
});
});
@@ -0,0 +1,117 @@
import { parseFlags } from '@grafana/data';
import { Label, LabelMatcher } from './types';
type LabelMatchingResult = {
// wether all of the labels match the given set of matchers
matches: boolean;
// details of which labels matched which matcher
details: LabelMatchDetails[];
};
// LabelMatchDetails is a map of labels to their match results
export type LabelMatchDetails = {
labelIndex: number; // index of the label in the labels array
match: boolean;
matcher: LabelMatcher | null;
} & (PositiveLabelMatch | NegativeLabelMatch);
type PositiveLabelMatch = {
match: true;
matcher: LabelMatcher;
};
type NegativeLabelMatch = {
match: false;
matcher: null;
};
// returns a match results for given set of matchers (from a policy for instance) and a set of labels
export function matchLabels(matchers: LabelMatcher[], labels: Label[]): LabelMatchingResult {
const matches = matchLabelsSet(matchers, labels);
// create initial map of label => match result
const details = labels.map<LabelMatchDetails>((_label, index) => ({
labelIndex: index,
match: false,
matcher: null,
}));
// for each matcher, check which label it matched for
matchers.forEach((matcher) => {
const matchingLabelIndex = labels.findIndex((label) => isLabelMatch(matcher, label));
// record that matcher for the label
if (matchingLabelIndex > -1) {
details[matchingLabelIndex].match = true;
details[matchingLabelIndex].matcher = matcher;
}
});
return { matches, details };
}
// ⚠️ DO NOT USE THIS FUNCTION FOR ROUTE SELECTION ALGORITHM
// for route selection algorithm, always compare a single matcher to the entire label set
// see "matchLabelsSet"
export function isLabelMatch(matcher: LabelMatcher, label: Label): boolean {
const [labelKey, labelValue] = label;
const { label: matcherLabel, type: matcherType, value: matcherValue } = matcher;
if (labelKey !== matcherLabel) {
return false;
}
const matchFunction = OperatorFunctions[matcherType];
return matchFunction(labelValue, matcherValue);
}
export function matchLabelsSet(matchers: LabelMatcher[], labels: Label[]): boolean {
for (const matcher of matchers) {
if (!isLabelMatchInSet(matcher, labels)) {
return false;
}
}
return true;
}
/**
* Checks if a label matcher matches any of the labels in the provided set.
*/
function isLabelMatchInSet(matcher: LabelMatcher, labels: Label[]): boolean {
const { label, type, value } = matcher;
let labelValue = ''; // matchers that have no labels are treated as empty string label values
const labelForMatcher = Object.fromEntries(labels)[label];
if (labelForMatcher) {
labelValue = labelForMatcher;
}
const matchFunction = OperatorFunctions[type];
try {
// This can throw because the regex operators use the JavaScript regex engine
// and "new RegExp()" throws on invalid regular expressions.
//
// This is usually a user-error (because matcher values are taken from user input)
return matchFunction(labelValue, value);
} catch (err) {
return false;
}
}
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
const OperatorFunctions: Record<LabelMatcher['type'], OperatorPredicate> = {
'=': (lv, mv) => lv === mv,
'!=': (lv, mv) => lv !== mv,
// At the time of writing, Alertmanager compiles to another (anchored) Regular Expression,
// so we should also anchor our UI matches for consistency with this behaviour
// https://github.com/prometheus/alertmanager/blob/fd37ce9c95898ca68be1ab4d4529517174b73c33/pkg/labels/matcher.go#L69
'=~': (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return re.test(lv);
},
'!~': (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return !re.test(lv);
},
};
@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`matchLabels should match with non-equal matchers 1`] = `
[
{
"labelIndex": 0,
"match": true,
"matcher": {
"label": "team",
"type": "=",
"value": "operations",
},
},
]
`;
exports[`matchLabels should match with non-matching matchers 1`] = `
[
{
"labelIndex": 0,
"match": true,
"matcher": {
"label": "team",
"type": "=",
"value": "operations",
},
},
]
`;
exports[`matchLabels should not match with a set of matchers 1`] = `
[
{
"labelIndex": 0,
"match": true,
"matcher": {
"label": "team",
"type": "=",
"value": "operations",
},
},
{
"labelIndex": 1,
"match": false,
"matcher": null,
},
]
`;
@@ -0,0 +1 @@
export const USER_DEFINED_TREE_NAME = 'user-defined';
@@ -0,0 +1,96 @@
import { useCallback } from 'react';
import { RoutingTree, alertingAPI } from '../../api/v0alpha1/api.gen';
import { Label } from '../../matchers/types';
import { USER_DEFINED_TREE_NAME } from '../consts';
import { Route, RouteWithID } from '../types';
import { RouteMatchResult, convertRoutingTreeToRoute, matchAlertInstancesToPolicyTree } from '../utils';
export type RouteMatch = {
route: Route;
routeTree: {
// Add some metadata about the tree that is useful for displaying diagnostics
metadata: Pick<RoutingTree['metadata'], 'name'>;
// We'll include the entire expanded policy tree for diagnostics
expandedSpec: RouteWithID;
};
matchDetails: RouteMatchResult<RouteWithID>;
};
export type InstanceMatchResult = {
// The labels we used to match to our policies
labels: Label[];
// The routes that matched the labels where the key is a route and the value is an array of instances that match that route
matchedRoutes: RouteMatch[];
};
/**
* React hook that finds notification policy routes in all routing trees that match the provided set of alert instances.
*
* This hook queries the routing tree API and processes each tree to:
* 1. Convert RoutingTree structures to Route structures
* 2. Compute the inherited properties for each node in the tree
* 3. Find routes within each tree that match the given set of labels
*
* @returns An object containing a `matchInstancesToPolicies` function that takes alert instances
* and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details
*/
export function useMatchAlertInstancesToNotificationPolicies() {
// fetch the routing trees from the API
const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery(
{},
{
refetchOnFocus: true,
refetchOnReconnect: true,
}
);
const matchInstancesToPolicies = useCallback(
(instances: Label[][]): InstanceMatchResult[] => {
if (!data) {
return [];
}
// the routing trees are returned as an array of items because there can be several
const trees = data.items;
return instances.map<InstanceMatchResult>((labels) => {
// Collect all matched routes from all trees
const allMatchedRoutes: RouteMatch[] = [];
// Process each tree for this instance
trees.forEach((tree) => {
const treeName = tree.metadata.name ?? USER_DEFINED_TREE_NAME;
// We have to convert the RoutingTree structure to a Route structure to be able to use the matching functions
const rootRoute = convertRoutingTreeToRoute(tree);
// Match this single instance against the route tree
const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute);
// Process each matched route from the tree
matchedPolicies.forEach((results, route) => {
// For each match result, create a RouteMatch object
results.forEach((matchDetails) => {
allMatchedRoutes.push({
route,
routeTree: {
metadata: { name: treeName },
expandedSpec: expandedTree,
},
matchDetails,
});
});
});
});
return {
labels,
matchedRoutes: allMatchedRoutes,
};
});
},
[data]
);
return { matchInstancesToPolicies, ...rest };
}
@@ -0,0 +1,20 @@
import { OverrideProperties } from 'type-fest';
import { RoutingTreeRoute } from '../api/v0alpha1/api.gen';
import { LabelMatcher } from '../matchers/types';
// type-narrow the route tree
export type Route = OverrideProperties<
RoutingTreeRoute,
{
matchers?: LabelMatcher[];
routes: Route[];
}
>;
// a route, but with an identifier – we use this to modify or identify individual routes.
// Mostly used for searching / filtering.
export interface RouteWithID extends Route {
id: string;
routes: RouteWithID[];
}
@@ -0,0 +1,192 @@
/**
* These tests were moved from Grafana core, we're keepign them around to prevent uncaught regressions
*/
import { LabelMatcherFactory, RouteFactory } from '../api/v0alpha1/mocks/fakes/Routes';
import { Route } from './types';
import { findMatchingRoutes } from './utils';
const CATCH_ALL_ROUTE: Route = RouteFactory.build({
receiver: 'ALL',
matchers: [],
});
describe('findMatchingRoutes', () => {
const policies: Route = RouteFactory.build({
receiver: 'ROOT',
group_by: ['grafana_folder'],
matchers: [],
routes: [
RouteFactory.build({
receiver: 'A',
matchers: [
LabelMatcherFactory.build({
label: 'team',
type: '=',
value: 'operations',
}),
],
routes: [
RouteFactory.build({
receiver: 'B1',
matchers: [
LabelMatcherFactory.build({
label: 'region',
type: '=',
value: 'europe',
}),
],
routes: [],
}),
RouteFactory.build({
receiver: 'B2',
matchers: [
LabelMatcherFactory.build({
label: 'region',
type: '=',
value: 'nasa',
}),
],
routes: [],
}),
],
}),
RouteFactory.build({
receiver: 'C',
matchers: [
LabelMatcherFactory.build({
label: 'foo',
type: '=',
value: 'bar',
}),
],
routes: [],
}),
],
group_wait: '10s',
group_interval: '1m',
});
it('should match root route with no matching labels', () => {
const 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']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'A');
});
it('should match route with negative matchers', () => {
const policiesWithNegative = RouteFactory.build({
...policies,
routes: policies.routes?.concat(
RouteFactory.build({
receiver: 'D',
matchers: [
LabelMatcherFactory.build({
label: 'name',
type: '!=',
value: 'gilles',
}),
],
routes: [],
})
),
});
const matches = findMatchingRoutes(policiesWithNegative, [['name', 'konrad']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'D');
});
it('should match child route of matching parent', () => {
const matches = findMatchingRoutes(policies, [
['team', 'operations'],
['region', 'europe'],
]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'B1');
});
it('should match simple policy', () => {
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'C');
});
it('should match catch-all route', () => {
const policiesWithAll: Route = RouteFactory.build({
...policies,
routes: [CATCH_ALL_ROUTE, ...(policies.routes ?? [])],
});
const matches = findMatchingRoutes(policiesWithAll, []);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
});
it('should match multiple routes with continue', () => {
const policiesWithAll: Route = RouteFactory.build({
...policies,
routes: [
RouteFactory.build({
...CATCH_ALL_ROUTE,
continue: true,
}),
...(policies.routes ?? []),
],
});
const matches = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]);
expect(matches).toHaveLength(2);
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
expect(matches[1].route).toHaveProperty('receiver', 'C');
});
it('should not match grandchild routes with same labels as parent', () => {
const policies: Route = RouteFactory.build({
receiver: 'PARENT',
group_by: ['grafana_folder'],
matchers: [
LabelMatcherFactory.build({
label: 'foo',
type: '=',
value: 'bar',
}),
],
routes: [
RouteFactory.build({
receiver: 'CHILD',
matchers: [
LabelMatcherFactory.build({
label: 'baz',
type: '=',
value: 'qux',
}),
],
routes: [
RouteFactory.build({
receiver: 'GRANDCHILD',
matchers: [
LabelMatcherFactory.build({
label: 'foo',
type: '=',
value: 'bar',
}),
],
routes: [],
}),
],
}),
],
group_wait: '10s',
group_interval: '1m',
});
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'PARENT');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash';
import { RoutingTree, RoutingTreeRoute } from '../api/v0alpha1/api.gen';
import { Label, LabelMatcher } from '../matchers/types';
import { LabelMatchDetails, matchLabels } from '../matchers/utils';
import { Route, RouteWithID } from './types';
export const INHERITABLE_KEYS = ['receiver', 'group_by', 'group_wait', 'group_interval', 'repeat_interval'] as const;
export type InheritableKeys = typeof INHERITABLE_KEYS;
export type InheritableProperties = Pick<Route, InheritableKeys[number]>;
// Represents matching information for a single route in the traversal path
export type RouteMatchInfo<T extends Route> = {
route: T;
matchDetails: LabelMatchDetails[];
matched: boolean;
};
export interface RouteMatchResult<T extends Route> {
route: T;
labels: Label[];
// Track matching information for each route in the traversal path
matchingJourney: Array<RouteMatchInfo<T>>;
}
/**
* This function performs a depth-first left-to-right search through the route tree and returns the matching routing nodes.
*
* If the current node is not a match, return nothing
* Normalization should have happened earlier in the code
*/
export function findMatchingRoutes<T extends Route>(
route: T,
labels: Label[],
matchingJourney: Array<RouteMatchInfo<T>> = []
): Array<RouteMatchResult<T>> {
let childMatches: Array<RouteMatchResult<T>> = [];
// Check if the current node matches
const matchResult = matchLabels(route.matchers ?? [], labels);
// Create matching info for this route
const currentMatchInfo: RouteMatchInfo<T> = {
route,
matchDetails: matchResult.details,
matched: matchResult.matches,
};
// Add current route's matching info to the journey
const currentMatchingJourney = [...matchingJourney, currentMatchInfo];
// If the current node is not a match, return nothing
if (!matchResult.matches) {
return [];
}
// If the current node matches, recurse through child nodes
if (route.routes) {
for (const child of route.routes) {
const matchingChildren = findMatchingRoutes(child, labels, currentMatchingJourney);
// TODO how do I solve this typescript thingy? It looks correct to me /shrug
// @ts-ignore
childMatches = childMatches.concat(matchingChildren);
// 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 (childMatches.length === 0) {
childMatches.push({
route,
labels,
matchingJourney: currentMatchingJourney,
});
}
return childMatches;
}
/**
* This function will compute the full tree with inherited properties – this is mostly used for search and filtering
*/
export function computeInheritedTree<T extends Route>(parent: T): T {
return {
...parent,
routes: parent.routes?.map((child) => {
const inheritedProperties = getInheritedProperties(parent, child);
return computeInheritedTree({
...child,
...inheritedProperties,
});
}),
};
}
// inherited properties are config properties that exist on the parent route (or its inherited properties) but not on the child route
export function getInheritedProperties<T extends Route>(
parentRoute: T,
childRoute: T,
propertiesParentInherited?: InheritableProperties
): InheritableProperties {
const propsFromParent: InheritableProperties = pick(parentRoute, INHERITABLE_KEYS);
const inheritableProperties: InheritableProperties = {
...propsFromParent,
...propertiesParentInherited,
} as const;
// @ts-expect-error we're using "keyof" for the property so the type checker can help us out but this makes the
// reduce function signature unhappy
const inherited = reduce(
inheritableProperties,
(inheritedProperties: InheritableProperties, parentValue, property: keyof InheritableProperties) => {
const parentHasValue = parentValue != null;
const inheritableValues = [undefined, '', null];
const childIsInheriting = inheritableValues.some((value) => childRoute[property] === value);
const inheritFromValue = childIsInheriting && parentHasValue;
const inheritEmptyGroupByFromParent =
property === 'group_by' &&
parentHasValue &&
isArray(childRoute[property]) &&
childRoute[property]?.length === 0;
const inheritFromParent = inheritFromValue || inheritEmptyGroupByFromParent;
if (inheritFromParent) {
// @ts-ignore
inheritedProperties[property] = parentValue;
}
return inheritedProperties;
},
{}
);
return inherited;
}
export function addUniqueIdentifier(route: Route): RouteWithID {
return {
id: uniqueId('route-'),
...route,
routes: route.routes?.map(addUniqueIdentifier) ?? [],
};
}
export type TreeMatch = {
/* we'll include the entire expanded policy tree for diagnostics */
expandedTree: RouteWithID;
/* the routes that matched the labels where the key is a route and the value is an array of instances that match that route */
matchedPolicies: Map<RouteWithID, Array<RouteMatchResult<RouteWithID>>>;
};
/**
* This function will return what notification policies would match a set of labels.
*
* ⚠️ This function is rather CPU intensive depending on both the size of the labels list and the size of the notification policy tree.
* When using this function, consider wrapping it in a web-worker to offload this from the main JavaScript thread.
*
* @param instances - A set of labels for which you want to determine the matching policies
* @param routingTree - A notification policy tree (or subtree)
*/
export function matchAlertInstancesToPolicyTree(instances: Label[][], routingTree: Route): TreeMatch {
// initially empty map of matches policies
const matchedPolicies = new Map();
// compute the entire expanded tree for matching routes and diagnostics
// this will include inherited properties from parent nodes
const expandedTree = addUniqueIdentifier(computeInheritedTree(routingTree));
// let's first find all matching routes for the provided instances
const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels));
// now group the matches by route ID
// this will give us a map of route IDs to their matching instances
// we use the route ID as the key to ensure uniqueness
const groupedByRoute = groupBy(matchesArray, (match) => match.route.id);
Object.entries(groupedByRoute).forEach(([_key, match]) => {
matchedPolicies.set(match[0].route, match);
});
return {
expandedTree,
matchedPolicies,
};
}
/**
* Converts a RoutingTree to a Route by merging defaults with routes.
*
* @param routingTree - The RoutingTree from the API
* @returns A Route that can be used with the matching functions
*/
export function convertRoutingTreeToRoute(routingTree: RoutingTree): Route {
const convertRoutingTreeRoutes = (routes: RoutingTreeRoute[]): Route[] => {
return routes.map(
(route): Route => ({
...route,
matchers: route.matchers?.map(
(matcher): LabelMatcher => ({
...matcher,
// sadly we use type narrowing for this on Route but the codegen has it as a string
type: matcher.type as LabelMatcher['type'],
})
),
routes: route.routes ? convertRoutingTreeRoutes(route.routes) : [],
})
);
};
// Create the root route by merging defaults with the route structure
const rootRoute: Route = {
...routingTree.spec.defaults,
continue: false,
active_time_intervals: [],
mute_time_intervals: [],
matchers: [], // Root route has no matchers (catch-all)
routes: convertRoutingTreeRoutes(routingTree.spec.routes),
};
return rootRoute;
}
@@ -1,4 +1,7 @@
/**
* Export things here that you want to be available under @grafana/alerting/internal
*/
export { INHERITABLE_KEYS, type InheritableProperties } from './grafana/notificationPolicies/utils';
export default {};
+1
View File
@@ -4,6 +4,7 @@ export * from './grafana/api/v0alpha1/mocks/handlers';
// export mocks and factories
export * from './grafana/api/v0alpha1/mocks/fakes/common';
export * from './grafana/api/v0alpha1/mocks/fakes/Receivers';
export * from './grafana/api/v0alpha1/mocks/fakes/Routes';
// scenarios
export * from './grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario';
+22
View File
@@ -6,6 +6,28 @@
export * from './grafana/api/v0alpha1/types';
export { useListContactPoints } from './grafana/contactPoints/hooks/v0alpha1/useContactPoints';
export { ContactPointSelector } from './grafana/contactPoints/components/ContactPointSelector/ContactPointSelector';
export { getContactPointDescription } from './grafana/contactPoints/utils';
// Notification Policies
export {
useMatchAlertInstancesToNotificationPolicies,
type RouteMatch,
type InstanceMatchResult,
} from './grafana/notificationPolicies/hooks/useMatchPolicies';
export {
type TreeMatch,
type RouteMatchResult,
matchAlertInstancesToPolicyTree,
findMatchingRoutes,
getInheritedProperties,
computeInheritedTree,
} from './grafana/notificationPolicies/utils';
export { USER_DEFINED_TREE_NAME } from './grafana/notificationPolicies/consts';
export * from './grafana/notificationPolicies/types';
// Matchers
export { type LabelMatcher, type Label } from './grafana/matchers/types';
export { matchLabelsSet, matchLabels, isLabelMatch, type LabelMatchDetails } from './grafana/matchers/utils';
// Low-level API hooks
export { alertingAPI } from './grafana/api/v0alpha1/api.gen';
+1
View File
@@ -3047,6 +3047,7 @@ __metadata:
type-fest: "npm:^4.40.0"
typescript: "npm:5.9.2"
peerDependencies:
"@grafana/data": ">=11.6 <= 12.x"
"@grafana/runtime": ">=11.6 <= 12.x"
"@grafana/ui": ">=11.6 <= 12.x"
"@reduxjs/toolkit": ^2.8.0