Alerting: Use route matching hook (#111028)

This commit is contained in:
Gilles De Mey 2025-09-24 19:01:45 +02:00 committed by GitHub
parent c59aff3bb9
commit 61bf3d9899
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 2015 additions and 1933 deletions

View File

@ -24,11 +24,6 @@
"count": 1
}
},
"packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"packages/grafana-data/src/dataframe/ArrayDataFrame.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View File

@ -0,0 +1,74 @@
import { RoutingTree } from '../../api/v0alpha1/api.gen';
import { LabelMatcherFactory, RouteFactory } from '../../api/v0alpha1/mocks/fakes/Routes';
import { Label } from '../../matchers/types';
import { matchInstancesToRouteTrees } from './useMatchPolicies';
describe('matchInstancesToRouteTrees', () => {
it('should return root route when child routes do not match instances', () => {
const route = RouteFactory.build({
matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })],
receiver: 'web-team',
});
const treeName = 'test-tree';
const trees: RoutingTree[] = [
{
kind: 'RoutingTree',
metadata: { name: treeName },
spec: {
defaults: {
receiver: 'receiver 1',
},
routes: [route],
},
status: {},
},
];
const instanceLabels: Label[] = [['service', 'api']];
const instances: Label[][] = [instanceLabels]; // Different service - should not match
const result = matchInstancesToRouteTrees(trees, instances);
expect(result).toHaveLength(1);
expect(result[0].labels).toBe(instanceLabels);
expect(result[0].matchedRoutes).toHaveLength(1);
// The root route should match as it's a catch-all
expect(result[0].matchedRoutes[0].route.receiver).toBe('receiver 1');
expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName);
});
it('should return matched routes when trees match instances', () => {
const route = RouteFactory.build({
matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })],
receiver: 'web-team',
});
const treeName = 'test-tree';
const trees: RoutingTree[] = [
{
kind: 'RoutingTree',
metadata: { name: treeName },
spec: {
defaults: {
receiver: 'receiver 1',
},
routes: [route],
},
status: {},
},
];
const instanceLabels: Label[] = [['service', 'web']];
const instances: Label[][] = [instanceLabels];
const result = matchInstancesToRouteTrees(trees, instances);
expect(result).toHaveLength(1);
expect(result[0].labels).toBe(instanceLabels);
expect(result[0].matchedRoutes.length).toBeGreaterThan(0);
expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName);
expect(result[0].matchedRoutes[0].matchDetails.labels).toBe(instanceLabels);
});
});

View File

@ -4,7 +4,7 @@ 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';
import { RouteMatchResult, TreeMatch, convertRoutingTreeToRoute, matchInstancesToRoute } from '../utils';
export type RouteMatch = {
route: Route;
@ -32,11 +32,10 @@ export type InstanceMatchResult = {
* 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
* @returns An object containing a `matchInstancesToRoutingTrees` 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
export function useMatchInstancesToRouteTrees() {
const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery(
{},
{
@ -45,52 +44,52 @@ export function useMatchAlertInstancesToNotificationPolicies() {
}
);
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]
const memoizedFunction = useCallback(
(instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances),
[data?.items]
);
return { matchInstancesToPolicies, ...rest };
return {
matchInstancesToRouteTrees: memoizedFunction,
...rest,
};
}
/**
* This function will match a set of labels to multiple routing trees. Assumes a list of routing trees has already been fetched.
*
* Use "useMatchInstancesToRouteTrees" if you want the hook to automatically fetch the latest definition of routing trees.
*/
export function matchInstancesToRouteTrees(trees: RoutingTree[], instances: Label[][]): InstanceMatchResult[] {
// Process each tree and get matches for all instances
const treeMatches = trees.map<TreeMatch>((tree) => {
const rootRoute = convertRoutingTreeToRoute(tree);
return matchInstancesToRoute(rootRoute, instances);
});
// Group results by instance
return instances.map<InstanceMatchResult>((labels) => {
// Collect matches for this specific instance from all trees
const allMatchedRoutes = treeMatches.flatMap(({ expandedTree, matchedPolicies }, index) => {
const tree = trees[index];
return Array.from(matchedPolicies.entries()).flatMap(([route, results]) =>
results
.filter((matchDetails) => matchDetails.labels === labels)
.map((matchDetails) => ({
route,
routeTree: {
metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME },
expandedSpec: expandedTree,
},
matchDetails,
}))
);
});
return {
labels,
matchedRoutes: allMatchedRoutes,
};
});
}

View File

@ -12,7 +12,7 @@ import {
computeInheritedTree,
findMatchingRoutes,
getInheritedProperties,
matchAlertInstancesToPolicyTree,
matchInstancesToRoute,
} from './utils';
describe('findMatchingRoutes', () => {
@ -1072,7 +1072,7 @@ describe('matchAlertInstancesToPolicyTree', () => {
], // Should match parent only
];
const result = matchAlertInstancesToPolicyTree(instances, parentRoute);
const result = matchInstancesToRoute(parentRoute, instances);
// Should return expanded tree with identifiers
expect(result.expandedTree).toHaveProperty('id');
@ -1109,13 +1109,13 @@ describe('matchAlertInstancesToPolicyTree', () => {
});
// Empty instances array
const result1 = matchAlertInstancesToPolicyTree([], route);
const result1 = matchInstancesToRoute(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);
const result2 = matchInstancesToRoute(route, instances);
expect(result2.expandedTree).toHaveProperty('id');
expect(result2.matchedPolicies.size).toBe(0);
});

View File

@ -1,7 +1,7 @@
import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash';
import { RoutingTree, RoutingTreeRoute } from '../api/v0alpha1/api.gen';
import { Label, LabelMatcher } from '../matchers/types';
import { Label } from '../matchers/types';
import { LabelMatchDetails, matchLabels } from '../matchers/utils';
import { Route, RouteWithID } from './types';
@ -150,6 +150,7 @@ export function addUniqueIdentifier(route: Route): RouteWithID {
};
}
// all policies that were matched of a single tree
export type TreeMatch = {
/* we'll include the entire expanded policy tree for diagnostics */
expandedTree: RouteWithID;
@ -166,13 +167,13 @@ export type TreeMatch = {
* @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 {
export function matchInstancesToRoute(rootRoute: Route, instances: Label[][]): 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));
const expandedTree = addUniqueIdentifier(computeInheritedTree(rootRoute));
// let's first find all matching routes for the provided instances
const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels));
@ -202,13 +203,6 @@ export function convertRoutingTreeToRoute(routingTree: RoutingTree): 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) : [],
})
);

View File

@ -10,14 +10,15 @@ export { getContactPointDescription } from './grafana/contactPoints/utils';
// Notification Policies
export {
useMatchAlertInstancesToNotificationPolicies,
useMatchInstancesToRouteTrees,
matchInstancesToRouteTrees,
type RouteMatch,
type InstanceMatchResult,
} from './grafana/notificationPolicies/hooks/useMatchPolicies';
export {
type TreeMatch,
type RouteMatchResult,
matchAlertInstancesToPolicyTree,
matchInstancesToRoute,
findMatchingRoutes,
getInheritedProperties,
computeInheritedTree,

View File

@ -10,9 +10,9 @@ export function useRouteGroupsMatcher() {
return routeGroupsMatcher.getRouteGroupsMap(route, groups);
}, []);
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
const matchInstancesToRoutes = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
return routeGroupsMatcher.matchInstancesToRoutes(rootRoute, instancesToMatch);
}, []);
return { getRouteGroupsMap, matchInstancesToRoute };
return { getRouteGroupsMap, matchInstancesToRoutes };
}

View File

