From a71664c1142ec5e7c3eb339329e62a687ad5550a Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 29 Aug 2025 16:52:27 +0200 Subject: [PATCH] Alerting: Add route matching functionality to package (#108982) --- .betterer.results | 3 + packages/grafana-alerting/package.json | 1 + packages/grafana-alerting/rollup.config.ts | 1 + .../api/v0alpha1/mocks/fakes/Routes.ts | 28 + .../src/grafana/contactPoints/utils.ts | 3 +- .../src/grafana/matchers/types.ts | 13 + .../src/grafana/matchers/utils.test.ts | 93 ++ .../src/grafana/matchers/utils.ts | 117 ++ .../__snapshots__/utils.test.ts.snap | 48 + .../grafana/notificationPolicies/consts.ts | 1 + .../hooks/useMatchPolicies.ts | 96 ++ .../src/grafana/notificationPolicies/types.ts | 20 + .../notificationPolicies/utils.old.test.ts | 192 +++ .../notificationPolicies/utils.test.ts | 1131 +++++++++++++++++ .../src/grafana/notificationPolicies/utils.ts | 228 ++++ packages/grafana-alerting/src/internal.ts | 3 + packages/grafana-alerting/src/testing.ts | 1 + packages/grafana-alerting/src/unstable.ts | 22 + yarn.lock | 1 + 19 files changed, 2001 insertions(+), 1 deletion(-) create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Routes.ts create mode 100644 packages/grafana-alerting/src/grafana/matchers/types.ts create mode 100644 packages/grafana-alerting/src/grafana/matchers/utils.test.ts create mode 100644 packages/grafana-alerting/src/grafana/matchers/utils.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/consts.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/types.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/utils.old.test.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts create mode 100644 packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts diff --git a/.betterer.results b/.betterer.results index dbdaa216916..817d62a8992 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index e51a30bdcb3..2c0f4f6f4c1 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -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", diff --git a/packages/grafana-alerting/rollup.config.ts b/packages/grafana-alerting/rollup.config.ts index 1638793c474..f8f41e3ad2c 100644 --- a/packages/grafana-alerting/rollup.config.ts +++ b/packages/grafana-alerting/rollup.config.ts @@ -22,5 +22,6 @@ export default [ input: 'src/testing.ts', plugins, output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], + treeshake: false, }, ]; diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Routes.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Routes.ts new file mode 100644 index 00000000000..ba9a1b4b8f0 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Routes.ts @@ -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(() => { + const operators: Array = ['=', '!=', '=~', '!~']; + + 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(() => ({ + 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: [], +})); diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts index d32f7d7ecfe..390c09d3887 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts +++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts @@ -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 ''; } diff --git a/packages/grafana-alerting/src/grafana/matchers/types.ts b/packages/grafana-alerting/src/grafana/matchers/types.ts new file mode 100644 index 00000000000..30128a94e99 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/matchers/types.ts @@ -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: '=' | '!=' | '=~' | '!~'; + } +>; diff --git a/packages/grafana-alerting/src/grafana/matchers/utils.test.ts b/packages/grafana-alerting/src/grafana/matchers/utils.test.ts new file mode 100644 index 00000000000..6fa169b651a --- /dev/null +++ b/packages/grafana-alerting/src/grafana/matchers/utils.test.ts @@ -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); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/matchers/utils.ts b/packages/grafana-alerting/src/grafana/matchers/utils.ts new file mode 100644 index 00000000000..fd3b1f18cd0 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/matchers/utils.ts @@ -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((_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 = { + '=': (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); + }, +}; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/__snapshots__/utils.test.ts.snap b/packages/grafana-alerting/src/grafana/notificationPolicies/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..3c4ea12b226 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/__snapshots__/utils.test.ts.snap @@ -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, + }, +] +`; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/consts.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/consts.ts new file mode 100644 index 00000000000..c103d731558 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/consts.ts @@ -0,0 +1 @@ +export const USER_DEFINED_TREE_NAME = 'user-defined'; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts new file mode 100644 index 00000000000..14516375a86 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts @@ -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; + // We'll include the entire expanded policy tree for diagnostics + expandedSpec: RouteWithID; + }; + matchDetails: RouteMatchResult; +}; + +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((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 }; +} diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts new file mode 100644 index 00000000000..04bdc4f96c5 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts @@ -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[]; +} diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.old.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.old.test.ts new file mode 100644 index 00000000000..0107ac4878f --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.old.test.ts @@ -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'); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts new file mode 100644 index 00000000000..3911a4ba676 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts @@ -0,0 +1,1131 @@ +import { omit } from 'lodash'; + +import { LabelMatcherFactory, RouteFactory } from '../api/v0alpha1/mocks/fakes/Routes'; +import { Label } from '../matchers/types'; +import { LabelMatchDetails, matchLabels } from '../matchers/utils'; + +import { Route } from './types'; +import { + InheritableProperties, + RouteMatchResult, + addUniqueIdentifier, + computeInheritedTree, + findMatchingRoutes, + getInheritedProperties, + matchAlertInstancesToPolicyTree, +} from './utils'; + +describe('findMatchingRoutes', () => { + describe('basic matching', () => { + it('should return empty array when route does not match', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + }); + const labels: Label[] = [['service', 'api']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toEqual([]); + }); + + it('should match route with exact label match', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + }); + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + expect(result[0].labels).toBe(labels); + expect(getRoutePath(result[0])).toEqual([route]); + }); + + it('should match route with multiple matchers', () => { + const route = RouteFactory.build({ + matchers: [ + LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' }), + LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' }), + ], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + expect(getRoutePath(result[0])).toEqual([route]); + }); + + it('should not match when one matcher fails', () => { + const route = RouteFactory.build({ + matchers: [ + LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' }), + LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' }), + ], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'staging'], + ]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toEqual([]); + }); + + it('should match route with no matchers (catch-all)', () => { + const route = RouteFactory.build({ + matchers: [], + receiver: 'default-receiver', + }); + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + expect(getRoutePath(result[0])).toEqual([route]); + }); + }); + + describe('nested route matching', () => { + it('should return child route when child matches', () => { + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(childRoute); + expect(getRoutePath(result[0])).toEqual([parentRoute, childRoute]); + }); + + it('should return parent route when parent matches but child does not', () => { + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'staging' })], + receiver: 'staging-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(parentRoute); + expect(getRoutePath(result[0])).toEqual([parentRoute]); + }); + + it('should return empty array when parent does not match', () => { + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'api' })], + receiver: 'api-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toEqual([]); + }); + + it('should handle deeply nested routes', () => { + const grandChildRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'region', type: '=', value: 'us-east' })], + receiver: 'us-east-receiver', + }); + const grandChildRoute2 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'region', type: '=', value: 'us-west' })], + receiver: 'us-west-receiver', + }); + + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + routes: [grandChildRoute, grandChildRoute2], + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['region', 'us-east'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(grandChildRoute); + expect(getRoutePath(result[0])).toEqual([parentRoute, childRoute, grandChildRoute]); + }); + }); + + describe('continue behavior', () => { + it('should return first matching child when continue is false', () => { + const childRoute1 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + continue: false, + }); + const childRoute2 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'team', type: '=', value: 'backend' })], + receiver: 'backend-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute1, childRoute2], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(childRoute1); + expect(getRoutePath(result[0])).toEqual([parentRoute, childRoute1]); + }); + + it('should return multiple matching children when continue is true', () => { + const childRoute1 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + continue: true, + }); + const childRoute2 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'team', type: '=', value: 'backend' })], + receiver: 'backend-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute1, childRoute2], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(2); + expect(result[0].route).toBe(childRoute1); + expect(getRoutePath(result[0])).toEqual([parentRoute, childRoute1]); + expect(result[1].route).toBe(childRoute2); + expect(getRoutePath(result[1])).toEqual([parentRoute, childRoute2]); + }); + + it('should continue processing siblings when continue is true', () => { + const childRoute1 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + continue: true, + }); + const childRoute2 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'team', type: '=', value: 'frontend' })], + receiver: 'frontend-receiver', + }); + const childRoute3 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'priority', type: '=', value: 'high' })], + receiver: 'high-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute1, childRoute2, childRoute3], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], // doesn't match childRoute2 + ['priority', 'high'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(2); // childRoute1 and childRoute3 both match + expect(result[0].route).toBe(childRoute1); + expect(getRoutePath(result[0])).toEqual([parentRoute, childRoute1]); + expect(result[1].route).toBe(childRoute3); + expect(getRoutePath(result[1])).toEqual([parentRoute, childRoute3]); + }); + }); + + describe('route path tracking', () => { + it('should track route path with initial path provided', () => { + const initialRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'app', type: '=', value: 'grafana' })], + receiver: 'grafana-receiver', + }); + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + }); + const labels: Label[] = [['service', 'web']]; + + // Create a matching journey with the initial route + const initialMatchInfo = { + route: initialRoute, + matchDetails: [], + matched: true, + }; + const result = findMatchingRoutes(route, labels, [initialMatchInfo]); + + expect(result).toHaveLength(1); + expect(getRoutePath(result[0])).toEqual([initialRoute, route]); + }); + + it('should handle empty initial route path', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + }); + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels, []); + + expect(result).toHaveLength(1); + expect(getRoutePath(result[0])).toEqual([route]); + }); + + it('should preserve route path through multiple levels', () => { + const level3Route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'instance', type: '=', value: 'i-123' })], + receiver: 'instance-receiver', + }); + const level2Route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + routes: [level3Route], + }); + const level1Route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [level2Route], + }); + const rootRoute = RouteFactory.build({ + matchers: [], + receiver: 'root-receiver', + routes: [level1Route], + }); + + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['instance', 'i-123'], + ]; + + const result = findMatchingRoutes(rootRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(level3Route); + expect(getRoutePath(result[0])).toEqual([rootRoute, level1Route, level2Route, level3Route]); + }); + }); + + describe('match details', () => { + it('should include match details for successful matches', () => { + const route = RouteFactory.build({ + matchers: [ + LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' }), + LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' }), + ], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + const matchDetails = getMatchDetails(result[0]); + expect(matchDetails).toBeDefined(); + expect(matchDetails).toHaveLength(3); // One for each label + expect(matchDetails[0].labelIndex).toBe(0); + expect(matchDetails[0].match).toBe(true); + expect(matchDetails[1].labelIndex).toBe(1); + expect(matchDetails[1].match).toBe(true); + expect(matchDetails[2].labelIndex).toBe(2); + expect(matchDetails[2].match).toBe(false); // team label doesn't have a matcher + }); + }); + + describe('regex matchers', () => { + it('should handle regex positive matching', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=~', value: 'web.*' })], + receiver: 'web-receiver', + }); + const labels: Label[] = [['service', 'web-api']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + }); + + it('should handle regex negative matching', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '!~', value: 'web.*' })], + receiver: 'non-web-receiver', + }); + const labels: Label[] = [['service', 'api-backend']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + }); + + it('should not match when regex positive match fails', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=~', value: 'web.*' })], + }); + const labels: Label[] = [['service', 'api-backend']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toEqual([]); + }); + }); + + describe('matching journey tracking', () => { + it('should track matching journey for single route', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + }); + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].matchingJourney).toHaveLength(1); + expect(result[0].matchingJourney[0].route).toBe(route); + expect(result[0].matchingJourney[0].matched).toBe(true); + expect(result[0].matchingJourney[0].matchDetails).toBeDefined(); + }); + + it('should track matching journey through nested routes', () => { + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(childRoute); + + // Should track journey through parent and child + expect(result[0].matchingJourney).toHaveLength(2); + expect(result[0].matchingJourney[0].route).toBe(parentRoute); + expect(result[0].matchingJourney[0].matched).toBe(true); + expect(result[0].matchingJourney[1].route).toBe(childRoute); + expect(result[0].matchingJourney[1].matched).toBe(true); + }); + + it('should track detailed matching information for each route in journey', () => { + const childRoute = RouteFactory.build({ + matchers: [ + LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' }), + LabelMatcherFactory.build({ label: 'region', type: '=', value: 'us-east' }), + ], + receiver: 'prod-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['region', 'us-east'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + + // Parent route matching details + const parentMatchInfo = result[0].matchingJourney[0]; + expect(parentMatchInfo.route).toBe(parentRoute); + expect(parentMatchInfo.matched).toBe(true); + expect(parentMatchInfo.matchDetails).toHaveLength(4); // All labels are checked + expect(parentMatchInfo.matchDetails[0].match).toBe(true); // service matches + expect(parentMatchInfo.matchDetails[1].match).toBe(false); // env doesn't have matcher in parent + expect(parentMatchInfo.matchDetails[2].match).toBe(false); // region doesn't have matcher in parent + expect(parentMatchInfo.matchDetails[3].match).toBe(false); // team doesn't have matcher in parent + + // Child route matching details + const childMatchInfo = result[0].matchingJourney[1]; + expect(childMatchInfo.route).toBe(childRoute); + expect(childMatchInfo.matched).toBe(true); + expect(childMatchInfo.matchDetails).toHaveLength(4); // All labels are checked + expect(childMatchInfo.matchDetails[0].match).toBe(false); // service doesn't have matcher in child + expect(childMatchInfo.matchDetails[1].match).toBe(true); // env matches + expect(childMatchInfo.matchDetails[2].match).toBe(true); // region matches + expect(childMatchInfo.matchDetails[3].match).toBe(false); // team doesn't have matcher in child + }); + + it('should track journey for deeply nested routes', () => { + const grandChildRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'region', type: '=', value: 'us-east' })], + receiver: 'us-east-receiver', + }); + const childRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + routes: [grandChildRoute], + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['region', 'us-east'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(grandChildRoute); + + // Should track journey through all three levels + expect(result[0].matchingJourney).toHaveLength(3); + expect(result[0].matchingJourney[0].route).toBe(parentRoute); + expect(result[0].matchingJourney[0].matched).toBe(true); + expect(result[0].matchingJourney[1].route).toBe(childRoute); + expect(result[0].matchingJourney[1].matched).toBe(true); + expect(result[0].matchingJourney[2].route).toBe(grandChildRoute); + expect(result[0].matchingJourney[2].matched).toBe(true); + }); + + it('should track journey for multiple matching routes with continue behavior', () => { + const childRoute1 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + continue: true, + }); + const childRoute2 = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'team', type: '=', value: 'backend' })], + receiver: 'backend-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [childRoute1, childRoute2], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ['team', 'backend'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(2); + + // First result (childRoute1) + expect(result[0].matchingJourney).toHaveLength(2); + expect(result[0].matchingJourney[0].route).toBe(parentRoute); + expect(result[0].matchingJourney[0].matched).toBe(true); + expect(result[0].matchingJourney[1].route).toBe(childRoute1); + expect(result[0].matchingJourney[1].matched).toBe(true); + + // Second result (childRoute2) + expect(result[1].matchingJourney).toHaveLength(2); + expect(result[1].matchingJourney[0].route).toBe(parentRoute); + expect(result[1].matchingJourney[0].matched).toBe(true); + expect(result[1].matchingJourney[1].route).toBe(childRoute2); + expect(result[1].matchingJourney[1].matched).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty routes array', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [], + }); + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + }); + + it('should handle empty labels array', () => { + const route = RouteFactory.build({ + matchers: [], + receiver: 'default-receiver', + }); + const labels: Label[] = []; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + expect(result[0].labels).toEqual([]); + }); + + it('should handle route with undefined matchers', () => { + const route: Route = { + receiver: 'default-receiver', + routes: [], + continue: false, + group_by: [], + group_wait: '10s', + group_interval: '5m', + repeat_interval: '12h', + mute_time_intervals: [], + active_time_intervals: [], + // matchers is undefined + }; + const labels: Label[] = [['service', 'web']]; + + const result = findMatchingRoutes(route, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(route); + }); + + it('should handle mixed matching and non-matching children', () => { + const matchingChild = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + receiver: 'prod-receiver', + }); + const nonMatchingChild = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'staging' })], + receiver: 'staging-receiver', + }); + const parentRoute = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-receiver', + routes: [nonMatchingChild, matchingChild], + }); + const labels: Label[] = [ + ['service', 'web'], + ['env', 'prod'], + ]; + + const result = findMatchingRoutes(parentRoute, labels); + + expect(result).toHaveLength(1); + expect(result[0].route).toBe(matchingChild); + expect(getRoutePath(result[0])).toEqual([parentRoute, matchingChild]); + }); + }); +}); + +describe('getInheritedProperties()', () => { + describe('group_by: []', () => { + it('should get group_by: [] from parent', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_by: ['label'], + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_by: [], + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toHaveProperty('group_by', ['label']); + }); + + it('should get group_by: [] from parent inherited properties', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_by: [], + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_by: [], + }); + + const parentInherited = { group_by: ['label'] }; + + const childInherited = getInheritedProperties(parent, child, parentInherited); + expect(childInherited).toHaveProperty('group_by', ['label']); + }); + + it('should not inherit if the child overrides an inheritable value (group_by)', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_by: ['parentLabel'], + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_by: ['childLabel'], + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).not.toHaveProperty('group_by'); + }); + + it('should inherit if group_by is undefined', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_by: ['label'], + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_by: undefined, + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toHaveProperty('group_by', ['label']); + }); + + it('should inherit from grandparent when parent is inheriting', () => { + const parentInheritedProperties: InheritableProperties = { receiver: 'grandparent' }; + const parent = RouteFactory.build({ receiver: undefined, group_by: ['foo'], routes: [] }); + const child = RouteFactory.build({ receiver: undefined, group_by: undefined }); + + const childInherited = getInheritedProperties(parent, child, parentInheritedProperties); + expect(childInherited).toHaveProperty('receiver', 'grandparent'); + expect(childInherited.group_by).toEqual(['foo']); + }); + }); + + describe('regular undefined or null values', () => { + it('should compute inherited properties being undefined', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_wait: '10s', + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_wait: undefined, + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toStrictEqual({ group_wait: '10s' }); + }); + + it('should compute inherited properties being null', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_wait: '10s', + }); + + const child = RouteFactory.build({ + receiver: undefined, + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toStrictEqual({ receiver: 'PARENT' }); + }); + + it('should compute inherited properties being undefined from parent inherited properties', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_wait: undefined, + }); + + const childInherited = getInheritedProperties(parent, child, { group_wait: '10s' }); + expect(childInherited).toStrictEqual({ group_wait: '10s' }); + }); + + it('should not inherit if the child overrides an inheritable value', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_wait: '10s', + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_wait: '30s', + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).not.toHaveProperty('group_wait'); + }); + + it('should not inherit if the child overrides an inheritable value and the parent inherits', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_wait: '30s', + }); + + const childInherited = getInheritedProperties(parent, child, { group_wait: '60s' }); + expect(childInherited).not.toHaveProperty('group_wait'); + }); + + it('should inherit if the child property is an empty string', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + }); + + const child = RouteFactory.build({ + receiver: '', + group_wait: '30s', + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toHaveProperty('receiver', 'PARENT'); + }); + }); + + describe('timing options', () => { + it('should inherit timing options', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_wait: '1m', + group_interval: '2m', + }); + + const child = RouteFactory.build({ + repeat_interval: '999s', + group_wait: undefined, + group_interval: undefined, + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toHaveProperty('group_wait', '1m'); + expect(childInherited).toHaveProperty('group_interval', '2m'); + }); + }); + it('should not inherit mute timings from parent route', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_by: ['parentLabel'], + mute_time_intervals: ['Mon-Fri 09:00-17:00'], + }); + + const child = RouteFactory.build({ + receiver: 'CHILD', + group_by: ['childLabel'], + }); + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).not.toHaveProperty('mute_time_intervals'); + }); +}); + +describe('computeInheritedTree', () => { + it('should merge properties from parent', () => { + const parent = RouteFactory.build({ + receiver: 'PARENT', + group_wait: '1m', + group_interval: '2m', + repeat_interval: '3m', + routes: [ + RouteFactory.build({ + receiver: undefined, + group_wait: undefined, + group_interval: undefined, + repeat_interval: '999s', + }), + ], + }); + + const treeRoot = computeInheritedTree(parent); + expect(treeRoot).toHaveProperty('group_wait', '1m'); + expect(treeRoot).toHaveProperty('group_interval', '2m'); + expect(treeRoot).toHaveProperty('repeat_interval', '3m'); + + expect(treeRoot).toHaveProperty('routes.0.group_wait', '1m'); + expect(treeRoot).toHaveProperty('routes.0.group_interval', '2m'); + expect(treeRoot).toHaveProperty('routes.0.repeat_interval', '999s'); + }); + + it('should not regress #73573', () => { + const parent = RouteFactory.build({ + routes: [ + RouteFactory.build({ + group_wait: '1m', + group_interval: '2m', + repeat_interval: '3m', + routes: [ + RouteFactory.build({ + group_wait: '10m', + group_interval: '20m', + repeat_interval: '30m', + }), + RouteFactory.build({ + group_wait: undefined, + group_interval: undefined, + repeat_interval: '999m', + }), + ], + }), + ], + }); + + const treeRoot = computeInheritedTree(parent); + expect(treeRoot).toHaveProperty('routes.0.group_wait', '1m'); + expect(treeRoot).toHaveProperty('routes.0.group_interval', '2m'); + expect(treeRoot).toHaveProperty('routes.0.repeat_interval', '3m'); + + expect(treeRoot).toHaveProperty('routes.0.routes.0.group_wait', '10m'); + expect(treeRoot).toHaveProperty('routes.0.routes.0.group_interval', '20m'); + expect(treeRoot).toHaveProperty('routes.0.routes.0.repeat_interval', '30m'); + + expect(treeRoot).toHaveProperty('routes.0.routes.1.group_wait', '1m'); + expect(treeRoot).toHaveProperty('routes.0.routes.1.group_interval', '2m'); + expect(treeRoot).toHaveProperty('routes.0.routes.1.repeat_interval', '999m'); + }); +}); + +describe('matchLabels', () => { + it('should match with non-matching matchers', () => { + const result = matchLabels( + [ + { label: 'foo', type: '=', value: '' }, + { label: 'team', type: '=', value: 'operations' }, + ], + [['team', 'operations']] + ); + + expect(result).toHaveProperty('matches', true); + expect(result.details).toMatchSnapshot(); + }); + + it('should match with non-equal matchers', () => { + const result = matchLabels( + [ + { label: 'foo', type: '!=', value: 'bar' }, + { label: 'team', type: '=', value: 'operations' }, + ], + [['team', 'operations']] + ); + + expect(result).toHaveProperty('matches', true); + expect(result.details).toMatchSnapshot(); + }); + + it('should not match with a set of matchers', () => { + const result = matchLabels( + [ + { label: 'foo', type: '!=', value: 'bar' }, + { label: 'team', type: '=', value: 'operations' }, + ], + [ + ['team', 'operations'], + ['foo', 'bar'], + ] + ); + + expect(result).toHaveProperty('matches', false); + expect(result.details).toMatchSnapshot(); + }); + + it('does not match unanchored regular expressions', () => { + const result = matchLabels([{ label: 'foo', type: '=~', value: 'bar' }], [['foo', 'barbarbar']]); + // This may seem unintuitive, but this is how Alertmanager matches, as it anchors the regex + expect(result.matches).toEqual(false); + }); + + it('matches regular expressions with wildcards', () => { + const result = matchLabels([{ label: 'foo', type: '=~', value: '.*bar.*' }], [['foo', 'barbarbar']]); + expect(result.matches).toEqual(true); + }); + + it('does match regular expressions with flags', () => { + const result = matchLabels([{ label: 'foo', type: '=~', value: '(?i).*BAr.*' }], [['foo', 'barbarbar']]); + expect(result.matches).toEqual(true); + }); +}); + +describe('addUniqueIdentifier', () => { + it('should add unique identifiers recursively and preserve all properties', () => { + const childRoute = RouteFactory.build({ + receiver: 'child-receiver', + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + }); + const parentRoute = RouteFactory.build({ + receiver: 'parent-receiver', + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + group_by: ['service'], + group_wait: '30s', + routes: [childRoute], + }); + + const { id, routes, ...rest } = addUniqueIdentifier(parentRoute); + + // Should add unique ID to parent + expect(id).toMatch(/^route-/); + // Should match the original route + expect(rest).toStrictEqual(omit(parentRoute, 'routes')); + + // Should recursively add unique ID to child + expect(routes).toHaveLength(1); + expect(routes[0]).toHaveProperty('id'); + expect(routes[0].id).toMatch(/^route-/); + expect(routes[0].receiver).toBe('child-receiver'); + expect(omit(routes[0], 'id')).toStrictEqual(childRoute); + + // IDs should be unique + expect(id).not.toBe(routes[0]?.id); + + // Should not modify original + expect(parentRoute).not.toHaveProperty('id'); + expect(childRoute).not.toHaveProperty('id'); + }); + + it('should handle undefined routes by converting to empty array', () => { + const route = RouteFactory.build({ + receiver: 'test-receiver', + routes: undefined, + }); + + const result = addUniqueIdentifier(route); + + expect(result).toHaveProperty('id'); + expect(result.routes).toEqual([]); + }); +}); + +describe('matchAlertInstancesToPolicyTree', () => { + it('should match alert instances to policy tree and return expanded tree with matched policies', () => { + const childRoute = RouteFactory.build({ + receiver: 'child-receiver', + matchers: [LabelMatcherFactory.build({ label: 'env', type: '=', value: 'prod' })], + group_wait: undefined, // Will inherit from parent + }); + const parentRoute = RouteFactory.build({ + receiver: 'parent-receiver', + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + group_wait: '30s', + routes: [childRoute], + }); + + const instances: Label[][] = [ + [ + ['service', 'web'], + ['env', 'prod'], + ], // Should match child + [ + ['service', 'web'], + ['env', 'staging'], + ], // Should match parent only + ]; + + const result = matchAlertInstancesToPolicyTree(instances, parentRoute); + + // Should return expanded tree with identifiers + expect(result.expandedTree).toHaveProperty('id'); + + // Should have matched policies map + expect(result.matchedPolicies).toBeInstanceOf(Map); + expect(result.matchedPolicies.size).toBe(2); // Both child and parent routes matched + + // Convert map to array for easier testing + const matches = Array.from(result.matchedPolicies.values()).flat(); + expect(matches).toHaveLength(2); + + // First instance should match child route + const childMatch = matches.find((match) => match.route.receiver === 'child-receiver'); + expect(childMatch).toBeDefined(); + expect(childMatch?.labels).toEqual([ + ['service', 'web'], + ['env', 'prod'], + ]); + + // Second instance should match parent route + const parentMatch = matches.find((match) => match.route.receiver === 'parent-receiver'); + expect(parentMatch).toBeDefined(); + expect(parentMatch?.labels).toEqual([ + ['service', 'web'], + ['env', 'staging'], + ]); + }); + + it('should handle empty instances and no matches', () => { + const route = RouteFactory.build({ + receiver: 'receiver', + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + }); + + // Empty instances array + const result1 = matchAlertInstancesToPolicyTree([], route); + expect(result1.expandedTree).toHaveProperty('id'); + expect(result1.matchedPolicies.size).toBe(0); + + // Instances that don't match + const instances: Label[][] = [[['service', 'api']]]; + const result2 = matchAlertInstancesToPolicyTree(instances, route); + expect(result2.expandedTree).toHaveProperty('id'); + expect(result2.matchedPolicies.size).toBe(0); + }); +}); + +function getRoutePath(result: RouteMatchResult): T[] { + return result.matchingJourney.map((step) => step.route); +} + +function getMatchDetails(result: RouteMatchResult): LabelMatchDetails[] { + const lastStep = result.matchingJourney[result.matchingJourney.length - 1]; + return lastStep ? lastStep.matchDetails : []; +} diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts new file mode 100644 index 00000000000..2cf5babe025 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts @@ -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; + +// Represents matching information for a single route in the traversal path +export type RouteMatchInfo = { + route: T; + matchDetails: LabelMatchDetails[]; + matched: boolean; +}; + +export interface RouteMatchResult { + route: T; + labels: Label[]; + // Track matching information for each route in the traversal path + matchingJourney: Array>; +} + +/** + * 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( + route: T, + labels: Label[], + matchingJourney: Array> = [] +): Array> { + let childMatches: Array> = []; + + // Check if the current node matches + const matchResult = matchLabels(route.matchers ?? [], labels); + + // Create matching info for this route + const currentMatchInfo: RouteMatchInfo = { + 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(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( + 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>>; +}; + +/** + * 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; +} diff --git a/packages/grafana-alerting/src/internal.ts b/packages/grafana-alerting/src/internal.ts index 9ad812acd3b..dbed1a3369b 100644 --- a/packages/grafana-alerting/src/internal.ts +++ b/packages/grafana-alerting/src/internal.ts @@ -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 {}; diff --git a/packages/grafana-alerting/src/testing.ts b/packages/grafana-alerting/src/testing.ts index b3a0d024b32..f9c6426fe9c 100644 --- a/packages/grafana-alerting/src/testing.ts +++ b/packages/grafana-alerting/src/testing.ts @@ -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'; diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index 5c10dcbf3eb..2433b19ade9 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index f4fe2d5cd5a..4ca287f6111 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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