Alerting: Add route matching functionality to package (#108982)
This commit is contained in:
@@ -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"]
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
+48
@@ -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 {};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user