@ -19,41 +19,101 @@ exports[`findRoutesMatchingFilters should work with all filters 1`] = `
"filtersApplied": true,
"matchedRoutesWithPath": Map {
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
} => [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "0",
"mute_time_intervals": undefined,
"object_matchers": [],
"receiver": "default-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -61,19 +121,45 @@ exports[`findRoutesMatchingFilters should work with all filters 1`] = `
],
},
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -95,41 +181,101 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher
"filtersApplied": true,
"matchedRoutesWithPath": Map {
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
} => [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "0",
"mute_time_intervals": undefined,
"object_matchers": [],
"receiver": "default-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -137,50 +283,121 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher
],
},
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
},
],
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
} => [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "0",
"mute_time_intervals": undefined,
"object_matchers": [],
"receiver": "default-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -188,29 +405,66 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher
],
},
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
},
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -223,41 +477,101 @@ exports[`findRoutesMatchingFilters should work with only label matchers 1`] = `
"filtersApplied": true,
"matchedRoutesWithPath": Map {
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
} => [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "0",
"mute_time_intervals": undefined,
"object_matchers": [],
"receiver": "default-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],
@ -265,19 +579,45 @@ exports[`findRoutesMatchingFilters should work with only label matchers 1`] = `
],
},
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "1",
"matchers": [
"hello=world",
"foo!=bar",
"mute_time_intervals": undefined,
"object_matchers": [
[
"hello",
"=",
"world",
],
[
"foo",
"!=",
"bar",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": [
{
"active_time_intervals": undefined,
"continue": false,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"id": "2",
"matchers": [
"bar=baz",
"mute_time_intervals": undefined,
"object_matchers": [
[
"bar",
"=",
"baz",
],
],
"receiver": "simple-receiver",
"repeat_interval": undefined,
"routes": undefined,
},
],

View File

@ -1,6 +1,6 @@
import { RelativeTimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import {
AlertQuery,
@ -33,11 +33,9 @@ import {
} from './prometheus';
import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
export type ResponseLabels = {
labels: AlertInstances[];
};
export type PreviewResponse = ResponseLabels[];
export type PreviewResponse = Array<
Pick<AlertmanagerAlert, 'annotations' | 'endsAt' | 'startsAt' | 'generatorURL' | 'labels'>
>;
export interface Datasource {
type: string;
@ -83,8 +81,6 @@ export interface Rule {
annotations: Annotations;
}
export type AlertInstances = Record<string, string>;
interface ExportRulesParams {
format: ExportFormats;
folderUid?: string;

View File

@ -1,6 +1,7 @@
import { difference, groupBy, take, trim, upperFirst } from 'lodash';
import { ReactNode } from 'react';
import { computeInheritedTree } from '@grafana/alerting/unstable';
import { t } from '@grafana/i18n';
import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/features/alerting/unified/types/alerting';
import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
@ -14,8 +15,8 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { OnCallIntegrationDTO } from '../../api/onCallApi';
import { computeInheritedTree } from '../../utils/notification-policies';
import { extractReceivers } from '../../utils/receivers';
import { routeAdapter } from '../../utils/routeAdapter';
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
import { ReceiverPluginMetadata, getOnCallMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
@ -132,8 +133,10 @@ export function enhanceContactPointsWithMetadata({
alertmanagerConfiguration,
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
// compute the entire inherited tree before finding what notification policies are using a particular contact point
const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {});
const usedContactPoints = getUsedContactPoints(fullyInheritedTree);
const fullyInheritedTree = computeInheritedTree(
routeAdapter.toPackage(alertmanagerConfiguration?.alertmanager_config?.route ?? {})
);
const usedContactPoints = getUsedContactPoints(routeAdapter.fromPackage(fullyInheritedTree));
const usedContactPointsByName = groupBy(usedContactPoints, 'receiver');
const enhanced = contactPoints.map((contactPoint) => {

View File

@ -6,7 +6,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { mockExportApi, setupMswServer } from '../../mockApi';
import { mockDataSource } from '../../mocks';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { grafanaRulerRule, mockPreviewApiResponse } from '../../mocks/grafanaRulerApi';
import { setupDataSources } from '../../testSetup/datasources';
import GrafanaModifyExport from './GrafanaModifyExport';
@ -59,6 +59,10 @@ function renderModifyExport(ruleId: string) {
const server = setupMswServer();
beforeEach(() => {
mockPreviewApiResponse(server, []);
});
describe('GrafanaModifyExport', () => {
setupDataSources(dataSources.default);

View File

@ -55,7 +55,7 @@ interface MatcherBadgeProps {
formatter?: MatcherFormatter;
}
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
export const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
const styles = useStyles2(getStyles);
return (

View File

@ -2,6 +2,7 @@ import { defaults } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { computeInheritedTree } from '@grafana/alerting/unstable';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Stack } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
@ -10,12 +11,12 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies';
import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { anyOfRequestState, isError } from '../../hooks/useAsync';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
import { routeAdapter } from '../../utils/routeAdapter';
import { alertmanagerApi } from './../../api/alertmanagerApi';
import { useGetContactPointsState } from './../../api/receiversApi';
@ -297,7 +298,10 @@ export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: Route
const matchedRoutes: RouteWithID[][] = [];
// compute fully inherited tree so all policies have their inherited receiver
const fullRoute = computeInheritedTree(rootRoute);
const adaptedRootRoute = routeAdapter.toPackage(rootRoute);
const adaptedFullTree = computeInheritedTree(adaptedRootRoute);
const fullRoute = routeAdapter.fromPackage(adaptedFullTree);
// find all routes for our contact point filter
const matchingRoutesForContactPoint = contactPointFilter

View File

@ -5,7 +5,8 @@ import * as React from 'react';
import { FC, Fragment, ReactNode, useState } from 'react';
import { useToggle } from 'react-use';
import { AlertLabel } from '@grafana/alerting/unstable';
import { InheritableProperties } from '@grafana/alerting/internal';
import { AlertLabel, getInheritedProperties } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
@ -39,7 +40,7 @@ import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility }
import { getAmMatcherFormatter } from '../../utils/alertmanager';
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createContactPointSearchLink, createMuteTimingLink } from '../../utils/misc';
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { routeAdapter } from '../../utils/routeAdapter';
import { InsertPosition } from '../../utils/routeTree';
import { Authorize } from '../Authorize';
import { PopupCard } from '../HoverCard';
@ -59,7 +60,7 @@ interface PolicyComponentProps {
contactPointsState?: ReceiversState;
readOnly?: boolean;
provisioned?: boolean;
inheritedProperties?: Partial<InheritableProperties>;
inheritedProperties?: InheritableProperties;
routesMatchingFilters?: RoutesMatchingFilters;
matchingInstancesPreview?: {
@ -346,7 +347,11 @@ const Policy = (props: PolicyComponentProps) => {
{showPolicyChildren && (
<>
{pageOfChildren.map((child) => {
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
const childInheritedProperties = getInheritedProperties(
routeAdapter.toPackage(currentRoute),
routeAdapter.toPackage(child),
inheritedProperties
);
// This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy.
const isThisChildAutoGenerated = isAutoGeneratedRoot(child) || isAutoGenerated;
/* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable,
@ -685,7 +690,7 @@ const AllMatchesIndicator: FC = () => {
);
};
function DefaultPolicyIndicator() {
export function DefaultPolicyIndicator() {
const styles = useStyles2(getStyles);
return (
<>

View File

@ -11,6 +11,8 @@ exports[`createKubernetesRoutingTreeSpec 1`] = `
"group_by": [
"alertname",
],
"group_interval": undefined,
"group_wait": undefined,
"receiver": "default-receiver",
"repeat_interval": "4h",
},

View File

@ -1,6 +1,7 @@
import { pick } from 'lodash';
import memoize from 'micro-memoize';
import { INHERITABLE_KEYS, type InheritableProperties } from '@grafana/alerting/internal';
import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks';
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
@ -23,7 +24,7 @@ import { FormAmRoute } from '../../types/amroutes';
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils';
import { INHERITABLE_KEYS, InheritableProperties } from '../../utils/notification-policies';
import { routeAdapter } from '../../utils/routeAdapter';
import {
InsertPosition,
addRouteToReferenceRoute,
@ -279,7 +280,7 @@ export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApis
export function createKubernetesRoutingTreeSpec(
rootRoute: Route
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
const inheritableDefaultProperties: InheritableProperties = pick(rootRoute, INHERITABLE_KEYS);
const inheritableDefaultProperties: InheritableProperties = pick(routeAdapter.toPackage(rootRoute), INHERITABLE_KEYS);
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
...inheritableDefaultProperties,

View File

@ -20,7 +20,7 @@ import { MANUAL_ROUTING_KEY, SIMPLIFIED_QUERY_EDITOR_KEY } from 'app/features/al
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types/accessControl';
import { grafanaRulerGroup } from '../../../../mocks/grafanaRulerApi';
import { grafanaRulerGroup, mockPreviewApiResponse } from '../../../../mocks/grafanaRulerApi';
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: ReactNode }) => <div>{actions}</div>,
@ -63,6 +63,8 @@ const selectContactPoint = async (contactPointName: string) => {
await clickSelectOption(contactPointInput, contactPointName);
};
const server = setupMswServer();
// combobox hack
beforeEach(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
@ -77,9 +79,10 @@ beforeEach(() => {
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
mockPreviewApiResponse(server, []);
});
setupMswServer();
setupDataSources(dataSources.default, dataSources.am);
// Setup plugin extensions hook to prevent setPluginLinksHook errors

View File

@ -0,0 +1,22 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Box, useStyles2 } from '@grafana/ui';
export function ConnectionLine() {
const styles = useStyles2(getStyles);
return (
<Box display="flex" justifyContent="center" alignItems="center" height={4}>
<div className={styles.line} />
</Box>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
line: css({
width: '1px',
height: '100%',
background: theme.colors.border.medium,
}),
});

View File

@ -0,0 +1,158 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { useToggle } from 'react-use';
import { alertingAPI, getContactPointDescription } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { stringifyFieldSelector } from '../../../utils/k8s/utils';
import { createContactPointLink } from '../../../utils/misc';
import { CollapseToggle } from '../../CollapseToggle';
import { MetaText } from '../../MetaText';
import { ContactPointLink } from '../../rule-viewer/ContactPointLink';
import UnknownContactPointDetails from './UnknownContactPointDetails';
interface ContactPointGroupProps extends PropsWithChildren {
name: string;
matchedInstancesCount: number;
}
export function GrafanaContactPointGroup({ name, matchedInstancesCount, children }: ContactPointGroupProps) {
// find receiver by name since this is what we store in the alert rule definition
const { data, isLoading } = alertingAPI.endpoints.listReceiver.useQuery({
fieldSelector: stringifyFieldSelector([['spec.title', name]]),
});
// grab the first result from the fieldSelector result
const contactPoint = data?.items.at(0);
return (
<ContactPointGroup
isLoading={isLoading}
matchedInstancesCount={matchedInstancesCount}
name={
contactPoint ? (
<ContactPointLink name={name} external color="primary" variant="bodySmall" />
) : (
<UnknownContactPointDetails receiverName={name ?? 'unknown'} />
)
}
description={contactPoint ? getContactPointDescription(contactPoint) : null}
>
{children}
</ContactPointGroup>
);
}
export function ExternalContactPointGroup({
name,
alertmanagerSourceName,
matchedInstancesCount,
children,
}: ContactPointGroupProps & { alertmanagerSourceName: string }) {
const link = (
<TextLink
color="primary"
variant="bodySmall"
external
inline={false}
href={createContactPointLink(name, alertmanagerSourceName)}
>
{name}
</TextLink>
);
return (
<ContactPointGroup name={link} matchedInstancesCount={matchedInstancesCount}>
{children}
</ContactPointGroup>
);
}
interface ContactPointGroupInnerProps extends Omit<ContactPointGroupProps, 'name'> {
name: NonNullable<ReactNode>;
description?: ReactNode;
isLoading?: boolean;
children: ReactNode;
}
export function ContactPointGroup({
name,
description,
matchedInstancesCount,
isLoading = false,
children,
}: ContactPointGroupInnerProps) {
const styles = useStyles2(getStyles);
const [isExpanded, toggleExpanded] = useToggle(false);
return (
<Stack direction="column" role="list" data-testid="matched-contactpoint-group">
<div className={styles.contactPointRow}>
<Stack direction="row" alignItems="center">
<CollapseToggle
isCollapsed={!isExpanded}
onToggle={() => toggleExpanded()}
aria-label={t('alerting.notification-route-header.aria-label-expand-policy-route', 'Expand policy route')}
/>
{isLoading && loader}
{!isLoading && (
<>
{name && (
<>
<MetaText icon="at">
<Trans i18nKey="alerting.notification-route-header.delivered-to">Delivered to</Trans> {name}
</MetaText>
{description && (
<Text variant="bodySmall" color="secondary">
{description}
</Text>
)}
</>
)}
{matchedInstancesCount && (
<>
<Text color="secondary" variant="bodySmall">
|
</Text>
<MetaText icon="layers-alt">
{/* @TODO pluralization */}
{matchedInstancesCount}{' '}
<Trans i18nKey="alerting.notification-route-header.instances">instances</Trans>
</MetaText>
</>
)}
</>
)}
</Stack>
</div>
{isExpanded && <div className={styles.notificationPolicies}>{children}</div>}
</Stack>
);
}
const loader = (
<Stack direction="row" gap={1}>
<Skeleton height={16} width={128} />
<Skeleton height={16} width={64} />
</Stack>
);
const getStyles = (theme: GrafanaTheme2) => ({
contactPointRow: css({
padding: theme.spacing(0.5),
':hover': {
background: theme.components.table.rowHoverBackground,
},
}),
notificationPolicies: css({
marginLeft: theme.spacing(2),
borderLeftStyle: 'solid',
borderLeftWidth: 1,
borderLeftColor: theme.colors.border.weak,
}),
});

View File

@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import { LabelMatcher, RouteWithID } from '@grafana/alerting/unstable';
import { JourneyPolicyCard } from './JourneyPolicyCard';
describe('JourneyPolicyCard', () => {
const mockMatchers: LabelMatcher[] = [
{ label: 'severity', type: '=', value: 'critical' },
{ label: 'team', type: '=', value: 'backend' },
];
const mockRoute: RouteWithID = {
id: 'test-route',
receiver: 'test-receiver',
group_by: ['alertname', 'severity'],
matchers: mockMatchers,
routes: [],
continue: false,
};
it('should render basic route information', () => {
render(<JourneyPolicyCard route={mockRoute} />);
expect(screen.getByText('test-receiver')).toBeInTheDocument();
expect(screen.getByText('alertname, severity')).toBeInTheDocument();
expect(screen.getByTestId('label-matchers')).toBeInTheDocument();
});
it('should render "No matchers" when route has no matchers', () => {
const routeWithoutMatchers: RouteWithID = {
id: 'test-route',
receiver: 'test-receiver',
matchers: [],
routes: [],
continue: false,
};
render(<JourneyPolicyCard route={routeWithoutMatchers} />);
expect(screen.getByText('No matchers')).toBeInTheDocument();
expect(screen.queryByTestId('label-matchers')).not.toBeInTheDocument();
});
it('should show continue matching indicator when route.continue is true', () => {
const routeWithContinue: RouteWithID = {
...mockRoute,
continue: true,
};
render(<JourneyPolicyCard route={routeWithContinue} />);
expect(screen.getByTestId('continue-matching')).toBeInTheDocument();
});
it('should not show continue matching indicator when route.continue is false or undefined', () => {
render(<JourneyPolicyCard route={mockRoute} />);
expect(screen.queryByTestId('continue-matching')).not.toBeInTheDocument();
});
it('should not render receiver or group_by when they are not provided', () => {
const minimalRoute: RouteWithID = {
id: 'test-route',
routes: [],
continue: false,
};
render(<JourneyPolicyCard route={minimalRoute} />);
expect(screen.queryByText(/test-receiver/)).not.toBeInTheDocument();
expect(screen.queryByText(/alertname/)).not.toBeInTheDocument();
});
it('should show DefaultPolicyIndicator when isRoot is true', () => {
render(<JourneyPolicyCard route={mockRoute} isRoot={true} />);
expect(screen.getByRole('heading', { name: 'Default policy' })).toBeInTheDocument();
});
describe('isFinalRoute prop', () => {
it('should set aria-current="true" and aria-label when isFinalRoute is true', () => {
render(<JourneyPolicyCard route={mockRoute} isFinalRoute={true} />);
const card = screen.getByRole('article', { current: true });
expect(card).toBeInTheDocument();
});
it('should not set aria-current when isFinalRoute is false', () => {
render(<JourneyPolicyCard route={mockRoute} isFinalRoute={false} />);
const card = screen.getByRole('article', { current: false });
expect(card).toBeInTheDocument();
});
it('should not set aria-current when isFinalRoute is undefined (default)', () => {
render(<JourneyPolicyCard route={mockRoute} />);
const card = screen.getByRole('article', { current: false });
expect(card).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,112 @@
import { css } from '@emotion/css';
import { RouteWithID } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Icon, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
import { labelMatcherToObjectMatcher } from '../../../utils/routeAdapter';
import { Matchers } from '../../notification-policies/Matchers';
import { DefaultPolicyIndicator } from '../../notification-policies/Policy';
interface JourneyPolicyCardProps {
route: RouteWithID;
isRoot?: boolean;
isFinalRoute?: boolean;
}
export function JourneyPolicyCard({ route, isRoot = false, isFinalRoute = false }: JourneyPolicyCardProps) {
const styles = useStyles2(getStyles);
// Convert route matchers to ObjectMatcher format
const matchers: ObjectMatcher[] = route.matchers?.map(labelMatcherToObjectMatcher) ?? [];
const hasMatchers = matchers.length > 0;
const continueMatching = route.continue ?? false;
return (
<article className={styles.policyWrapper(isFinalRoute)} aria-current={isFinalRoute ? 'true' : 'false'}>
{continueMatching && <ContinueMatchingIndicator />}
<Stack direction="column" gap={0.5}>
{/* root route indicator */}
{isRoot && <DefaultPolicyIndicator />}
{/* Matchers */}
{hasMatchers ? (
<Matchers matchers={matchers} formatter={undefined} />
) : (
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.policies.no-matchers">No matchers</Trans>
</Text>
)}
{/* Route metadata */}
<Stack direction="row" alignItems="center" gap={1}>
{route.receiver && (
<Text variant="bodySmall" color="secondary">
<Icon name="at" size="xs" /> {route.receiver}
</Text>
)}
{route.group_by && route.group_by.length > 0 && (
<Text variant="bodySmall" color="secondary">
<Icon name="layer-group" size="xs" /> {route.group_by.join(', ')}
</Text>
)}
</Stack>
</Stack>
</article>
);
}
const ContinueMatchingIndicator = () => {
const styles = useStyles2(getStyles);
return (
<Tooltip
placement="top"
content={
<Trans i18nKey="alerting.continue-matching-indicator.content-route-continue-matching-other-policies">
This route will continue matching other policies
</Trans>
}
>
<div className={styles.gutterIcon} data-testid="continue-matching">
<Icon name="arrow-down" />
</div>
</Tooltip>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
policyWrapper: (hasFocus = false) =>
css({
position: 'relative',
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
border: `solid 1px ${theme.colors.border.weak}`,
...(hasFocus && {
borderColor: theme.colors.primary.border,
background: theme.colors.primary.transparent,
}),
padding: theme.spacing(1),
}),
gutterIcon: css({
position: 'absolute',
left: `-${theme.spacing(3.5)}`,
top: theme.spacing(2.25),
color: theme.colors.text.secondary,
background: theme.colors.background.primary,
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});

View File

@ -0,0 +1,56 @@
import { css } from '@emotion/css';
import { AlertLabel, LabelMatchDetails } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Box, Text, useStyles2 } from '@grafana/ui';
import { labelMatcherToObjectMatcher } from '../../../utils/routeAdapter';
import { MatcherBadge } from '../../notification-policies/Matchers';
interface MatchDetailsProps {
matchDetails: LabelMatchDetails[];
labels: Array<[string, string]>;
}
export function MatchDetails({ matchDetails, labels }: MatchDetailsProps) {
const styles = useStyles2(getStyles);
const matchingLabels = matchDetails.filter((detail) => detail.match);
const noMatchingLabels = matchingLabels.length === 0;
return (
<div className={styles.container}>
{noMatchingLabels ? (
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.match-details.no-matchers-matched">Policy matches all labels</Trans>
</Text>
) : (
matchingLabels.map((detail) => (
<Box key={detail.labelIndex} display="flex" alignItems="center" gap={1}>
<AlertLabel labelKey={labels[detail.labelIndex][0]} value={labels[detail.labelIndex][1]} />
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.match-details.matched">matched</Trans>
</Text>
{detail.matcher && <MatcherBadge matcher={labelMatcherToObjectMatcher(detail.matcher)} />}
</Box>
))
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.pill,
border: `solid 1px ${theme.colors.border.weak}`,
width: 'fit-content',
alignSelf: 'center',
}),
});

View File

@ -0,0 +1,110 @@
import { Fragment, useState } from 'react';
import { AlertLabel, RouteMatchResult, RouteWithID } from '@grafana/alerting/unstable';
import { Trans } from '@grafana/i18n';
import { Button, Drawer, Text, TextLink } from '@grafana/ui';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { createRelativeUrl } from '../../../utils/url';
import { ConnectionLine } from './ConnectionLine';
import { JourneyPolicyCard } from './JourneyPolicyCard';
import { MatchDetails } from './MatchDetails';
type NotificationPolicyDrawerProps = {
policyName?: string;
matchedRootRoute: boolean;
journey: RouteMatchResult<RouteWithID>['matchingJourney'];
labels: Array<[string, string]>;
};
export function NotificationPolicyDrawer({
policyName,
matchedRootRoute,
journey,
labels,
}: NotificationPolicyDrawerProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const handleOpenDrawer = () => {
setIsDrawerOpen(true);
};
const handleCloseDrawer = () => {
setIsDrawerOpen(false);
};
// Process the journey data to extract the information we need
const finalRouteMatchInfo = journey.at(-1);
const nonMatchingLabels = finalRouteMatchInfo?.matchDetails.filter((detail) => !detail.match) ?? [];
return (
<>
<Button fill="outline" variant="secondary" size="sm" onClick={handleOpenDrawer}>
<Trans i18nKey="alerting.instance-match.notification-policy">View route</Trans>
</Button>
{isDrawerOpen && (
<Drawer
size="md"
title={
<>
<Trans i18nKey="alerting.notification-route.notification-policy">Notification policy</Trans>
{policyName && (
<Text color="secondary" variant="bodySmall">
{' '}
{policyName}
</Text>
)}
</>
}
onClose={handleCloseDrawer}
>
<Stack direction="column" gap={2}>
<Stack direction="column" gap={2} alignItems="center">
<Stack direction="column" gap={0}>
{journey.map((routeInfo, index) => (
<Fragment key={index}>
{index > 0 && (
<>
<ConnectionLine />
<MatchDetails matchDetails={routeInfo.matchDetails} labels={labels} />
<ConnectionLine />
</>
)}
<JourneyPolicyCard
route={routeInfo.route}
isRoot={index === 0}
isFinalRoute={index === journey.length - 1}
/>
</Fragment>
))}
</Stack>
{nonMatchingLabels.length > 0 && (
<Stack direction="column">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.instance-match.non-matching-labels">Non-matching labels</Trans>
</Text>
<Stack direction="row" gap={0.5}>
{nonMatchingLabels.map((detail) => (
<Text key={detail.labelIndex} color="secondary" variant="bodySmall">
<AlertLabel labelKey={labels[detail.labelIndex][0]} value={labels[detail.labelIndex][1]} />
</Text>
))}
</Stack>
</Stack>
)}
</Stack>
<TextLink href={createRelativeUrl('/alerting/routes')} external inline={false}>
<Trans i18nKey="alerting.notification-policy-drawer.view-notification-policy-tree">
View notification policy tree
</Trans>
</TextLink>
</Stack>
</Drawer>
)}
</>
);
}

View File

@ -1,45 +1,23 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { Text } from '@grafana/ui';
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
import { MatcherFormatter } from '../../../utils/matchers';
import { Matchers } from '../../notification-policies/Matchers';
import { RouteWithPath, hasEmptyMatchers, isDefaultPolicy } from './route';
interface Props {
route: RouteWithPath;
matchers?: ObjectMatcher[];
matcherFormatter: MatcherFormatter;
}
export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) {
const styles = useStyles2(getStyles);
if (isDefaultPolicy(route)) {
export function NotificationPolicyMatchers({ matchers = [], matcherFormatter }: Props) {
if (matchers.length === 0) {
return (
<div className={styles.defaultPolicy}>
<Trans i18nKey="alerting.notification-policy-matchers.default-policy">Default policy</Trans>
</div>
);
} else if (hasEmptyMatchers(route)) {
return (
<div className={styles.textMuted}>
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.notification-policy-matchers.no-matchers">No matchers</Trans>
</div>
</Text>
);
} else {
return <Matchers matchers={route.object_matchers ?? []} formatter={matcherFormatter} />;
return <Matchers matchers={matchers} formatter={matcherFormatter} />;
}
}
const getStyles = (theme: GrafanaTheme2) => ({
defaultPolicy: css({
padding: theme.spacing(0.5),
background: theme.colors.background.secondary,
width: 'fit-content',
}),
textMuted: css({
color: theme.colors.text.secondary,
}),
});

View File

@ -1,13 +1,12 @@
import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { render, waitFor, within } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
import { AccessControlAction } from 'app/types/accessControl';
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { getMockConfig, setupMswServer } from '../../../mockApi';
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
import { grantUserPermissions, mockAlertQuery, mockAlertmanagerAlert } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
import { Folder } from '../../../types/rule-form';
import * as dataSource from '../../../utils/datasource';
@ -18,7 +17,6 @@ import {
} from '../../../utils/datasource';
import { NotificationPreview } from './NotificationPreview';
import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager';
jest.mock('../../../useRouteGroupsMatcher');
@ -34,18 +32,14 @@ const getAlertManagerDataSourcesByPermissionAndConfigMock =
>;
const ui = {
route: byTestId('matching-policy-route'),
routeButton: byRole('button', { name: /Expand policy route/ }),
routeMatchingInstances: byTestId('route-matching-instance'),
loadingIndicator: byText(/Loading routing preview/i),
previewButton: byRole('button', { name: /preview routing/i }),
contactPointGroup: byRole('list'),
grafanaAlertManagerLabel: byText(/alertmanager:grafana/i),
otherAlertManagerLabel: byText(/alertmanager:other_am/i),
seeDetails: byText(/see details/i),
expandButton: byRole('button', { name: 'Expand policy route' }),
seeDetails: byRole('button', { name: 'View route' }),
details: {
title: byRole('heading', { name: /routing details/i }),
modal: byRole('dialog'),
linkToContactPoint: byRole('link', { name: /see details/i }),
drawer: byRole('dialog'),
linkToPolicyTree: byRole('link', { name: /view notification policy tree/i }),
},
};
@ -118,360 +112,91 @@ describe('NotificationPreview', () => {
it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => {
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockPreviewApiResponse(server, [
mockAlertmanagerAlert({
labels: { tomato: 'red', avocate: 'green' },
}),
]);
const { user } = render(
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
await user.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
// wait for loading to finish
await waitFor(async () => {
const matchingContactPoint = await ui.contactPointGroup.findAll();
expect(matchingContactPoint).toHaveLength(1);
});
// we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts
await waitFor(() => {
expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
});
expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
const matchingPoliciesElements = ui.route.queryAll;
await waitFor(() => {
expect(matchingPoliciesElements()).toHaveLength(1);
});
expect(matchingPoliciesElements()[0]).toHaveTextContent(/tomato = red/);
const matchingContactPoint = await ui.contactPointGroup.findAll();
expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/);
expect(matchingContactPoint[0]).toHaveTextContent(/1 instance/);
});
it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => {
// two alert managers configured to receive alerts
mockTwoAlertManagers();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockPreviewApiResponse(server, [
mockAlertmanagerAlert({
labels: { tomato: 'red', avocate: 'green' },
}),
]);
const { user } = render(
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
await user.click(await ui.previewButton.find());
// wait for loading to finish
await waitFor(async () => {
const matchingContactPoint = await ui.contactPointGroup.findAll();
expect(matchingContactPoint).toHaveLength(2);
});
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
expect(await ui.grafanaAlertManagerLabel.find()).toBeInTheDocument();
expect(await ui.otherAlertManagerLabel.find()).toBeInTheDocument();
const matchingPoliciesElements = await ui.route.findAll();
const matchingContactPoint = await ui.contactPointGroup.findAll();
expect(matchingPoliciesElements).toHaveLength(2);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/);
expect(matchingContactPoint).toHaveLength(2);
expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/);
expect(matchingContactPoint[0]).toHaveTextContent(/1 instance/);
expect(matchingContactPoint[1]).toHaveTextContent(/Delivered to slack/);
expect(matchingContactPoint[1]).toHaveTextContent(/1 instance/);
});
it('should render details modal when clicking see details button', async () => {
// two alert managers configured to receive alerts
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
it('should render details when clicking see details button', async () => {
mockHasEditPermission(true);
const { user } = render(
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await user.click(ui.previewButton.get());
await user.click(await ui.seeDetails.find());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers');
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument();
expect(ui.details.linkToContactPoint.get()).toBeInTheDocument();
});
it('should not render contact point link in details modal if user has no permissions for editing contact points', async () => {
// two alert managers configured to receive alerts
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(false);
mockPreviewApiResponse(server, [
mockAlertmanagerAlert({
labels: { tomato: 'red', avocate: 'green' },
}),
]);
const { user } = render(
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
// wait for loading to finish
await waitFor(async () => {
const matchingContactPoint = await ui.contactPointGroup.findAll();
expect(matchingContactPoint).toHaveLength(1);
});
await user.click(ui.previewButton.get());
// expand the matching contact point to show instances
await user.click(await ui.expandButton.find());
// click "view route"
await user.click(await ui.seeDetails.find());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers');
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument();
expect(ui.details.linkToContactPoint.query()).not.toBeInTheDocument();
});
});
describe('NotificationPreviewByAlertmanager', () => {
it('should render route matching preview for alertmanager', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical'))
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
const { user } = render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (no receiver) ', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => {
rb.addRoute((rb) => rb.withoutReceiver().addMatcher('foo', MatcherOperator.equal, 'bar'));
return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical');
})
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
const { user } = render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (empty string receiver)', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => {
rb.addRoute((rb) => rb.withEmptyReceiver().addMatcher('foo', MatcherOperator.equal, 'bar'));
return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical');
})
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
const { user } = render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
describe('regex matching', () => {
it('does not match regex in middle of the word as alertmanager will anchor when queried via API', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'foobarfoo' }];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'bar'))
)
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
expect(await screen.findByText(/default policy/i)).toBeInTheDocument();
expect(screen.queryByText(/regexfield/)).not.toBeInTheDocument();
});
it('matches regex at the start of the word', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'ba.*h'))
)
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
expect(await screen.findByText(/regexfield/i)).toBeInTheDocument();
});
it('handles negated regex correctly', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'thing' }];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.notRegex, 'thing'))
)
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
expect(await screen.findByText(/default policy/i)).toBeInTheDocument();
expect(screen.queryByText(/regexfield/i)).not.toBeInTheDocument();
});
});
it('matches regex with flags', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, '(?i)BA.*h'))
)
);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
expect(await screen.findByText(/regexfield/i)).toBeInTheDocument();
// grab drawer and assert within
const drawer = ui.details.drawer.getAll()[0];
expect(drawer).toBeInTheDocument();
// assert within the drawer
expect(within(drawer).getByRole('heading', { name: 'Default policy' })).toBeInTheDocument();
expect(within(drawer).getByText(/non-matching labels/i)).toBeInTheDocument();
expect(ui.details.linkToPolicyTree.get()).toBeInTheDocument();
});
});

