mirror of https://github.com/grafana/grafana.git
				
				
				
			Alerting: Use route matching hook (#111028)
This commit is contained in:
		
							parent
							
								
									c59aff3bb9
								
							
						
					
					
						commit
						61bf3d9899
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -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); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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,42 +44,47 @@ export function useMatchAlertInstancesToNotificationPolicies() { | |||
|     } | ||||
|   ); | ||||
| 
 | ||||
|   const matchInstancesToPolicies = useCallback( | ||||
|     (instances: Label[][]): InstanceMatchResult[] => { | ||||
|       if (!data) { | ||||
|         return []; | ||||
|       } | ||||
|   const memoizedFunction = useCallback( | ||||
|     (instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances), | ||||
|     [data?.items] | ||||
|   ); | ||||
| 
 | ||||
|       // the routing trees are returned as an array of items because there can be several
 | ||||
|       const trees = data.items; | ||||
|   return { | ||||
|     matchInstancesToRouteTrees: memoizedFunction, | ||||
|     ...rest, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|       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
 | ||||
| /** | ||||
|  * 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); | ||||
|   }); | ||||
| 
 | ||||
|           // Match this single instance against the route tree
 | ||||
|           const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute); | ||||
|   // 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]; | ||||
| 
 | ||||
|           // Process each matched route from the tree
 | ||||
|           matchedPolicies.forEach((results, route) => { | ||||
|             // For each match result, create a RouteMatch object
 | ||||
|             results.forEach((matchDetails) => { | ||||
|               allMatchedRoutes.push({ | ||||
|       return Array.from(matchedPolicies.entries()).flatMap(([route, results]) => | ||||
|         results | ||||
|           .filter((matchDetails) => matchDetails.labels === labels) | ||||
|           .map((matchDetails) => ({ | ||||
|             route, | ||||
|             routeTree: { | ||||
|                   metadata: { name: treeName }, | ||||
|               metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME }, | ||||
|               expandedSpec: expandedTree, | ||||
|             }, | ||||
|             matchDetails, | ||||
|               }); | ||||
|             }); | ||||
|           }); | ||||
|           })) | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     return { | ||||
|  | @ -88,9 +92,4 @@ export function useMatchAlertInstancesToNotificationPolicies() { | |||
|       matchedRoutes: allMatchedRoutes, | ||||
|     }; | ||||
|   }); | ||||
|     }, | ||||
|     [data] | ||||
|   ); | ||||
| 
 | ||||
|   return { matchInstancesToPolicies, ...rest }; | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|   }); | ||||
|  |  | |||
|  | @ -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) : [], | ||||
|       }) | ||||
|     ); | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 }; | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|           }, | ||||
|         ], | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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) => { | ||||
|  |  | |||
|  | @ -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); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 ( | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <> | ||||
|  |  | |||
|  | @ -11,6 +11,8 @@ exports[`createKubernetesRoutingTreeSpec 1`] = ` | |||
|       "group_by": [ | ||||
|         "alertname", | ||||
|       ], | ||||
|       "group_interval": undefined, | ||||
|       "group_wait": undefined, | ||||
|       "receiver": "default-receiver", | ||||
|       "repeat_interval": "4h", | ||||
|     }, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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, | ||||
|   }), | ||||
| }); | ||||
|  | @ -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, | ||||
|   }), | ||||
| }); | ||||
|  | @ -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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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, | ||||
|   }), | ||||
| }); | ||||
|  | @ -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', | ||||
|   }), | ||||
| }); | ||||
|  | @ -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> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | @ -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, | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -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.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(); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -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) => ( | ||||
|             <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} | ||||
|               potentialInstances={potentialInstances} | ||||
|               onlyOneAM={onlyOneAM} | ||||
|               key={alertManagerSource.name} | ||||
|                   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), | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -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> | ||||
|     </div> | ||||
|           </ExternalContactPointGroup> | ||||
|         ))} | ||||
|       </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(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), | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -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); | ||||
|  | @ -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={styles.instanceListItem}> | ||||
|       <Stack direction="row" gap={2} alignItems="center"> | ||||
|         {labels.length > 0 ? ( | ||||
|           <AlertLabels size="sm" labels={routeMatchLabels} /> | ||||
|         ) : ( | ||||
|                         <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}> | ||||
|           <Text color="secondary"> | ||||
|             <Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans> | ||||
|                     </div> | ||||
|           </Text> | ||||
|         )} | ||||
|                 </div> | ||||
|               ); | ||||
|             })} | ||||
|           </div> | ||||
|         <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), | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -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`, | ||||
|       }, | ||||
|     }), | ||||
| }); | ||||
|  | @ -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; | ||||
| } | ||||
|  | @ -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, | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -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; | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 () { | ||||
|  |  | |||
|  | @ -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 () => { | ||||
|  |  | |||
|  | @ -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 () => { | ||||
|  |  | |||
|  | @ -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 }; | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|   }, | ||||
| } | ||||
| `; | ||||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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(','); | ||||
| }; | ||||
|  |  | |||
|  | @ -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]; | ||||
|  |  | |||
|  | @ -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', | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -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 }; | ||||
|  |  | |||
|  | @ -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
 | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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]; | ||||
| } | ||||
|  | @ -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", | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue