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,52 +44,52 @@ export function useMatchAlertInstancesToNotificationPolicies() {
|
|||
}
|
||||
);
|
||||
|
||||
const matchInstancesToPolicies = useCallback(
|
||||
(instances: Label[][]): InstanceMatchResult[] => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// the routing trees are returned as an array of items because there can be several
|
||||
const trees = data.items;
|
||||
|
||||
return instances.map<InstanceMatchResult>((labels) => {
|
||||
// Collect all matched routes from all trees
|
||||
const allMatchedRoutes: RouteMatch[] = [];
|
||||
|
||||
// Process each tree for this instance
|
||||
trees.forEach((tree) => {
|
||||
const treeName = tree.metadata.name ?? USER_DEFINED_TREE_NAME;
|
||||
// We have to convert the RoutingTree structure to a Route structure to be able to use the matching functions
|
||||
const rootRoute = convertRoutingTreeToRoute(tree);
|
||||
|
||||
// Match this single instance against the route tree
|
||||
const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute);
|
||||
|
||||
// Process each matched route from the tree
|
||||
matchedPolicies.forEach((results, route) => {
|
||||
// For each match result, create a RouteMatch object
|
||||
results.forEach((matchDetails) => {
|
||||
allMatchedRoutes.push({
|
||||
route,
|
||||
routeTree: {
|
||||
metadata: { name: treeName },
|
||||
expandedSpec: expandedTree,
|
||||
},
|
||||
matchDetails,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
matchedRoutes: allMatchedRoutes,
|
||||
};
|
||||
});
|
||||
},
|
||||
[data]
|
||||
const memoizedFunction = useCallback(
|
||||
(instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances),
|
||||
[data?.items]
|
||||
);
|
||||
|
||||
return { matchInstancesToPolicies, ...rest };
|
||||
return {
|
||||
matchInstancesToRouteTrees: memoizedFunction,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will match a set of labels to multiple routing trees. Assumes a list of routing trees has already been fetched.
|
||||
*
|
||||
* Use "useMatchInstancesToRouteTrees" if you want the hook to automatically fetch the latest definition of routing trees.
|
||||
*/
|
||||
export function matchInstancesToRouteTrees(trees: RoutingTree[], instances: Label[][]): InstanceMatchResult[] {
|
||||
// Process each tree and get matches for all instances
|
||||
const treeMatches = trees.map<TreeMatch>((tree) => {
|
||||
const rootRoute = convertRoutingTreeToRoute(tree);
|
||||
return matchInstancesToRoute(rootRoute, instances);
|
||||
});
|
||||
|
||||
// Group results by instance
|
||||
return instances.map<InstanceMatchResult>((labels) => {
|
||||
// Collect matches for this specific instance from all trees
|
||||
const allMatchedRoutes = treeMatches.flatMap(({ expandedTree, matchedPolicies }, index) => {
|
||||
const tree = trees[index];
|
||||
|
||||
return Array.from(matchedPolicies.entries()).flatMap(([route, results]) =>
|
||||
results
|
||||
.filter((matchDetails) => matchDetails.labels === labels)
|
||||
.map((matchDetails) => ({
|
||||
route,
|
||||
routeTree: {
|
||||
metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME },
|
||||
expandedSpec: expandedTree,
|
||||
},
|
||||
matchDetails,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
matchedRoutes: allMatchedRoutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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) => (
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={alertManagerSource}
|
||||
potentialInstances={potentialInstances}
|
||||
onlyOneAM={onlyOneAM}
|
||||
key={alertManagerSource.name}
|
||||
/>
|
||||
<Fragment key={alertManagerSource.name}>
|
||||
{!singleAlertManagerConfigured && (
|
||||
<Stack direction="row" alignItems="center">
|
||||
<div className={styles.firstAlertManagerLine} />
|
||||
<div className={styles.alertManagerName}>
|
||||
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
|
||||
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
||||
{alertManagerSource.name}
|
||||
</div>
|
||||
<div className={styles.secondAlertManagerLine} />
|
||||
</Stack>
|
||||
)}
|
||||
{alertManagerSource.name === 'grafana' ? (
|
||||
<NotificationPreviewForGrafanaManaged
|
||||
alertManagerSource={alertManagerSource}
|
||||
instances={potentialInstances}
|
||||
/>
|
||||
) : (
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={alertManagerSource}
|
||||
instances={potentialInstances}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Suspense>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
firstAlertManagerLine: css({
|
||||
height: '1px',
|
||||
width: theme.spacing(4),
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
alertManagerName: css({
|
||||
width: 'fit-content',
|
||||
}),
|
||||
secondAlertManagerLine: css({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
img: css({
|
||||
marginLeft: theme.spacing(2),
|
||||
width: theme.spacing(3),
|
||||
height: theme.spacing(3),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,31 +1,29 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
|
||||
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
|
||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { AlertManagerDataSource } from '../../../utils/datasource';
|
||||
|
||||
import { NotificationRoute } from './NotificationRoute';
|
||||
import { ExternalContactPointGroup } from './ContactPointGroup';
|
||||
import { InstanceMatch } from './NotificationRoute';
|
||||
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
|
||||
|
||||
const UNKNOWN_RECEIVER = 'unknown';
|
||||
|
||||
function NotificationPreviewByAlertManager({
|
||||
alertManagerSource,
|
||||
potentialInstances,
|
||||
onlyOneAM,
|
||||
instances,
|
||||
}: {
|
||||
alertManagerSource: AlertManagerDataSource;
|
||||
potentialInstances: Labels[];
|
||||
onlyOneAM: boolean;
|
||||
instances: Labels[];
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview(
|
||||
const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview(
|
||||
alertManagerSource.name,
|
||||
potentialInstances
|
||||
instances
|
||||
);
|
||||
|
||||
if (error) {
|
||||
|
|
@ -39,7 +37,7 @@ function NotificationPreviewByAlertManager({
|
|||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoadingPlaceholder
|
||||
text={t(
|
||||
|
|
@ -50,76 +48,48 @@ function NotificationPreviewByAlertManager({
|
|||
);
|
||||
}
|
||||
|
||||
const matchingPoliciesFound = matchingMap.size > 0;
|
||||
const matchingPoliciesFound = treeMatchingResults.some((result) => result.matchedRoutes.length > 0);
|
||||
|
||||
// Group results by receiver name
|
||||
// We need to flatten the structure first to group by receiver
|
||||
const flattenedResults = treeMatchingResults.flatMap(({ labels, matchedRoutes }) => {
|
||||
return Array.from(matchedRoutes).map(({ route, routeTree, matchDetails }) => ({
|
||||
labels,
|
||||
receiver: route.receiver || UNKNOWN_RECEIVER,
|
||||
routeTree,
|
||||
matchDetails,
|
||||
}));
|
||||
});
|
||||
|
||||
const contactPointGroups = groupBy(flattenedResults, 'receiver');
|
||||
|
||||
return matchingPoliciesFound ? (
|
||||
<div className={styles.alertManagerRow}>
|
||||
{!onlyOneAM && (
|
||||
<Stack direction="row" alignItems="center">
|
||||
<div className={styles.firstAlertManagerLine} />
|
||||
<div className={styles.alertManagerName}>
|
||||
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
|
||||
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
||||
{alertManagerSource.name}
|
||||
</div>
|
||||
<div className={styles.secondAlertManagerLine} />
|
||||
</Stack>
|
||||
)}
|
||||
<Stack gap={1} direction="column">
|
||||
{Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => {
|
||||
const route = routesByIdMap.get(routeId);
|
||||
const receiver = route?.receiver && receiversByName.get(route.receiver);
|
||||
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NotificationRoute
|
||||
instanceMatches={instanceMatches}
|
||||
route={route}
|
||||
// If we can't find a receiver, it might just be because the user doesn't have access
|
||||
receiver={receiver ? receiver : undefined}
|
||||
receiverNameFromRoute={route?.receiver ? route.receiver : undefined}
|
||||
key={routeId}
|
||||
routesByIdMap={routesByIdMap}
|
||||
alertManagerSourceName={alertManagerSource.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Box display="flex" direction="column" gap={1} width="100%">
|
||||
<Stack direction="column" gap={0}>
|
||||
{Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => (
|
||||
<ExternalContactPointGroup
|
||||
key={receiver}
|
||||
name={receiver}
|
||||
matchedInstancesCount={resultsForReceiver.length}
|
||||
alertmanagerSourceName={alertManagerSource.name}
|
||||
>
|
||||
<Stack direction="column" gap={0}>
|
||||
{resultsForReceiver.map(({ routeTree, matchDetails }) => (
|
||||
<InstanceMatch
|
||||
key={matchDetails.labels.join(',')}
|
||||
matchedInstance={matchDetails}
|
||||
policyTreeSpec={routeTree.expandedSpec}
|
||||
policyTreeMetadata={routeTree.metadata}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ExternalContactPointGroup>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</Box>
|
||||
) : null;
|
||||
}
|
||||
|
||||
// export default because we want to load the component dynamically using React.lazy
|
||||
// Due to loading of the web worker we don't want to load this component when not necessary
|
||||
export default withErrorBoundary(NotificationPreviewByAlertManager);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alertManagerRow: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
width: '100%',
|
||||
}),
|
||||
firstAlertManagerLine: css({
|
||||
height: '1px',
|
||||
width: theme.spacing(4),
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
alertManagerName: css({
|
||||
width: 'fit-content',
|
||||
}),
|
||||
secondAlertManagerLine: css({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
img: css({
|
||||
marginLeft: theme.spacing(2),
|
||||
width: theme.spacing(3),
|
||||
height: theme.spacing(3),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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={cx(styles.textMuted, styles.textItalic)}>
|
||||
<Trans i18nKey="alerting.notification-route.no-matching-labels">No matching labels</Trans>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.labelSeparator} />
|
||||
<TagList
|
||||
tags={nonMatchingLabels.map((mr) => mr.label)}
|
||||
className={styles.labelList}
|
||||
getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.textMuted}>
|
||||
<Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
<div className={styles.instanceListItem}>
|
||||
<Stack direction="row" gap={2} alignItems="center">
|
||||
{labels.length > 0 ? (
|
||||
<AlertLabels size="sm" labels={routeMatchLabels} />
|
||||
) : (
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans>
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
<NotificationPolicyDrawer
|
||||
labels={labels}
|
||||
policyName={policyTreeMetadata.name}
|
||||
matchedRootRoute={matchedRootRoute}
|
||||
journey={matchingJourney}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
textMuted: css({
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
textItalic: css({
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
expandable: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
routeHeader: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(1),
|
||||
alignItems: 'center',
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
padding: theme.spacing(0.5, 0.5, 0.5, 0),
|
||||
instanceListItem: css({
|
||||
padding: theme.spacing(1, 2),
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.components.table.rowHoverBackground,
|
||||
},
|
||||
}),
|
||||
labelList: css({
|
||||
flex: '0 1 auto',
|
||||
justifyContent: 'flex-start',
|
||||
}),
|
||||
labelSeparator: css({
|
||||
width: '1px',
|
||||
backgroundColor: theme.colors.border.weak,
|
||||
}),
|
||||
tagListCard: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(2),
|
||||
|
||||
position: 'relative',
|
||||
background: theme.colors.background.secondary,
|
||||
padding: theme.spacing(1),
|
||||
|
||||
borderRadius: theme.shape.borderRadius(2),
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
}),
|
||||
routeInstances: css({
|
||||
padding: theme.spacing(1, 0, 1, 4),
|
||||
position: 'relative',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: theme.spacing(2),
|
||||
height: `calc(100% - ${theme.spacing(2)})`,
|
||||
width: theme.spacing(4),
|
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
verticalBar: css({
|
||||
width: '1px',
|
||||
height: '20px',
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
marginLeft: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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