View File

@ -1,15 +1,18 @@
import { compact } from 'lodash';
import { Suspense, lazy } from 'react';
import { css } from '@emotion/css';
import { Fragment, Suspense, lazy } from 'react';
import { useEffectOnce } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, LoadingPlaceholder, Stack, Text } from '@grafana/ui';
import { Button, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertQuery, Labels } from 'app/types/unified-alerting-dto';
import { Folder, KBObjectArray } from '../../../types/rule-form';
import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
const NotificationPreviewForGrafanaManaged = lazy(() => import('./NotificationPreviewGrafanaManaged'));
interface NotificationPreviewProps {
customLabels: KBObjectArray;
@ -20,6 +23,8 @@ interface NotificationPreviewProps {
alertUid?: string;
}
const { preview } = alertRuleApi.endpoints;
// TODO the scroll position keeps resetting when we preview
// this is to be expected because the list of routes dissapears as we start the request but is very annoying
export const NotificationPreview = ({
@ -30,15 +35,20 @@ export const NotificationPreview = ({
alertName,
alertUid,
}: NotificationPreviewProps) => {
const styles = useStyles2(getStyles);
const disabled = !condition || !folder;
const previewEndpoint = alertRuleApi.endpoints.preview;
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = previewEndpoint.useMutation();
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = preview.useMutation();
// potential instances are the instances that are going to be routed to the notification policies
// convert data to list of labels: are the representation of the potential instances
const potentialInstances = compact(data.flatMap((label) => label?.labels));
const potentialInstances = data.reduce<Labels[]>((acc = [], instance) => {
if (instance.labels) {
acc.push(instance.labels);
}
return acc;
}, []);
const onPreview = () => {
if (!folder || !condition) {
@ -56,10 +66,13 @@ export const NotificationPreview = ({
});
};
useEffectOnce(() => {
onPreview();
});
// Get alert managers's data source information
const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification');
const onlyOneAM = alertManagerDataSources.length === 1;
const singleAlertManagerConfigured = alertManagerDataSources.length === 1;
return (
<Stack direction="column">
@ -93,22 +106,63 @@ export const NotificationPreview = ({
<Trans i18nKey="alerting.notification-preview.preview-routing">Preview routing</Trans>
</Button>
</Stack>
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
{potentialInstances.length > 0 && (
<Suspense
fallback={
<LoadingPlaceholder text={t('alerting.notification-preview.text-loading-preview', 'Loading preview...')} />
}
>
{alertManagerDataSources.map((alertManagerSource) => (
<NotificationPreviewByAlertManager
alertManagerSource={alertManagerSource}
potentialInstances={potentialInstances}
onlyOneAM={onlyOneAM}
key={alertManagerSource.name}
/>
<Fragment key={alertManagerSource.name}>
{!singleAlertManagerConfigured && (
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine} />
<div className={styles.alertManagerName}>
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
{alertManagerSource.name}
</div>
<div className={styles.secondAlertManagerLine} />
</Stack>
)}
{alertManagerSource.name === 'grafana' ? (
<NotificationPreviewForGrafanaManaged
alertManagerSource={alertManagerSource}
instances={potentialInstances}
/>
) : (
<NotificationPreviewByAlertManager
alertManagerSource={alertManagerSource}
instances={potentialInstances}
/>
)}
</Fragment>
))}
</Suspense>
)}
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
firstAlertManagerLine: css({
height: '1px',
width: theme.spacing(4),
backgroundColor: theme.colors.secondary.main,
}),
alertManagerName: css({
width: 'fit-content',
}),
secondAlertManagerLine: css({
height: '1px',
width: '100%',
flex: 1,
backgroundColor: theme.colors.secondary.main,
}),
img: css({
marginLeft: theme.spacing(2),
width: theme.spacing(3),
height: theme.spacing(3),
marginRight: theme.spacing(1),
}),
});

View File

@ -1,31 +1,29 @@
import { css } from '@emotion/css';
import { groupBy } from 'lodash';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { t } from '@grafana/i18n';
import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { AlertManagerDataSource } from '../../../utils/datasource';
import { NotificationRoute } from './NotificationRoute';
import { ExternalContactPointGroup } from './ContactPointGroup';
import { InstanceMatch } from './NotificationRoute';
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
const UNKNOWN_RECEIVER = 'unknown';
function NotificationPreviewByAlertManager({
alertManagerSource,
potentialInstances,
onlyOneAM,
instances,
}: {
alertManagerSource: AlertManagerDataSource;
potentialInstances: Labels[];
onlyOneAM: boolean;
instances: Labels[];
}) {
const styles = useStyles2(getStyles);
const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview(
const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview(
alertManagerSource.name,
potentialInstances
instances
);
if (error) {
@ -39,7 +37,7 @@ function NotificationPreviewByAlertManager({
);
}
if (loading) {
if (isLoading) {
return (
<LoadingPlaceholder
text={t(
@ -50,76 +48,48 @@ function NotificationPreviewByAlertManager({
);
}
const matchingPoliciesFound = matchingMap.size > 0;
const matchingPoliciesFound = treeMatchingResults.some((result) => result.matchedRoutes.length > 0);
// Group results by receiver name
// We need to flatten the structure first to group by receiver
const flattenedResults = treeMatchingResults.flatMap(({ labels, matchedRoutes }) => {
return Array.from(matchedRoutes).map(({ route, routeTree, matchDetails }) => ({
labels,
receiver: route.receiver || UNKNOWN_RECEIVER,
routeTree,
matchDetails,
}));
});
const contactPointGroups = groupBy(flattenedResults, 'receiver');
return matchingPoliciesFound ? (
<div className={styles.alertManagerRow}>
{!onlyOneAM && (
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine} />
<div className={styles.alertManagerName}>
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
{alertManagerSource.name}
</div>
<div className={styles.secondAlertManagerLine} />
</Stack>
)}
<Stack gap={1} direction="column">
{Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => {
const route = routesByIdMap.get(routeId);
const receiver = route?.receiver && receiversByName.get(route.receiver);
if (!route) {
return null;
}
return (
<NotificationRoute
instanceMatches={instanceMatches}
route={route}
// If we can't find a receiver, it might just be because the user doesn't have access
receiver={receiver ? receiver : undefined}
receiverNameFromRoute={route?.receiver ? route.receiver : undefined}
key={routeId}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSource.name}
/>
);
})}
<Box display="flex" direction="column" gap={1} width="100%">
<Stack direction="column" gap={0}>
{Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => (
<ExternalContactPointGroup
key={receiver}
name={receiver}
matchedInstancesCount={resultsForReceiver.length}
alertmanagerSourceName={alertManagerSource.name}
>
<Stack direction="column" gap={0}>
{resultsForReceiver.map(({ routeTree, matchDetails }) => (
<InstanceMatch
key={matchDetails.labels.join(',')}
matchedInstance={matchDetails}
policyTreeSpec={routeTree.expandedSpec}
policyTreeMetadata={routeTree.metadata}
/>
))}
</Stack>
</ExternalContactPointGroup>
))}
</Stack>
</div>
</Box>
) : null;
}
// export default because we want to load the component dynamically using React.lazy
// Due to loading of the web worker we don't want to load this component when not necessary
export default withErrorBoundary(NotificationPreviewByAlertManager);
const getStyles = (theme: GrafanaTheme2) => ({
alertManagerRow: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
width: '100%',
}),
firstAlertManagerLine: css({
height: '1px',
width: theme.spacing(4),
backgroundColor: theme.colors.secondary.main,
}),
alertManagerName: css({
width: 'fit-content',
}),
secondAlertManagerLine: css({
height: '1px',
width: '100%',
flex: 1,
backgroundColor: theme.colors.secondary.main,
}),
img: css({
marginLeft: theme.spacing(2),
width: theme.spacing(3),
height: theme.spacing(3),
marginRight: theme.spacing(1),
}),
});

View File

@ -0,0 +1,90 @@
import { groupBy } from 'lodash';
import { t } from '@grafana/i18n';
import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { AlertManagerDataSource } from '../../../utils/datasource';
import { GrafanaContactPointGroup } from './ContactPointGroup';
import { InstanceMatch } from './NotificationRoute';
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
const UNKNOWN_RECEIVER = 'unknown';
function NotificationPreviewGrafanaManaged({
alertManagerSource,
instances,
}: {
alertManagerSource: AlertManagerDataSource;
instances: Labels[];
}) {
const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview(
alertManagerSource.name,
instances
);
if (error) {
const title = t('alerting.notification-preview.error', 'Could not load routing preview for {{alertmanager}}', {
alertmanager: alertManagerSource.name,
});
return (
<Alert title={title} severity="error">
{stringifyErrorLike(error)}
</Alert>
);
}
if (isLoading) {
return (
<LoadingPlaceholder
text={t(
'alerting.notification-preview-by-alert-manager.text-loading-routing-preview',
'Loading routing preview...'
)}
/>
);
}
const matchingPoliciesFound = treeMatchingResults.some((result) => result.matchedRoutes.length > 0);
// Group results by receiver name
// We need to flatten the structure first to group by receiver
const flattenedResults = treeMatchingResults.flatMap(({ labels, matchedRoutes }) => {
return Array.from(matchedRoutes).map(({ route, routeTree, matchDetails }) => ({
labels,
receiver: route.receiver || UNKNOWN_RECEIVER,
routeTree,
matchDetails,
}));
});
const contactPointGroups = groupBy(flattenedResults, 'receiver');
return matchingPoliciesFound ? (
<Box display="flex" direction="column" gap={1} width="100%">
<Stack direction="column" gap={0}>
{Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => (
<GrafanaContactPointGroup key={receiver} name={receiver} matchedInstancesCount={resultsForReceiver.length}>
<Stack direction="column" gap={0}>
{resultsForReceiver.map(({ routeTree, matchDetails }) => (
<InstanceMatch
key={matchDetails.labels.join(',')}
matchedInstance={matchDetails}
policyTreeSpec={routeTree.expandedSpec}
policyTreeMetadata={routeTree.metadata}
/>
))}
</Stack>
</GrafanaContactPointGroup>
))}
</Stack>
</Box>
) : null;
}
// export default because we want to load the component dynamically using React.lazy
// Due to loading of the web worker we don't want to load this component when not necessary
export default withErrorBoundary(NotificationPreviewGrafanaManaged);

View File

@ -1,263 +1,66 @@
import { css, cx } from '@emotion/css';
import { uniqueId } from 'lodash';
import pluralize from 'pluralize';
import { useState } from 'react';
import { useToggle } from 'react-use';
import { css } from '@emotion/css';
import { AlertLabels, RouteMatchResult, RouteWithID } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, TagList, getTagColorIndexFromName, useStyles2 } from '@grafana/ui';
import { Trans } from '@grafana/i18n';
import { Text, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
import { AlertInstanceMatch } from '../../../utils/notification-policies';
import { CollapseToggle } from '../../CollapseToggle';
import { MetaText } from '../../MetaText';
import { arrayLabelsToObject } from '../../../utils/labels';
import { Spacer } from '../../Spacer';
import { NotificationPolicyMatchers } from './NotificationPolicyMatchers';
import { NotificationRouteDetailsModal } from './NotificationRouteDetailsModal';
import UnknownContactPointDetails from './UnknownContactPointDetails';
import { RouteWithPath } from './route';
import { NotificationPolicyDrawer } from './NotificationPolicyDrawer';
export interface ReceiverNameProps {
/** Receiver name taken from route definition. Used as a fallback when full receiver details cannot be found (in case of RBAC restrictions) */
receiverNameFromRoute?: string;
}
type TreeMeta = {
name?: string;
};
interface NotificationRouteHeaderProps extends ReceiverNameProps {
route: RouteWithPath;
receiver?: Receiver;
routesByIdMap: Map<string, RouteWithPath>;
instancesCount: number;
alertManagerSourceName: string;
expandRoute: boolean;
onExpandRouteClick: (expand: boolean) => void;
}
type InstanceMatchProps = {
matchedInstance: RouteMatchResult<RouteWithID>;
policyTreeSpec: RouteWithID;
policyTreeMetadata: TreeMeta;
};
function NotificationRouteHeader({
route,
receiver,
receiverNameFromRoute,
routesByIdMap,
instancesCount,
alertManagerSourceName,
expandRoute,
onExpandRouteClick,
}: NotificationRouteHeaderProps) {
export function InstanceMatch({ matchedInstance, policyTreeSpec, policyTreeMetadata }: InstanceMatchProps) {
const styles = useStyles2(getStyles);
const [showDetails, setShowDetails] = useState(false);
const onClickDetails = () => {
setShowDetails(true);
};
const { labels, matchingJourney, route } = matchedInstance;
// @TODO: re-use component ContactPointsHoverDetails from Policy once we have it for cloud AMs.
return (
<div className={styles.routeHeader}>
<CollapseToggle
isCollapsed={!expandRoute}
onToggle={(isCollapsed) => onExpandRouteClick(!isCollapsed)}
aria-label={t('alerting.notification-route-header.aria-label-expand-policy-route', 'Expand policy route')}
/>
<Stack flexGrow={1} gap={1}>
{/* TODO: fix keyboard a11y */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
<Stack gap={1} direction="row" alignItems="center">
<Trans i18nKey="alerting.notification-route-header.notification-policy">Notification policy</Trans>
<NotificationPolicyMatchers
route={route}
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
/>
</Stack>
</div>
<Spacer />
<Stack gap={2} direction="row" alignItems="center">
<MetaText icon="layers-alt" data-testid="matching-instances">
{instancesCount ?? '-'} {pluralize('instance', instancesCount)}
</MetaText>
<Stack gap={1} direction="row" alignItems="center">
<div>
<span className={styles.textMuted}>
<Trans i18nKey="alerting.notification-route-header.delivered-to">@ Delivered to</Trans>
</span>{' '}
{receiver ? receiver.name : <UnknownContactPointDetails receiverName={receiverNameFromRoute} />}
</div>
<div className={styles.verticalBar} />
<Button type="button" onClick={onClickDetails} variant="secondary" fill="outline" size="sm">
<Trans i18nKey="alerting.notification-route-header.see-details">See details</Trans>
</Button>
</Stack>
</Stack>
</Stack>
{showDetails && (
<NotificationRouteDetailsModal
onClose={() => setShowDetails(false)}
route={route}
receiver={receiver}
receiverNameFromRoute={receiverNameFromRoute}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSourceName}
/>
)}
</div>
// Get all match details from the final matched route in the journey
const finalRouteMatchInfo = matchingJourney.at(-1);
const routeMatchLabels = arrayLabelsToObject(
finalRouteMatchInfo?.matchDetails.map((detail) => labels[detail.labelIndex]) ?? []
);
}
interface NotificationRouteProps extends ReceiverNameProps {
route: RouteWithPath;
receiver?: Receiver;
instanceMatches: AlertInstanceMatch[];
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
}
export function NotificationRoute({
route,
instanceMatches,
receiver,
receiverNameFromRoute,
routesByIdMap,
alertManagerSourceName,
}: NotificationRouteProps) {
const styles = useStyles2(getStyles);
const [expandRoute, setExpandRoute] = useToggle(false);
// @TODO: The color index might be updated at some point in the future.Maybe we should roll our own tag component,
// one that supports a custom function to define the color and allow manual color overrides
const GREY_COLOR_INDEX = 9;
const matchedRootRoute = route.id === policyTreeSpec.id;
return (
<div data-testid="matching-policy-route">
<NotificationRouteHeader
route={route}
receiver={receiver}
receiverNameFromRoute={receiverNameFromRoute}
routesByIdMap={routesByIdMap}
instancesCount={instanceMatches.length}
alertManagerSourceName={alertManagerSourceName}
expandRoute={expandRoute}
onExpandRouteClick={setExpandRoute}
/>
{expandRoute && (
<Stack gap={1} direction="column">
<div className={styles.routeInstances} data-testid="route-matching-instance">
{instanceMatches.map((instanceMatch) => {
const matchArray = Array.from(instanceMatch.labelsMatch);
const matchResult = matchArray.map(([label, matchResult]) => ({
label: `${label[0]}=${label[1]}`,
match: matchResult.match,
colorIndex: matchResult.match ? getTagColorIndexFromName(label[0]) : GREY_COLOR_INDEX,
}));
const matchingLabels = matchResult.filter((mr) => mr.match);
const nonMatchingLabels = matchResult.filter((mr) => !mr.match);
return (
<div className={styles.tagListCard} key={uniqueId()}>
{matchArray.length > 0 ? (
<>
{matchingLabels.length > 0 ? (
<TagList
tags={matchingLabels.map((mr) => mr.label)}
className={styles.labelList}
getColorIndex={(_, index) => matchingLabels[index].colorIndex}
/>
) : (
<div className={cx(styles.textMuted, styles.textItalic)}>
<Trans i18nKey="alerting.notification-route.no-matching-labels">No matching labels</Trans>
</div>
)}
<div className={styles.labelSeparator} />
<TagList
tags={nonMatchingLabels.map((mr) => mr.label)}
className={styles.labelList}
getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex}
/>
</>
) : (
<div className={styles.textMuted}>
<Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans>
</div>
)}
</div>
);
})}
</div>
</Stack>
)}
<div className={styles.instanceListItem}>
<Stack direction="row" gap={2} alignItems="center">
{labels.length > 0 ? (
<AlertLabels size="sm" labels={routeMatchLabels} />
) : (
<Text color="secondary">
<Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans>
</Text>
)}
<Spacer />
<NotificationPolicyDrawer
labels={labels}
policyName={policyTreeMetadata.name}
matchedRootRoute={matchedRootRoute}
journey={matchingJourney}
/>
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
textMuted: css({
color: theme.colors.text.secondary,
}),
textItalic: css({
fontStyle: 'italic',
}),
expandable: css({
cursor: 'pointer',
}),
routeHeader: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
alignItems: 'center',
borderBottom: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(0.5, 0.5, 0.5, 0),
instanceListItem: css({
padding: theme.spacing(1, 2),
'&:hover': {
backgroundColor: theme.components.table.rowHoverBackground,
},
}),
labelList: css({
flex: '0 1 auto',
justifyContent: 'flex-start',
}),
labelSeparator: css({
width: '1px',
backgroundColor: theme.colors.border.weak,
}),
tagListCard: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(2),
position: 'relative',
background: theme.colors.background.secondary,
padding: theme.spacing(1),
borderRadius: theme.shape.borderRadius(2),
border: `solid 1px ${theme.colors.border.weak}`,
}),
routeInstances: css({
padding: theme.spacing(1, 0, 1, 4),
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
'&:before': {
content: '""',
position: 'absolute',
left: theme.spacing(2),
height: `calc(100% - ${theme.spacing(2)})`,
width: theme.spacing(4),
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}),
verticalBar: css({
width: '1px',
height: '20px',
backgroundColor: theme.colors.secondary.main,
marginLeft: theme.spacing(1),
marginRight: theme.spacing(1),
}),
});

View File

@ -1,200 +0,0 @@
import { css, cx } from '@emotion/css';
import { compact } from 'lodash';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Modal, Stack, TextLink, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { AlertmanagerAction } from '../../../hooks/useAbilities';
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
import { MatcherFormatter } from '../../../utils/matchers';
import { createContactPointSearchLink } from '../../../utils/misc';
import { Authorize } from '../../Authorize';
import { Matchers } from '../../notification-policies/Matchers';
import { ReceiverNameProps } from './NotificationRoute';
import UnknownContactPointDetails from './UnknownContactPointDetails';
import { RouteWithPath, hasEmptyMatchers, isDefaultPolicy } from './route';
interface Props {
routesByIdMap: Map<string, RouteWithPath>;
route: RouteWithPath;
matcherFormatter: MatcherFormatter;
}
function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) {
const styles = useStyles2(getStyles);
const routePathIds = route.path?.slice(1) ?? [];
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
return (
<div className={styles.policyPathWrapper}>
<div className={styles.defaultPolicy}>
<Trans i18nKey="alerting.policy-path.default-policy">Default policy</Trans>
</div>
{routePathObjects.map((pathRoute, index) => {
return (
<div key={pathRoute.id}>
<div className={styles.policyInPath(index, index === routePathObjects.length - 1)}>
{hasEmptyMatchers(pathRoute) ? (
<div className={styles.textMuted}>
<Trans i18nKey="alerting.policy-path.no-matchers">No matchers</Trans>
</div>
) : (
<Matchers matchers={pathRoute.object_matchers ?? []} formatter={matcherFormatter} />
)}
</div>
</div>
);
})}
</div>
);
}
interface NotificationRouteDetailsModalProps extends ReceiverNameProps {
onClose: () => void;
route: RouteWithPath;
receiver?: Receiver;
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
}
export function NotificationRouteDetailsModal({
onClose,
route,
receiver,
receiverNameFromRoute,
routesByIdMap,
alertManagerSourceName,
}: NotificationRouteDetailsModalProps) {
const styles = useStyles2(getStyles);
const isDefault = isDefaultPolicy(route);
return (
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}>
<Modal
className={styles.detailsModal}
isOpen={true}
title={t('alerting.notification-route-details-modal.title-routing-details', 'Routing details')}
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Stack gap={0} direction="column">
<div className={cx(styles.textMuted, styles.marginBottom(2))}>
<Trans i18nKey="alerting.notification-route-details-modal.alert-instances-routed-follows">
Your alert instances are routed as follows.
</Trans>
</div>
<div>
<Trans i18nKey="alerting.notification-route-details-modal.notification-policy-path">
Notification policy path
</Trans>
</div>
{isDefault && (
<div className={styles.textMuted}>
<Trans i18nKey="alerting.notification-route-details-modal.default-policy">Default policy</Trans>
</div>
)}
<div className={styles.separator(1)} />
{!isDefault && (
<PolicyPath
route={route}
routesByIdMap={routesByIdMap}
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
/>
)}
<div className={styles.separator(4)} />
<div className={styles.contactPoint}>
<Stack gap={1} direction="column">
<Trans i18nKey="alerting.notification-route-details-modal.contact-point">Contact point</Trans>
<span className={styles.textMuted}>
{receiver ? receiver.name : <UnknownContactPointDetails receiverName={receiverNameFromRoute} />}
</span>
</Stack>
<Authorize actions={[AlertmanagerAction.UpdateContactPoint]}>
<Stack gap={1} direction="row" alignItems="center">
{receiver ? (
<TextLink href={createContactPointSearchLink(receiver.name, alertManagerSourceName)} external>
<Trans i18nKey="alerting.notification-route-details-modal.see-details-link">See details</Trans>
</TextLink>
) : null}
</Stack>
</Authorize>
</div>
<div className={styles.button}>
<Button variant="primary" type="button" onClick={onClose}>
<Trans i18nKey="alerting.notification-route-details-modal.close">Close</Trans>
</Button>
</div>
</Stack>
</Modal>
</AlertmanagerProvider>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
textMuted: css({
color: theme.colors.text.secondary,
}),
link: css({
display: 'block',
color: theme.colors.text.link,
}),
button: css({
justifyContent: 'flex-end',
display: 'flex',
}),
detailsModal: css({
maxWidth: '560px',
}),
defaultPolicy: css({
padding: theme.spacing(0.5),
background: theme.colors.background.secondary,
width: 'fit-content',
}),
contactPoint: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: theme.spacing(1),
}),
policyPathWrapper: css({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(1),
}),
separator: (units: number) =>
css({
marginTop: theme.spacing(units),
}),
marginBottom: (units: number) =>
css({
marginBottom: theme.spacing(theme.spacing(units)),
}),
policyInPath: (index = 0, highlight = false) =>
css({
marginLeft: `${30 + index * 30}px`,
padding: theme.spacing(1),
marginTop: theme.spacing(1),
border: `solid 1px ${highlight ? theme.colors.info.border : theme.colors.border.weak}`,
background: theme.colors.background.secondary,
width: 'fit-content',
position: 'relative',
'&:before': {
content: '""',
position: 'absolute',
height: 'calc(100% - 10px)',
width: theme.spacing(1),
borderLeft: `solid 1px ${theme.colors.border.weak}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
marginTop: theme.spacing(-2),
marginLeft: `-17px`,
},
}),
});

View File

@ -1,26 +0,0 @@
import { RouteWithID } from '../../../../../../plugins/datasource/alertmanager/types';
export interface RouteWithPath extends RouteWithID {
path: string[]; // path from root route to this route
}
export function isDefaultPolicy(route: RouteWithPath) {
return route.path?.length === 0;
}
// we traverse the whole tree and we create a map with <id , RouteWithPath>
export function getRoutesByIdMap(rootRoute: RouteWithID): Map<string, RouteWithPath> {
const map = new Map<string, RouteWithPath>();
function addRoutesToMap(route: RouteWithID, path: string[] = []) {
map.set(route.id, { ...route, path: path });
route.routes?.forEach((r) => addRoutesToMap(r, [...path, route.id]));
}
addRoutesToMap(rootRoute, []);
return map;
}
export function hasEmptyMatchers(route: RouteWithID) {
return route.object_matchers?.length === 0;
}

View File

@ -1,36 +1,23 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { useContactPointsWithStatus } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
import { useNotificationPolicyRoute } from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies';
import { normalizeRoute } from '../../../utils/notification-policies';
import { RouteWithPath, getRoutesByIdMap } from './route';
export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, potentialInstances: Labels[]) => {
export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, instances: Labels[]) => {
const {
data: currentData,
isLoading: isPoliciesLoading,
error: policiesError,
} = useNotificationPolicyRoute({ alertmanager });
const {
contactPoints,
isLoading: contactPointsLoading,
error: contactPointsError,
} = useContactPointsWithStatus({
alertmanager,
fetchPolicies: false,
fetchStatuses: false,
});
const { matchInstancesToRoute } = useRouteGroupsMatcher();
// this function will use a web worker to compute matching routes
const { matchInstancesToRoutes } = useRouteGroupsMatcher();
const [defaultPolicy] = currentData ?? [];
const rootRoute = useMemo(() => {
@ -40,27 +27,9 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string,
return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy));
}, [defaultPolicy]);
// create maps for routes to be get by id, this map also contains the path to the route
// ⚠️ don't forget to compute the inherited tree before using this map
const routesByIdMap = rootRoute
? getRoutesByIdMap(computeInheritedTree(rootRoute))
: new Map<string, RouteWithPath>();
// to create the list of matching contact points we need to first get the rootRoute
const receiversByName = useMemo(() => {
if (!contactPoints) {
return new Map<string, Receiver>();
}
// create map for receivers to be get by name
return contactPoints.reduce((map, receiver) => {
return map.set(receiver.name, receiver);
}, new Map<string, Receiver>());
}, [contactPoints]);
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
const {
value: matchingMap = new Map<string, AlertInstanceMatch[]>(),
value: treeMatchingResults = [],
loading: matchingLoading,
error: matchingError,
} = useAsync(async () => {
@ -68,16 +37,14 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string,
return;
}
return await matchInstancesToRoute(rootRoute, potentialInstances, {
return await matchInstancesToRoutes(rootRoute, instances, {
unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME,
});
}, [rootRoute, potentialInstances]);
}, [rootRoute, instances]);
return {
routesByIdMap,
receiversByName,
matchingMap,
loading: isPoliciesLoading || contactPointsLoading || matchingLoading,
error: policiesError ?? contactPointsError ?? matchingError,
treeMatchingResults,
isLoading: isPoliciesLoading || matchingLoading,
error: policiesError ?? matchingError,
};
};

View File

@ -1,13 +1,10 @@
import { InstanceMatchResult, matchInstancesToRoute } from '@grafana/alerting/unstable';
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../types/unified-alerting-dto';
import {
AlertInstanceMatch,
findMatchingAlertGroups,
findMatchingRoutes,
normalizeRoute,
unquoteRouteMatchers,
} from './utils/notification-policies';
import { findMatchingAlertGroups, normalizeRoute, unquoteRouteMatchers } from './utils/notification-policies';
import { routeAdapter } from './utils/routeAdapter';
export interface MatchOptions {
unquoteMatchers?: boolean;
@ -34,29 +31,39 @@ export const routeGroupsMatcher = {
return routeGroupsMap;
},
matchInstancesToRoute(
routeTree: RouteWithID,
instancesToMatch: Labels[],
options?: MatchOptions
): Map<string, AlertInstanceMatch[]> {
const result = new Map<string, AlertInstanceMatch[]>();
matchInstancesToRoutes(routeTree: RouteWithID, instances: Labels[], options?: MatchOptions): InstanceMatchResult[] {
const normalizedRouteTree = getNormalizedRoute(routeTree, options);
const normalizedRootRoute = getNormalizedRoute(routeTree, options);
// Convert all instances to labels format and match them all at once
const allLabels = instances.map((instance) => Object.entries(instance));
instancesToMatch.forEach((instance) => {
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
matchingRoutes.forEach(({ route, labelsMatch }) => {
const currentRoute = result.get(route.id);
// Convert the RouteWithID to the alerting package format to ensure compatibility
const convertedRoute = routeAdapter.toPackage(normalizedRouteTree);
const { expandedTree, matchedPolicies } = matchInstancesToRoute(convertedRoute, allLabels);
if (currentRoute) {
currentRoute.push({ instance, labelsMatch });
} else {
result.set(route.id, [{ instance, labelsMatch }]);
}
});
// Group results by instance
return instances.map<InstanceMatchResult>((instance, index) => {
const labels = allLabels[index];
// Collect matches for this specific instance
const allMatchedRoutes = Array.from(matchedPolicies.entries()).flatMap(([route, results]) =>
results
.filter((matchDetails) => matchDetails.labels === labels)
.map((matchDetails) => ({
route,
routeTree: {
metadata: { name: 'user-defined' },
expandedSpec: expandedTree,
},
matchDetails,
}))
);
return {
labels,
matchedRoutes: allMatchedRoutes,
};
});
return result;
},
};

View File

@ -24,7 +24,7 @@ import {
mockRulerGrafanaRule,
mockRulerRuleGroup,
} from '../mocks';
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
import { grafanaRulerRule, mockPreviewApiResponse } from '../mocks/grafanaRulerApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
import { setFolderResponse } from '../mocks/server/configure';
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
@ -103,6 +103,7 @@ describe('CloneRuleEditor', function () {
};
setupDataSources(dataSources.default);
setFolderResponse(mockFolder(folder));
mockPreviewApiResponse(server, []);
});
describe('Grafana-managed rules', function () {

View File

@ -10,7 +10,7 @@ import { AccessControlAction } from 'app/types/accessControl';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
import { grafanaRulerRule, mockPreviewApiResponse } from '../mocks/grafanaRulerApi';
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
import { setupDataSources } from '../testSetup/datasources';
import { Annotation } from '../utils/constants';
@ -23,7 +23,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
jest.setTimeout(60 * 1000);
setupMswServer();
const server = setupMswServer();
function renderRuleEditor(identifier: string) {
return render(
@ -85,6 +85,7 @@ describe('RuleEditor grafana managed rules', () => {
setupDataSources(dataSources.default);
setFolderResponse(mockFolder(folder));
setFolderResponse(mockFolder(slashedFolder));
mockPreviewApiResponse(server, []);
});
it('can edit grafana managed rule', async () => {

View File

@ -12,7 +12,12 @@ import { DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types/accessControl';
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
import { grafanaRulerGroup, grafanaRulerGroup2, grafanaRulerRule } from '../mocks/grafanaRulerApi';
import {
grafanaRulerGroup,
grafanaRulerGroup2,
grafanaRulerRule,
mockPreviewApiResponse,
} from '../mocks/grafanaRulerApi';
import { setFolderResponse } from '../mocks/server/configure';
import { captureRequests, serializeRequests } from '../mocks/server/events';
import { setupDataSources } from '../testSetup/datasources';
@ -25,7 +30,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
jest.setTimeout(60 * 1000);
setupMswServer();
const server = setupMswServer();
const dataSources = {
default: mockDataSource(
@ -62,6 +67,8 @@ describe('RuleEditor grafana managed rules', () => {
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]);
mockPreviewApiResponse(server, []);
});
it('can create new grafana managed alert', async () => {

View File

@ -75,19 +75,19 @@ export function useRouteGroupsMatcher() {
[]
);
const matchInstancesToRoute = useCallback(
async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => {
const matchInstancesToRoutes = useCallback(
async (rootRoute: RouteWithID, instances: Labels[], options?: MatchOptions) => {
validateWorker(routeMatcher);
const startTime = performance.now();
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options);
const result = await routeMatcher.matchInstancesToRoutes(rootRoute, instances, options);
const timeSpent = performance.now() - startTime;
logInfo(`Instances Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
instancesToMatchCount: instancesToMatch.length.toString(),
instancesToMatchCount: instances.length.toString(),
// Counting all nested routes might be too time-consuming, so we only count the first level
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
});
@ -97,5 +97,5 @@ export function useRouteGroupsMatcher() {
[]
);
return { getRouteGroupsMap, matchInstancesToRoute };
return { getRouteGroupsMap, matchInstancesToRoutes };
}

View File

@ -1,56 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`matchLabels should match with non-equal matchers 1`] = `
Map {
[
"team",
"operations",
] => {
"match": true,
"matcher": [
"team",
"=",
"operations",
],
},
}
`;
exports[`matchLabels should match with non-matching matchers 1`] = `
Map {
[
"team",
"operations",
] => {
"match": true,
"matcher": [
"team",
"=",
"operations",
],
},
}
`;
exports[`matchLabels should not match with a set of matchers 1`] = `
Map {
[
"team",
"operations",
] => {
"match": true,
"matcher": [
"team",
"=",
"operations",
],
},
[
"foo",
"bar",
] => {
"match": false,
"matcher": null,
},
}
`;

View File

@ -1,5 +1,6 @@
import { isEqual, uniqWith } from 'lodash';
import { matchLabelsSet } from '@grafana/alerting/unstable';
import { SelectableValue } from '@grafana/data';
import {
AlertManagerCortexConfig,
@ -17,7 +18,12 @@ import { MatcherFieldValue } from '../types/silence-form';
import { getAllDataSources } from './config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { objectLabelsToArray } from './labels';
import { MatcherFormatter, matchLabelsSet, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers';
import {
MatcherFormatter,
convertObjectMatcherToAlertingPackageMatcher,
parsePromQLStyleMatcherLooseSafe,
unquoteWithUnescape,
} from './matchers';
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
// add default receiver if it does not exist
@ -127,9 +133,9 @@ export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher {
export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean {
const labelsArray = objectLabelsToArray(labels);
const objectMatchers = matchers.map(matcherToObjectMatcher);
const labelMatchers = matchers.map(matcherToObjectMatcher).map(convertObjectMatcherToAlertingPackageMatcher);
return matchLabelsSet(objectMatchers, labelsArray);
return matchLabelsSet(labelMatchers, labelsArray);
}
export function combineMatcherStrings(...matcherStrings: string[]): string {

View File

@ -49,3 +49,8 @@ export const canDeleteEntity = (k8sEntity: EntityToCheck) =>
export const encodeFieldSelector = (value: string): string => {
return value.replaceAll(/\\/g, '\\\\').replaceAll(/\=/g, '\\=').replaceAll(/,/g, '\\,');
};
type FieldSelector = [string, string] | [string, string, '=' | '!='];
export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string => {
return fieldSelectors.map(([key, value, operator = '=']) => `${key}${operator}${value}`).join(',');
};

View File

@ -7,7 +7,7 @@
import { chain, compact } from 'lodash';
import { parseFlags } from '@grafana/data';
import { type LabelMatcher } from '@grafana/alerting/unstable';
import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from '../../../../types/unified-alerting-dto';
@ -228,6 +228,8 @@ export const matcherFormatter = {
},
} as const;
export type MatcherFormatter = keyof typeof matcherFormatter;
export function isPromQLStyleMatcher(input: string): boolean {
return input.startsWith('{') && input.endsWith('}');
}
@ -251,81 +253,12 @@ function matcherToOperator(matcher: Matcher): MatcherOperator {
}
}
// Compare set of matchers to set of label
export function matchLabelsSet(matchers: ObjectMatcher[], labels: Label[]): boolean {
for (const matcher of matchers) {
if (!isLabelMatchInSet(matcher, labels)) {
return false;
}
}
return true;
export function convertObjectMatcherToAlertingPackageMatcher(matcher: ObjectMatcher): LabelMatcher {
const [label, type, value] = matcher;
return {
label,
type,
value,
};
}
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
[MatcherOperator.equal]: (lv, mv) => lv === mv,
[MatcherOperator.notEqual]: (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
[MatcherOperator.regex]: (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return re.test(lv);
},
[MatcherOperator.notRegex]: (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return !re.test(lv);
},
};
function isLabelMatchInSet(matcher: ObjectMatcher, labels: Label[]): boolean {
const [matcherKey, operator, matcherValue] = matcher;
let labelValue = ''; // matchers that have no labels are treated as empty string label values
const labelForMatcher = Object.fromEntries(labels)[matcherKey];
if (labelForMatcher) {
labelValue = labelForMatcher;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
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)
// but we're still logging this as a warning because it _might_ be a programmer error.
return matchFunction(labelValue, matcherValue);
} catch (err) {
console.warn(err);
return false;
}
}
// ⚠️ 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: ObjectMatcher, label: Label): boolean {
const [labelKey, labelValue] = label;
const [matcherKey, operator, matcherValue] = matcher;
if (labelKey !== matcherKey) {
return false;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
return matchFunction(labelValue, matcherValue);
}
export type MatcherFormatter = keyof typeof matcherFormatter;
export type Label = [string, string];

View File

@ -1,3 +1,4 @@
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { createReturnTo } from '../hooks/useReturnTo';
@ -89,3 +90,12 @@ export const rulesNav = {
{ skipSubPath: options?.skipSubPath }
),
};
export const notificationPolicies = {
viewLink: (matchers: ObjectMatcher[], alertmanagerSourceName?: string) => {
return createRelativeUrl('/alerting/routes', {
queryString: matchers.map((matcher) => matcher.join('')).join(','),
alertmanager: alertmanagerSourceName ?? 'grafana',
});
},
};

View File

@ -1,399 +1,6 @@
import { MatcherOperator, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import {
InheritableProperties,
computeInheritedTree,
findMatchingRoutes,
getInheritedProperties,
matchLabels,
normalizeRoute,
unquoteRouteMatchers,
} from './notification-policies';
const CATCH_ALL_ROUTE: Route = {
receiver: 'ALL',
object_matchers: [],
};
describe('findMatchingRoutes', () => {
const policies: Route = {
receiver: 'ROOT',
group_by: ['grafana_folder'],
object_matchers: [],
routes: [
{
receiver: 'A',
object_matchers: [['team', MatcherOperator.equal, 'operations']],
routes: [
{
receiver: 'B1',
object_matchers: [['region', MatcherOperator.equal, 'europe']],
},
{
receiver: 'B2',
object_matchers: [['region', MatcherOperator.equal, 'nasa']],
},
],
},
{
receiver: 'C',
object_matchers: [['foo', MatcherOperator.equal, 'bar']],
},
],
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 = {
...policies,
routes: policies.routes?.concat({
receiver: 'D',
object_matchers: [['name', MatcherOperator.notEqual, 'gilles']],
}),
};
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 = {
...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 = {
...policies,
routes: [
{
...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 = {
receiver: 'PARENT',
group_by: ['grafana_folder'],
object_matchers: [['foo', MatcherOperator.equal, 'bar']],
routes: [
{
receiver: 'CHILD',
object_matchers: [['baz', MatcherOperator.equal, 'qux']],
routes: [
{
receiver: 'GRANDCHILD',
object_matchers: [['foo', MatcherOperator.equal, 'bar']],
},
],
},
],
group_wait: '10s',
group_interval: '1m',
};
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0].route).toHaveProperty('receiver', 'PARENT');
});
});
describe('getInheritedProperties()', () => {
describe('group_by: []', () => {
it('should get group_by: [] from parent', () => {
const parent: Route = {
receiver: 'PARENT',
group_by: ['label'],
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
group_by: [],
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
group_by: ['parentLabel'],
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
group_by: ['label'],
};
const child: Route = {
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: Route = { receiver: null, group_by: ['foo'] };
const child: Route = { receiver: null };
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: Route = {
receiver: 'PARENT',
group_wait: '10s',
};
const child: Route = {
receiver: 'CHILD',
};
const childInherited = getInheritedProperties(parent, child);
expect(childInherited).toHaveProperty('group_wait', '10s');
});
it('should compute inherited properties being null', () => {
const parent: Route = {
receiver: 'PARENT',
group_wait: '10s',
};
const child: Route = {
receiver: null,
};
const childInherited = getInheritedProperties(parent, child);
expect(childInherited).toHaveProperty('receiver', 'PARENT');
});
it('should compute inherited properties being undefined from parent inherited properties', () => {
const parent: Route = {
receiver: 'PARENT',
};
const child: Route = {
receiver: 'CHILD',
};
const childInherited = getInheritedProperties(parent, child, { group_wait: '10s' });
expect(childInherited).toHaveProperty('group_wait', '10s');
});
it('should not inherit if the child overrides an inheritable value', () => {
const parent: Route = {
receiver: 'PARENT',
group_wait: '10s',
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
};
const child: Route = {
receiver: '',
group_wait: '30s',
};
const childInherited = getInheritedProperties(parent, child);
expect(childInherited).toHaveProperty('receiver', 'PARENT');
});
});
describe('timing options', () => {
it('should inherit timing options', () => {
const parent: Route = {
receiver: 'PARENT',
group_wait: '1m',
group_interval: '2m',
};
const child: Route = {
repeat_interval: '999s',
};
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: Route = {
receiver: 'PARENT',
group_by: ['parentLabel'],
mute_time_intervals: ['Mon-Fri 09:00-17:00'],
};
const child: Route = {
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: Route = {
receiver: 'PARENT',
group_wait: '1m',
group_interval: '2m',
repeat_interval: '3m',
routes: [
{
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: Route = {
routes: [
{
group_wait: '1m',
group_interval: '2m',
repeat_interval: '3m',
routes: [
{
group_wait: '10m',
group_interval: '20m',
repeat_interval: '30m',
},
{
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');
});
});
import { normalizeRoute, unquoteRouteMatchers } from './notification-policies';
describe('normalizeRoute', () => {
it('should map matchers property to object_matchers', function () {
@ -432,66 +39,6 @@ describe('normalizeRoute', () => {
});
});
describe('matchLabels', () => {
it('should match with non-matching matchers', () => {
const result = matchLabels(
[
['foo', MatcherOperator.equal, ''],
['team', MatcherOperator.equal, 'operations'],
],
[['team', 'operations']]
);
expect(result).toHaveProperty('matches', true);
expect(result.labelsMatch).toMatchSnapshot();
});
it('should match with non-equal matchers', () => {
const result = matchLabels(
[
['foo', MatcherOperator.notEqual, 'bar'],
['team', MatcherOperator.equal, 'operations'],
],
[['team', 'operations']]
);
expect(result).toHaveProperty('matches', true);
expect(result.labelsMatch).toMatchSnapshot();
});
it('should not match with a set of matchers', () => {
const result = matchLabels(
[
['foo', MatcherOperator.notEqual, 'bar'],
['team', MatcherOperator.equal, 'operations'],
],
[
['team', 'operations'],
['foo', 'bar'],
]
);
expect(result).toHaveProperty('matches', false);
expect(result.labelsMatch).toMatchSnapshot();
});
it('does not match unanchored regular expressions', () => {
const result = matchLabels([['foo', MatcherOperator.regex, '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([['foo', MatcherOperator.regex, '.*bar.*']], [['foo', 'barbarbar']]);
expect(result.matches).toEqual(true);
});
it('does match regular expressions with flags', () => {
const result = matchLabels([['foo', MatcherOperator.regex, '(?i).*BAr.*']], [['foo', 'barbarbar']]);
expect(result.matches).toEqual(true);
});
});
describe('unquoteRouteMatchers', () => {
it('should unquote and unescape matchers values', () => {
const route: RouteWithID = {

View File

@ -1,100 +1,12 @@
import { isArray, pick, reduce } from 'lodash';
import { findMatchingRoutes } from '@grafana/alerting/unstable';
import { AlertmanagerGroup, Route } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerGroup, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { Label, isLabelMatch, matchLabelsSet, normalizeMatchers, unquoteWithUnescape } from './matchers';
// If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true
// So we cannot use null as an indicator of no match
interface LabelMatchResult {
match: boolean;
matcher: ObjectMatcher | null;
}
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]>;
type LabelsMatch = Map<Label, LabelMatchResult>;
interface MatchingResult {
matches: boolean;
labelsMatch: LabelsMatch;
}
// returns a match results for given set of matchers (from a policy for instance) and a set of labels
export function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult {
const matches = matchLabelsSet(matchers, labels);
// create initial map of label => match result
const labelsMatch: LabelsMatch = new Map(labels.map((label) => [label, { match: false, matcher: null }]));
// for each matcher, check which label it matched for
matchers.forEach((matcher) => {
const matchingLabel = labels.find((label) => isLabelMatch(matcher, label));
// record that matcher for the label
if (matchingLabel) {
labelsMatch.set(matchingLabel, {
match: true,
matcher,
});
}
});
return { matches, labelsMatch };
}
export interface AlertInstanceMatch {
instance: Labels;
labelsMatch: LabelsMatch;
}
export interface RouteMatchResult<T extends Route> {
route: T;
labelsMatch: LabelsMatch;
}
// Match does 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
function findMatchingRoutes<T extends Route>(route: T, labels: Label[]): Array<RouteMatchResult<T>> {
let childMatches: Array<RouteMatchResult<T>> = [];
// If the current node is not a match, return nothing
const matchResult = matchLabels(route.object_matchers ?? [], labels);
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);
// 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, labelsMatch: matchResult.labelsMatch });
}
return childMatches;
}
import { normalizeMatchers, unquoteWithUnescape } from './matchers';
import { routeAdapter } from './routeAdapter';
// This is a performance improvement to normalize matchers only once and use the normalized version later on
export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
function normalizeRoute(route: RouteWithID) {
export function normalizeRoute<T extends Route>(rootRoute: T): T {
function normalizeRoute<T extends Route>(route: T) {
route.object_matchers = normalizeMatchers(route);
delete route.matchers;
delete route.match;
@ -108,8 +20,8 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
return normalizedRootRoute;
}
export function unquoteRouteMatchers(route: RouteWithID): RouteWithID {
function unquoteRoute(route: RouteWithID) {
export function unquoteRouteMatchers<T extends Route>(route: T): T {
function unquoteRoute(route: Route) {
route.object_matchers = route.object_matchers?.map(([name, operator, value]) => {
return [unquoteWithUnescape(name), operator, unquoteWithUnescape(value)];
});
@ -137,7 +49,11 @@ function findMatchingAlertGroups(
// find matching alerts in the current group
const matchingAlerts = group.alerts.filter((alert) => {
const labels = Object.entries(alert.labels);
return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute.route === route);
const alertingRouteTree = routeAdapter.toPackage(routeTree);
const alertingRoute = routeAdapter.toPackage(route);
return findMatchingRoutes(alertingRouteTree, labels).some(
(matchingRoute) => matchingRoute.route === alertingRoute
);
});
// if the groups has any alerts left after matching, add it to the results
@ -152,66 +68,6 @@ function findMatchingAlertGroups(
}, matchingGroups);
}
// inherited properties are config properties that exist on the parent route (or its inherited properties) but not on the child route
function getInheritedProperties(
parentRoute: Route,
childRoute: Route,
propertiesParentInherited?: InheritableProperties
): InheritableProperties {
const propsFromParent: InheritableProperties = pick(parentRoute, INHERITABLE_KEYS);
const inheritableProperties: InheritableProperties = {
...propsFromParent,
...propertiesParentInherited,
};
const inherited = reduce(
inheritableProperties,
(inheritedProperties: InheritableProperties, parentValue, property) => {
const parentHasValue = parentValue != null;
const inheritableValues = [undefined, '', null];
// @ts-ignore
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;
}
/**
* 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,
});
}),
};
}
// recursive function to rename receivers in all routes (notification policies)
function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
const updated: Route = {
@ -229,4 +85,4 @@ function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
return updated;
}
export { findMatchingAlertGroups, findMatchingRoutes, getInheritedProperties, renameReceiverInRoute };
export { findMatchingAlertGroups, renameReceiverInRoute };

View File

@ -0,0 +1,267 @@
import { Factory } from 'fishery';
import { RouteFactory } from '@grafana/alerting/testing';
import { RouteWithID as AlertingRouteWithID } from '@grafana/alerting/unstable';
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { routeAdapter } from './routeAdapter';
describe('routeAdapter', () => {
// Create RouteWithID factory that extends the base RouteFactory
const RouteWithIDFactory = Factory.define<AlertingRouteWithID>(({ sequence }) => ({
...RouteFactory.build(),
id: `route-${sequence}`,
routes: [],
}));
const mockObjectMatchers: ObjectMatcher[] = [['severity', MatcherOperator.equal, 'critical']];
const mockLabelMatchers = [
{
label: 'severity',
type: '=' as const,
value: 'critical',
},
];
describe('toPackage', () => {
it('should convert basic Route to AlertingRoute', () => {
const route: Route = {
receiver: 'test-receiver',
continue: true,
group_by: ['alertname'],
object_matchers: mockObjectMatchers,
routes: [],
};
const result = routeAdapter.toPackage(route);
expect(result).toEqual({
receiver: 'test-receiver',
continue: true,
group_by: ['alertname'],
matchers: mockLabelMatchers,
routes: [],
group_wait: undefined,
group_interval: undefined,
repeat_interval: undefined,
mute_time_intervals: undefined,
active_time_intervals: undefined,
});
});
it('should convert RouteWithID to AlertingRouteWithID', () => {
const routeWithId: RouteWithID = {
id: 'test-id',
receiver: 'test-receiver',
continue: false,
object_matchers: mockObjectMatchers,
routes: [],
};
const result = routeAdapter.toPackage(routeWithId);
expect(result).toEqual({
id: 'test-id',
receiver: 'test-receiver',
continue: false,
matchers: mockLabelMatchers,
routes: [],
group_by: undefined,
group_wait: undefined,
group_interval: undefined,
repeat_interval: undefined,
mute_time_intervals: undefined,
active_time_intervals: undefined,
});
});
it('should handle undefined continue as false', () => {
const route: Route = {
receiver: 'test-receiver',
object_matchers: mockObjectMatchers,
routes: [],
};
const result = routeAdapter.toPackage(route);
expect(result.continue).toBe(false);
});
it('should handle null receiver as undefined', () => {
const route: Route = {
receiver: null,
object_matchers: mockObjectMatchers,
routes: [],
};
const result = routeAdapter.toPackage(route);
expect(result.receiver).toBeUndefined();
});
it('should recursively convert child routes', () => {
const route: RouteWithID = {
id: 'parent',
receiver: 'parent-receiver',
routes: [
{
id: 'child',
receiver: 'child-receiver',
object_matchers: mockObjectMatchers,
routes: [],
},
],
};
const result = routeAdapter.toPackage(route);
expect(result.routes).toHaveLength(1);
expect(result.routes[0]).toEqual({
id: 'child',
receiver: 'child-receiver',
continue: false,
matchers: mockLabelMatchers,
routes: [],
group_by: undefined,
group_wait: undefined,
group_interval: undefined,
repeat_interval: undefined,
mute_time_intervals: undefined,
active_time_intervals: undefined,
});
});
});
describe('fromPackage', () => {
it('should convert basic AlertingRoute to Route', () => {
const alertingRoute = RouteFactory.build({
receiver: 'test-receiver',
continue: true,
group_by: ['alertname'],
matchers: mockLabelMatchers,
routes: [],
});
const result = routeAdapter.fromPackage(alertingRoute);
expect(result.receiver).toBe('test-receiver');
expect(result.continue).toBe(true);
expect(result.group_by).toEqual(['alertname']);
expect(result.object_matchers).toEqual(mockObjectMatchers);
expect(result.routes).toBeUndefined();
});
it('should convert AlertingRouteWithID to RouteWithID', () => {
const alertingRouteWithId = RouteWithIDFactory.build({
id: 'test-id',
receiver: 'test-receiver',
continue: false,
matchers: mockLabelMatchers,
routes: [],
});
const result = routeAdapter.fromPackage(alertingRouteWithId);
expect(result.id).toBe('test-id');
expect(result.receiver).toBe('test-receiver');
expect(result.continue).toBe(false);
expect(result.object_matchers).toEqual(mockObjectMatchers);
expect(result.routes).toBeUndefined();
});
it('should handle undefined receiver as null', () => {
const alertingRoute = RouteFactory.build();
// Override receiver to undefined after building
const alertingRouteWithUndefinedReceiver = {
...alertingRoute,
receiver: undefined,
};
const result = routeAdapter.fromPackage(alertingRouteWithUndefinedReceiver);
expect(result.receiver).toBeNull();
});
it('should recursively convert child routes', () => {
const childRoute = RouteWithIDFactory.build({
id: 'child',
receiver: 'child-receiver',
continue: true,
matchers: mockLabelMatchers,
routes: [],
});
const alertingRoute = RouteWithIDFactory.build({
id: 'parent',
receiver: 'parent-receiver',
continue: false,
routes: [childRoute],
});
const result = routeAdapter.fromPackage(alertingRoute);
expect(result.routes).toHaveLength(1);
expect(result.routes![0].id).toBe('child');
expect(result.routes![0].receiver).toBe('child-receiver');
expect(result.routes![0].continue).toBe(true);
expect(result.routes![0].object_matchers).toEqual(mockObjectMatchers);
expect(result.routes![0].routes).toBeUndefined();
});
it('should handle routes without matchers', () => {
const alertingRoute = RouteFactory.build();
// Override matchers to undefined after building
const alertingRouteWithoutMatchers = {
...alertingRoute,
matchers: undefined,
};
const result = routeAdapter.fromPackage(alertingRouteWithoutMatchers);
expect(result.object_matchers).toBeUndefined();
});
});
describe('round-trip conversion', () => {
it('should maintain data integrity through round-trip conversion', () => {
const childRoute: RouteWithID = {
id: 'child-route',
receiver: 'child-receiver',
continue: false,
object_matchers: [['environment', MatcherOperator.equal, 'prod']],
routes: [],
};
const originalRoute: RouteWithID = {
id: 'test-route',
receiver: 'test-receiver',
continue: true,
group_by: ['alertname', 'severity'],
group_wait: '10s',
group_interval: '5m',
repeat_interval: '1h',
object_matchers: [
['severity', MatcherOperator.equal, 'critical'],
['team', MatcherOperator.regex, 'frontend|backend'],
],
mute_time_intervals: ['maintenance'],
active_time_intervals: ['business-hours'],
routes: [childRoute],
};
// Convert to package format and back
const packageRoute = routeAdapter.toPackage(originalRoute);
const backToOriginal = routeAdapter.fromPackage(packageRoute);
expect(backToOriginal).toEqual({
...originalRoute,
routes: [
{
...childRoute,
routes: undefined, // fromPackage doesn't add routes array if empty
},
],
});
});
});
});

View File

@ -0,0 +1,159 @@
import {
Route as AlertingRoute,
RouteWithID as AlertingRouteWithID,
type LabelMatcher,
} from '@grafana/alerting/unstable';
import {
MatcherOperator,
type ObjectMatcher,
type Route,
type RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { convertObjectMatcherToAlertingPackageMatcher, matcherToObjectMatcher, parseMatcherToArray } from './matchers';
/**
* Enhanced type guards using infer-based utility types
*/
function hasId<T extends Route | RouteWithID>(route: T): route is T & RouteWithID {
return 'id' in route && typeof route.id === 'string';
}
function hasAlertingId<T extends AlertingRoute | AlertingRouteWithID>(route: T): route is T & AlertingRouteWithID {
return 'id' in route && typeof route.id === 'string';
}
/**
* Converts from package route format to alertmanager route format
*/
function fromPackageRoute(route: AlertingRouteWithID): RouteWithID;
function fromPackageRoute(route: AlertingRoute): Route;
function fromPackageRoute(route: AlertingRoute | AlertingRouteWithID): Route | RouteWithID {
// Convert matchers from LabelMatcher[] to ObjectMatcher[]
const object_matchers = route.matchers?.map(labelMatcherToObjectMatcher);
// Recursively convert child routes
const routes = route.routes?.length ? route.routes.map(fromPackageRoute) : undefined;
const baseRoute = {
receiver: route.receiver || null,
group_by: route.group_by,
continue: route.continue,
group_wait: route.group_wait,
group_interval: route.group_interval,
repeat_interval: route.repeat_interval,
mute_time_intervals: route.mute_time_intervals,
active_time_intervals: route.active_time_intervals,
};
const convertedRoute = {
...baseRoute,
object_matchers,
routes,
};
// If the input route has an ID, include it in the output
if (hasAlertingId(route)) {
return {
...convertedRoute,
id: route.id,
};
}
return convertedRoute;
}
/**
* Converts from alertmanager route format to package route format
*/
function toPackageRoute(route: RouteWithID): AlertingRouteWithID;
function toPackageRoute(route: Route): AlertingRoute;
function toPackageRoute(route: Route | RouteWithID): AlertingRoute | AlertingRouteWithID {
// Convert matchers
let matchers: LabelMatcher[] = [];
if (route.object_matchers) {
matchers = route.object_matchers.map(convertObjectMatcherToAlertingPackageMatcher);
} else if (route.matchers) {
matchers = [];
route.matchers.forEach((matcher) => {
const parsedMatchers = parseMatcherToArray(matcher)
.map(matcherToObjectMatcher)
.map(convertObjectMatcherToAlertingPackageMatcher);
matchers.push(...parsedMatchers);
});
}
// Recursively convert child routes
const routes = route.routes?.length ? route.routes.map(toPackageRoute) : [];
const baseRoute = {
receiver: route.receiver ?? undefined,
group_by: route.group_by,
continue: route.continue ?? false,
group_wait: route.group_wait,
group_interval: route.group_interval,
repeat_interval: route.repeat_interval,
mute_time_intervals: route.mute_time_intervals,
active_time_intervals: route.active_time_intervals,
};
const convertedRoute = {
...baseRoute,
matchers,
routes,
};
// If the input route has an ID, include it in the output
if (hasId(route)) {
return {
...convertedRoute,
id: route.id,
};
}
return convertedRoute;
}
/**
* Converts routes between alertmanager and package formats
*/
export const routeAdapter = {
/**
* Converts from package route format to alertmanager route format
* Handles both Route and RouteWithID variants
*/
fromPackage: fromPackageRoute,
/**
* Converts from alertmanager route format to package route format
* Handles both Route and RouteWithID variants
*/
toPackage: toPackageRoute,
};
/**
* Safely converts a LabelMatcher type to MatcherOperator
*/
function convertToMatcherOperator(type: LabelMatcher['type']): MatcherOperator {
switch (type) {
case '=':
return MatcherOperator.equal;
case '!=':
return MatcherOperator.notEqual;
case '=~':
return MatcherOperator.regex;
case '!~':
return MatcherOperator.notRegex;
default:
const exhaustiveCheck: never = type;
throw new Error(`Unknown matcher type: ${exhaustiveCheck}`);
}
}
/**
* Converts a LabelMatcher from the alerting package format to an ObjectMatcher for alertmanager format
*/
export function labelMatcherToObjectMatcher(matcher: LabelMatcher): ObjectMatcher {
return [matcher.label, convertToMatcherOperator(matcher.type), matcher.value];
}

View File

@ -1671,6 +1671,10 @@
"inspector-yaml-tab": {
"apply": "Apply"
},
"instance-match": {
"non-matching-labels": "Non-matching labels",
"notification-policy": "View route"
},
"irm-integration": {
"connection-method": "How to connect to IRM",
"disabled-description": "Enable IRM through a Webhook integration",
@ -1813,6 +1817,10 @@
}
}
},
"match-details": {
"matched": "matched",
"no-matchers-matched": "Policy matches all labels"
},
"matcher-filter": {
"filter-alerts-using-label-querying-without-braces": "Filter alerts using label querying without braces, ex:",
"filter-alerts-using-label-querying-without-spaces": "Filter alerts using label querying without spaces, ex:",
@ -1975,8 +1983,10 @@
"label-notification-policies": "Notification Policies",
"label-time-intervals": "Time intervals"
},
"notification-policy-drawer": {
"view-notification-policy-tree": "View notification policy tree"
},
"notification-policy-matchers": {
"default-policy": "Default policy",
"no-matchers": "No matchers"
},
"notification-preview": {
@ -1993,22 +2003,12 @@
},
"notification-route": {
"no-labels": "No labels",
"no-matching-labels": "No matching labels"
},
"notification-route-details-modal": {
"alert-instances-routed-follows": "Your alert instances are routed as follows.",
"close": "Close",
"contact-point": "Contact point",
"default-policy": "Default policy",
"notification-policy-path": "Notification policy path",
"see-details-link": "See details",
"title-routing-details": "Routing details"
"notification-policy": "Notification policy"
},
"notification-route-header": {
"aria-label-expand-policy-route": "Expand policy route",
"delivered-to": "@ Delivered to",
"notification-policy": "Notification policy",
"see-details": "See details"
"delivered-to": "Delivered to",
"instances": "instances"
},
"notification-templates": {
"duplicate": {
@ -2137,10 +2137,6 @@
"label-new-sibling-above": "New sibling above",
"label-new-sibling-below": "New sibling below"
},
"policy-path": {
"default-policy": "Default policy",
"no-matchers": "No matchers"
},
"preview-rule": {
"body-preview-is-not-available": "Cannot display the query preview. Some of the data sources used in the queries are not available.",
"preview-alerts": "Preview alerts",