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
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts": {
|
|
||||||
"@typescript-eslint/consistent-type-assertions": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/grafana-data/src/dataframe/ArrayDataFrame.ts": {
|
"packages/grafana-data/src/dataframe/ArrayDataFrame.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"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 { Label } from '../../matchers/types';
|
||||||
import { USER_DEFINED_TREE_NAME } from '../consts';
|
import { USER_DEFINED_TREE_NAME } from '../consts';
|
||||||
import { Route, RouteWithID } from '../types';
|
import { Route, RouteWithID } from '../types';
|
||||||
import { RouteMatchResult, convertRoutingTreeToRoute, matchAlertInstancesToPolicyTree } from '../utils';
|
import { RouteMatchResult, TreeMatch, convertRoutingTreeToRoute, matchInstancesToRoute } from '../utils';
|
||||||
|
|
||||||
export type RouteMatch = {
|
export type RouteMatch = {
|
||||||
route: Route;
|
route: Route;
|
||||||
|
|
@ -32,11 +32,10 @@ export type InstanceMatchResult = {
|
||||||
* 2. Compute the inherited properties for each node in the tree
|
* 2. Compute the inherited properties for each node in the tree
|
||||||
* 3. Find routes within each tree that match the given set of labels
|
* 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
|
* and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details
|
||||||
*/
|
*/
|
||||||
export function useMatchAlertInstancesToNotificationPolicies() {
|
export function useMatchInstancesToRouteTrees() {
|
||||||
// fetch the routing trees from the API
|
|
||||||
const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery(
|
const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
|
@ -45,42 +44,47 @@ export function useMatchAlertInstancesToNotificationPolicies() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchInstancesToPolicies = useCallback(
|
const memoizedFunction = useCallback(
|
||||||
(instances: Label[][]): InstanceMatchResult[] => {
|
(instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances),
|
||||||
if (!data) {
|
[data?.items]
|
||||||
return [];
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// the routing trees are returned as an array of items because there can be several
|
return {
|
||||||
const trees = data.items;
|
matchInstancesToRouteTrees: memoizedFunction,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return instances.map<InstanceMatchResult>((labels) => {
|
/**
|
||||||
// Collect all matched routes from all trees
|
* This function will match a set of labels to multiple routing trees. Assumes a list of routing trees has already been fetched.
|
||||||
const allMatchedRoutes: RouteMatch[] = [];
|
*
|
||||||
|
* Use "useMatchInstancesToRouteTrees" if you want the hook to automatically fetch the latest definition of routing trees.
|
||||||
// Process each tree for this instance
|
*/
|
||||||
trees.forEach((tree) => {
|
export function matchInstancesToRouteTrees(trees: RoutingTree[], instances: Label[][]): InstanceMatchResult[] {
|
||||||
const treeName = tree.metadata.name ?? USER_DEFINED_TREE_NAME;
|
// Process each tree and get matches for all instances
|
||||||
// We have to convert the RoutingTree structure to a Route structure to be able to use the matching functions
|
const treeMatches = trees.map<TreeMatch>((tree) => {
|
||||||
const rootRoute = convertRoutingTreeToRoute(tree);
|
const rootRoute = convertRoutingTreeToRoute(tree);
|
||||||
|
return matchInstancesToRoute(rootRoute, instances);
|
||||||
|
});
|
||||||
|
|
||||||
// Match this single instance against the route tree
|
// Group results by instance
|
||||||
const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute);
|
return instances.map<InstanceMatchResult>((labels) => {
|
||||||
|
// Collect matches for this specific instance from all trees
|
||||||
|
const allMatchedRoutes = treeMatches.flatMap(({ expandedTree, matchedPolicies }, index) => {
|
||||||
|
const tree = trees[index];
|
||||||
|
|
||||||
// Process each matched route from the tree
|
return Array.from(matchedPolicies.entries()).flatMap(([route, results]) =>
|
||||||
matchedPolicies.forEach((results, route) => {
|
results
|
||||||
// For each match result, create a RouteMatch object
|
.filter((matchDetails) => matchDetails.labels === labels)
|
||||||
results.forEach((matchDetails) => {
|
.map((matchDetails) => ({
|
||||||
allMatchedRoutes.push({
|
|
||||||
route,
|
route,
|
||||||
routeTree: {
|
routeTree: {
|
||||||
metadata: { name: treeName },
|
metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME },
|
||||||
expandedSpec: expandedTree,
|
expandedSpec: expandedTree,
|
||||||
},
|
},
|
||||||
matchDetails,
|
matchDetails,
|
||||||
});
|
}))
|
||||||
});
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -88,9 +92,4 @@ export function useMatchAlertInstancesToNotificationPolicies() {
|
||||||
matchedRoutes: allMatchedRoutes,
|
matchedRoutes: allMatchedRoutes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { matchInstancesToPolicies, ...rest };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
computeInheritedTree,
|
computeInheritedTree,
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
getInheritedProperties,
|
getInheritedProperties,
|
||||||
matchAlertInstancesToPolicyTree,
|
matchInstancesToRoute,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
describe('findMatchingRoutes', () => {
|
describe('findMatchingRoutes', () => {
|
||||||
|
|
@ -1072,7 +1072,7 @@ describe('matchAlertInstancesToPolicyTree', () => {
|
||||||
], // Should match parent only
|
], // Should match parent only
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = matchAlertInstancesToPolicyTree(instances, parentRoute);
|
const result = matchInstancesToRoute(parentRoute, instances);
|
||||||
|
|
||||||
// Should return expanded tree with identifiers
|
// Should return expanded tree with identifiers
|
||||||
expect(result.expandedTree).toHaveProperty('id');
|
expect(result.expandedTree).toHaveProperty('id');
|
||||||
|
|
@ -1109,13 +1109,13 @@ describe('matchAlertInstancesToPolicyTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Empty instances array
|
// Empty instances array
|
||||||
const result1 = matchAlertInstancesToPolicyTree([], route);
|
const result1 = matchInstancesToRoute(route, []);
|
||||||
expect(result1.expandedTree).toHaveProperty('id');
|
expect(result1.expandedTree).toHaveProperty('id');
|
||||||
expect(result1.matchedPolicies.size).toBe(0);
|
expect(result1.matchedPolicies.size).toBe(0);
|
||||||
|
|
||||||
// Instances that don't match
|
// Instances that don't match
|
||||||
const instances: Label[][] = [[['service', 'api']]];
|
const instances: Label[][] = [[['service', 'api']]];
|
||||||
const result2 = matchAlertInstancesToPolicyTree(instances, route);
|
const result2 = matchInstancesToRoute(route, instances);
|
||||||
expect(result2.expandedTree).toHaveProperty('id');
|
expect(result2.expandedTree).toHaveProperty('id');
|
||||||
expect(result2.matchedPolicies.size).toBe(0);
|
expect(result2.matchedPolicies.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash';
|
import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash';
|
||||||
|
|
||||||
import { RoutingTree, RoutingTreeRoute } from '../api/v0alpha1/api.gen';
|
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 { LabelMatchDetails, matchLabels } from '../matchers/utils';
|
||||||
|
|
||||||
import { Route, RouteWithID } from './types';
|
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 = {
|
export type TreeMatch = {
|
||||||
/* we'll include the entire expanded policy tree for diagnostics */
|
/* we'll include the entire expanded policy tree for diagnostics */
|
||||||
expandedTree: RouteWithID;
|
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 instances - A set of labels for which you want to determine the matching policies
|
||||||
* @param routingTree - A notification policy tree (or subtree)
|
* @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
|
// initially empty map of matches policies
|
||||||
const matchedPolicies = new Map();
|
const matchedPolicies = new Map();
|
||||||
|
|
||||||
// compute the entire expanded tree for matching routes and diagnostics
|
// compute the entire expanded tree for matching routes and diagnostics
|
||||||
// this will include inherited properties from parent nodes
|
// 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
|
// let's first find all matching routes for the provided instances
|
||||||
const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels));
|
const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels));
|
||||||
|
|
@ -202,13 +203,6 @@ export function convertRoutingTreeToRoute(routingTree: RoutingTree): Route {
|
||||||
return routes.map(
|
return routes.map(
|
||||||
(route): Route => ({
|
(route): 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) : [],
|
routes: route.routes ? convertRoutingTreeRoutes(route.routes) : [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,15 @@ export { getContactPointDescription } from './grafana/contactPoints/utils';
|
||||||
|
|
||||||
// Notification Policies
|
// Notification Policies
|
||||||
export {
|
export {
|
||||||
useMatchAlertInstancesToNotificationPolicies,
|
useMatchInstancesToRouteTrees,
|
||||||
|
matchInstancesToRouteTrees,
|
||||||
type RouteMatch,
|
type RouteMatch,
|
||||||
type InstanceMatchResult,
|
type InstanceMatchResult,
|
||||||
} from './grafana/notificationPolicies/hooks/useMatchPolicies';
|
} from './grafana/notificationPolicies/hooks/useMatchPolicies';
|
||||||
export {
|
export {
|
||||||
type TreeMatch,
|
type TreeMatch,
|
||||||
type RouteMatchResult,
|
type RouteMatchResult,
|
||||||
matchAlertInstancesToPolicyTree,
|
matchInstancesToRoute,
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
getInheritedProperties,
|
getInheritedProperties,
|
||||||
computeInheritedTree,
|
computeInheritedTree,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ export function useRouteGroupsMatcher() {
|
||||||
return routeGroupsMatcher.getRouteGroupsMap(route, groups);
|
return routeGroupsMatcher.getRouteGroupsMap(route, groups);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
|
const matchInstancesToRoutes = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
|
||||||
return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
|
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,
|
"filtersApplied": true,
|
||||||
"matchedRoutesWithPath": Map {
|
"matchedRoutesWithPath": Map {
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} => [
|
} => [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "0",
|
"id": "0",
|
||||||
|
"mute_time_intervals": undefined,
|
||||||
|
"object_matchers": [],
|
||||||
"receiver": "default-receiver",
|
"receiver": "default-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": 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",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -95,41 +181,101 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher
|
||||||
"filtersApplied": true,
|
"filtersApplied": true,
|
||||||
"matchedRoutesWithPath": Map {
|
"matchedRoutesWithPath": Map {
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} => [
|
} => [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "0",
|
"id": "0",
|
||||||
|
"mute_time_intervals": undefined,
|
||||||
|
"object_matchers": [],
|
||||||
"receiver": "default-receiver",
|
"receiver": "default-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": 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",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
} => [
|
} => [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "0",
|
"id": "0",
|
||||||
|
"mute_time_intervals": undefined,
|
||||||
|
"object_matchers": [],
|
||||||
"receiver": "default-receiver",
|
"receiver": "default-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": 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",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -223,41 +477,101 @@ exports[`findRoutesMatchingFilters should work with only label matchers 1`] = `
|
||||||
"filtersApplied": true,
|
"filtersApplied": true,
|
||||||
"matchedRoutesWithPath": Map {
|
"matchedRoutesWithPath": Map {
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} => [
|
} => [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "0",
|
"id": "0",
|
||||||
|
"mute_time_intervals": undefined,
|
||||||
|
"object_matchers": [],
|
||||||
"receiver": "default-receiver",
|
"receiver": "default-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": 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",
|
"id": "1",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"hello=world",
|
"object_matchers": [
|
||||||
"foo!=bar",
|
[
|
||||||
|
"hello",
|
||||||
|
"=",
|
||||||
|
"world",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
"!=",
|
||||||
|
"bar",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
"active_time_intervals": undefined,
|
||||||
|
"continue": false,
|
||||||
|
"group_by": undefined,
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"matchers": [
|
"mute_time_intervals": undefined,
|
||||||
"bar=baz",
|
"object_matchers": [
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
"=",
|
||||||
|
"baz",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
"receiver": "simple-receiver",
|
"receiver": "simple-receiver",
|
||||||
|
"repeat_interval": undefined,
|
||||||
"routes": undefined,
|
"routes": undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { RelativeTimeRange } from '@grafana/data';
|
import { RelativeTimeRange } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
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 { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||||
import {
|
import {
|
||||||
AlertQuery,
|
AlertQuery,
|
||||||
|
|
@ -33,11 +33,9 @@ import {
|
||||||
} from './prometheus';
|
} from './prometheus';
|
||||||
import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
|
import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
|
||||||
|
|
||||||
export type ResponseLabels = {
|
export type PreviewResponse = Array<
|
||||||
labels: AlertInstances[];
|
Pick<AlertmanagerAlert, 'annotations' | 'endsAt' | 'startsAt' | 'generatorURL' | 'labels'>
|
||||||
};
|
>;
|
||||||
|
|
||||||
export type PreviewResponse = ResponseLabels[];
|
|
||||||
|
|
||||||
export interface Datasource {
|
export interface Datasource {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -83,8 +81,6 @@ export interface Rule {
|
||||||
annotations: Annotations;
|
annotations: Annotations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AlertInstances = Record<string, string>;
|
|
||||||
|
|
||||||
interface ExportRulesParams {
|
interface ExportRulesParams {
|
||||||
format: ExportFormats;
|
format: ExportFormats;
|
||||||
folderUid?: string;
|
folderUid?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { difference, groupBy, take, trim, upperFirst } from 'lodash';
|
import { difference, groupBy, take, trim, upperFirst } from 'lodash';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { computeInheritedTree } from '@grafana/alerting/unstable';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/features/alerting/unified/types/alerting';
|
import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/features/alerting/unified/types/alerting';
|
||||||
import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||||
|
|
@ -14,8 +15,8 @@ import {
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { OnCallIntegrationDTO } from '../../api/onCallApi';
|
import { OnCallIntegrationDTO } from '../../api/onCallApi';
|
||||||
import { computeInheritedTree } from '../../utils/notification-policies';
|
|
||||||
import { extractReceivers } from '../../utils/receivers';
|
import { extractReceivers } from '../../utils/receivers';
|
||||||
|
import { routeAdapter } from '../../utils/routeAdapter';
|
||||||
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
|
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
|
||||||
import { ReceiverPluginMetadata, getOnCallMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
|
import { ReceiverPluginMetadata, getOnCallMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
|
||||||
|
|
||||||
|
|
@ -132,8 +133,10 @@ export function enhanceContactPointsWithMetadata({
|
||||||
alertmanagerConfiguration,
|
alertmanagerConfiguration,
|
||||||
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
|
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
|
||||||
// compute the entire inherited tree before finding what notification policies are using a particular contact point
|
// compute the entire inherited tree before finding what notification policies are using a particular contact point
|
||||||
const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {});
|
const fullyInheritedTree = computeInheritedTree(
|
||||||
const usedContactPoints = getUsedContactPoints(fullyInheritedTree);
|
routeAdapter.toPackage(alertmanagerConfiguration?.alertmanager_config?.route ?? {})
|
||||||
|
);
|
||||||
|
const usedContactPoints = getUsedContactPoints(routeAdapter.fromPackage(fullyInheritedTree));
|
||||||
const usedContactPointsByName = groupBy(usedContactPoints, 'receiver');
|
const usedContactPointsByName = groupBy(usedContactPoints, 'receiver');
|
||||||
|
|
||||||
const enhanced = contactPoints.map((contactPoint) => {
|
const enhanced = contactPoints.map((contactPoint) => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
import { mockExportApi, setupMswServer } from '../../mockApi';
|
import { mockExportApi, setupMswServer } from '../../mockApi';
|
||||||
import { mockDataSource } from '../../mocks';
|
import { mockDataSource } from '../../mocks';
|
||||||
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
|
import { grafanaRulerRule, mockPreviewApiResponse } from '../../mocks/grafanaRulerApi';
|
||||||
import { setupDataSources } from '../../testSetup/datasources';
|
import { setupDataSources } from '../../testSetup/datasources';
|
||||||
|
|
||||||
import GrafanaModifyExport from './GrafanaModifyExport';
|
import GrafanaModifyExport from './GrafanaModifyExport';
|
||||||
|
|
@ -59,6 +59,10 @@ function renderModifyExport(ruleId: string) {
|
||||||
|
|
||||||
const server = setupMswServer();
|
const server = setupMswServer();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPreviewApiResponse(server, []);
|
||||||
|
});
|
||||||
|
|
||||||
describe('GrafanaModifyExport', () => {
|
describe('GrafanaModifyExport', () => {
|
||||||
setupDataSources(dataSources.default);
|
setupDataSources(dataSources.default);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ interface MatcherBadgeProps {
|
||||||
formatter?: MatcherFormatter;
|
formatter?: MatcherFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
|
export const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defaults } from 'lodash';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAsyncFn } from 'react-use';
|
import { useAsyncFn } from 'react-use';
|
||||||
|
|
||||||
|
import { computeInheritedTree } from '@grafana/alerting/unstable';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { Alert, Button, Stack } from '@grafana/ui';
|
import { Alert, Button, Stack } from '@grafana/ui';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
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 { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
|
||||||
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
|
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
|
||||||
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
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 { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { anyOfRequestState, isError } from '../../hooks/useAsync';
|
import { anyOfRequestState, isError } from '../../hooks/useAsync';
|
||||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
|
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
|
||||||
|
import { routeAdapter } from '../../utils/routeAdapter';
|
||||||
|
|
||||||
import { alertmanagerApi } from './../../api/alertmanagerApi';
|
import { alertmanagerApi } from './../../api/alertmanagerApi';
|
||||||
import { useGetContactPointsState } from './../../api/receiversApi';
|
import { useGetContactPointsState } from './../../api/receiversApi';
|
||||||
|
|
@ -297,7 +298,10 @@ export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: Route
|
||||||
const matchedRoutes: RouteWithID[][] = [];
|
const matchedRoutes: RouteWithID[][] = [];
|
||||||
|
|
||||||
// compute fully inherited tree so all policies have their inherited receiver
|
// 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
|
// find all routes for our contact point filter
|
||||||
const matchingRoutesForContactPoint = contactPointFilter
|
const matchingRoutesForContactPoint = contactPointFilter
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import * as React from 'react';
|
||||||
import { FC, Fragment, ReactNode, useState } from 'react';
|
import { FC, Fragment, ReactNode, useState } from 'react';
|
||||||
import { useToggle } from 'react-use';
|
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 { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import {
|
import {
|
||||||
|
|
@ -39,7 +40,7 @@ import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility }
|
||||||
import { getAmMatcherFormatter } from '../../utils/alertmanager';
|
import { getAmMatcherFormatter } from '../../utils/alertmanager';
|
||||||
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
|
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
|
||||||
import { createContactPointLink, createContactPointSearchLink, createMuteTimingLink } from '../../utils/misc';
|
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 { InsertPosition } from '../../utils/routeTree';
|
||||||
import { Authorize } from '../Authorize';
|
import { Authorize } from '../Authorize';
|
||||||
import { PopupCard } from '../HoverCard';
|
import { PopupCard } from '../HoverCard';
|
||||||
|
|
@ -59,7 +60,7 @@ interface PolicyComponentProps {
|
||||||
contactPointsState?: ReceiversState;
|
contactPointsState?: ReceiversState;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
provisioned?: boolean;
|
provisioned?: boolean;
|
||||||
inheritedProperties?: Partial<InheritableProperties>;
|
inheritedProperties?: InheritableProperties;
|
||||||
routesMatchingFilters?: RoutesMatchingFilters;
|
routesMatchingFilters?: RoutesMatchingFilters;
|
||||||
|
|
||||||
matchingInstancesPreview?: {
|
matchingInstancesPreview?: {
|
||||||
|
|
@ -346,7 +347,11 @@ const Policy = (props: PolicyComponentProps) => {
|
||||||
{showPolicyChildren && (
|
{showPolicyChildren && (
|
||||||
<>
|
<>
|
||||||
{pageOfChildren.map((child) => {
|
{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.
|
// 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;
|
const isThisChildAutoGenerated = isAutoGeneratedRoot(child) || isAutoGenerated;
|
||||||
/* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable,
|
/* 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);
|
const styles = useStyles2(getStyles);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ exports[`createKubernetesRoutingTreeSpec 1`] = `
|
||||||
"group_by": [
|
"group_by": [
|
||||||
"alertname",
|
"alertname",
|
||||||
],
|
],
|
||||||
|
"group_interval": undefined,
|
||||||
|
"group_wait": undefined,
|
||||||
"receiver": "default-receiver",
|
"receiver": "default-receiver",
|
||||||
"repeat_interval": "4h",
|
"repeat_interval": "4h",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import memoize from 'micro-memoize';
|
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 { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks';
|
||||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
|
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 { addUniqueIdentifierToRoute } from '../../utils/amroutes';
|
||||||
import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||||
import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils';
|
import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils';
|
||||||
import { INHERITABLE_KEYS, InheritableProperties } from '../../utils/notification-policies';
|
import { routeAdapter } from '../../utils/routeAdapter';
|
||||||
import {
|
import {
|
||||||
InsertPosition,
|
InsertPosition,
|
||||||
addRouteToReferenceRoute,
|
addRouteToReferenceRoute,
|
||||||
|
|
@ -279,7 +280,7 @@ export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApis
|
||||||
export function createKubernetesRoutingTreeSpec(
|
export function createKubernetesRoutingTreeSpec(
|
||||||
rootRoute: Route
|
rootRoute: Route
|
||||||
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
||||||
const inheritableDefaultProperties: InheritableProperties = pick(rootRoute, INHERITABLE_KEYS);
|
const inheritableDefaultProperties: InheritableProperties = pick(routeAdapter.toPackage(rootRoute), INHERITABLE_KEYS);
|
||||||
|
|
||||||
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
|
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
|
||||||
...inheritableDefaultProperties,
|
...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 { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
import { grafanaRulerGroup } from '../../../../mocks/grafanaRulerApi';
|
import { grafanaRulerGroup, mockPreviewApiResponse } from '../../../../mocks/grafanaRulerApi';
|
||||||
|
|
||||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||||
AppChromeUpdate: ({ actions }: { actions: ReactNode }) => <div>{actions}</div>,
|
AppChromeUpdate: ({ actions }: { actions: ReactNode }) => <div>{actions}</div>,
|
||||||
|
|
@ -63,6 +63,8 @@ const selectContactPoint = async (contactPointName: string) => {
|
||||||
await clickSelectOption(contactPointInput, contactPointName);
|
await clickSelectOption(contactPointInput, contactPointName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const server = setupMswServer();
|
||||||
|
|
||||||
// combobox hack
|
// combobox hack
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||||
|
|
@ -77,9 +79,10 @@ beforeEach(() => {
|
||||||
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
|
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
|
||||||
value: mockGetBoundingClientRect,
|
value: mockGetBoundingClientRect,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mockPreviewApiResponse(server, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
setupMswServer();
|
|
||||||
setupDataSources(dataSources.default, dataSources.am);
|
setupDataSources(dataSources.default, dataSources.am);
|
||||||
|
|
||||||
// Setup plugin extensions hook to prevent setPluginLinksHook errors
|
// 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 { 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 { MatcherFormatter } from '../../../utils/matchers';
|
||||||
import { Matchers } from '../../notification-policies/Matchers';
|
import { Matchers } from '../../notification-policies/Matchers';
|
||||||
|
|
||||||
import { RouteWithPath, hasEmptyMatchers, isDefaultPolicy } from './route';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
route: RouteWithPath;
|
matchers?: ObjectMatcher[];
|
||||||
matcherFormatter: MatcherFormatter;
|
matcherFormatter: MatcherFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) {
|
export function NotificationPolicyMatchers({ matchers = [], matcherFormatter }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
if (matchers.length === 0) {
|
||||||
if (isDefaultPolicy(route)) {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.defaultPolicy}>
|
<Text variant="bodySmall" color="secondary">
|
||||||
<Trans i18nKey="alerting.notification-policy-matchers.default-policy">Default policy</Trans>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (hasEmptyMatchers(route)) {
|
|
||||||
return (
|
|
||||||
<div className={styles.textMuted}>
|
|
||||||
<Trans i18nKey="alerting.notification-policy-matchers.no-matchers">No matchers</Trans>
|
<Trans i18nKey="alerting.notification-policy-matchers.no-matchers">No matchers</Trans>
|
||||||
</div>
|
</Text>
|
||||||
);
|
);
|
||||||
} else {
|
} 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 { render, waitFor, within } from 'test/test-utils';
|
||||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
import { byRole, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
|
import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
|
||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
|
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
|
||||||
import { getMockConfig, setupMswServer } from '../../../mockApi';
|
import { getMockConfig, setupMswServer } from '../../../mockApi';
|
||||||
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
|
import { grantUserPermissions, mockAlertQuery, mockAlertmanagerAlert } from '../../../mocks';
|
||||||
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
|
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
|
||||||
import { Folder } from '../../../types/rule-form';
|
import { Folder } from '../../../types/rule-form';
|
||||||
import * as dataSource from '../../../utils/datasource';
|
import * as dataSource from '../../../utils/datasource';
|
||||||
|
|
@ -18,7 +17,6 @@ import {
|
||||||
} from '../../../utils/datasource';
|
} from '../../../utils/datasource';
|
||||||
|
|
||||||
import { NotificationPreview } from './NotificationPreview';
|
import { NotificationPreview } from './NotificationPreview';
|
||||||
import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager';
|
|
||||||
|
|
||||||
jest.mock('../../../useRouteGroupsMatcher');
|
jest.mock('../../../useRouteGroupsMatcher');
|
||||||
|
|
||||||
|
|
@ -34,18 +32,14 @@ const getAlertManagerDataSourcesByPermissionAndConfigMock =
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
route: byTestId('matching-policy-route'),
|
contactPointGroup: byRole('list'),
|
||||||
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 }),
|
|
||||||
grafanaAlertManagerLabel: byText(/alertmanager:grafana/i),
|
grafanaAlertManagerLabel: byText(/alertmanager:grafana/i),
|
||||||
otherAlertManagerLabel: byText(/alertmanager:other_am/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: {
|
details: {
|
||||||
title: byRole('heading', { name: /routing details/i }),
|
drawer: byRole('dialog'),
|
||||||
modal: byRole('dialog'),
|
linkToPolicyTree: byRole('link', { name: /view notification policy tree/i }),
|
||||||
linkToContactPoint: byRole('link', { name: /see details/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 () => {
|
it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => {
|
||||||
mockOneAlertManager();
|
mockOneAlertManager();
|
||||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
mockPreviewApiResponse(server, [
|
||||||
|
mockAlertmanagerAlert({
|
||||||
|
labels: { tomato: 'red', avocate: 'green' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const { user } = render(
|
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(ui.previewButton.get());
|
// wait for loading to finish
|
||||||
await waitFor(() => {
|
await waitFor(async () => {
|
||||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
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
|
// 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();
|
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
|
||||||
|
|
||||||
const matchingPoliciesElements = ui.route.queryAll;
|
const matchingContactPoint = await ui.contactPointGroup.findAll();
|
||||||
await waitFor(() => {
|
expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/);
|
||||||
expect(matchingPoliciesElements()).toHaveLength(1);
|
expect(matchingContactPoint[0]).toHaveTextContent(/1 instance/);
|
||||||
});
|
|
||||||
expect(matchingPoliciesElements()[0]).toHaveTextContent(/tomato = red/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => {
|
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
|
// two alert managers configured to receive alerts
|
||||||
mockTwoAlertManagers();
|
mockTwoAlertManagers();
|
||||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
mockPreviewApiResponse(server, [
|
||||||
|
mockAlertmanagerAlert({
|
||||||
|
labels: { tomato: 'red', avocate: 'green' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const { user } = render(
|
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||||
<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
|
// 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.grafanaAlertManagerLabel.find()).toBeInTheDocument();
|
||||||
expect(await ui.otherAlertManagerLabel.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(matchingContactPoint).toHaveLength(2);
|
||||||
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
|
expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/);
|
||||||
expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/);
|
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
|
it('should render details when clicking see details button', async () => {
|
||||||
mockOneAlertManager();
|
|
||||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
|
||||||
mockHasEditPermission(true);
|
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();
|
mockOneAlertManager();
|
||||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
mockPreviewApiResponse(server, [
|
||||||
mockHasEditPermission(false);
|
mockAlertmanagerAlert({
|
||||||
|
labels: { tomato: 'red', avocate: 'green' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const { user } = render(
|
const { user } = render(
|
||||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
// wait for loading to finish
|
||||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
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());
|
await user.click(await ui.seeDetails.find());
|
||||||
expect(ui.details.title.query()).toBeInTheDocument();
|
|
||||||
//we expect seeing the default policy
|
// grab drawer and assert within
|
||||||
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
|
const drawer = ui.details.drawer.getAll()[0];
|
||||||
const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers');
|
expect(drawer).toBeInTheDocument();
|
||||||
expect(matchingPoliciesElements).toHaveLength(1);
|
|
||||||
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
|
// assert within the drawer
|
||||||
expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument();
|
expect(within(drawer).getByRole('heading', { name: 'Default policy' })).toBeInTheDocument();
|
||||||
expect(ui.details.linkToContactPoint.query()).not.toBeInTheDocument();
|
expect(within(drawer).getByText(/non-matching labels/i)).toBeInTheDocument();
|
||||||
});
|
expect(ui.details.linkToPolicyTree.get()).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { compact } from 'lodash';
|
import { css } from '@emotion/css';
|
||||||
import { Suspense, lazy } from 'react';
|
import { Fragment, Suspense, lazy } from 'react';
|
||||||
|
import { useEffectOnce } from 'react-use';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
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 { 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 { Folder, KBObjectArray } from '../../../types/rule-form';
|
||||||
import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
|
import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
|
||||||
|
|
||||||
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
|
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
|
||||||
|
const NotificationPreviewForGrafanaManaged = lazy(() => import('./NotificationPreviewGrafanaManaged'));
|
||||||
|
|
||||||
interface NotificationPreviewProps {
|
interface NotificationPreviewProps {
|
||||||
customLabels: KBObjectArray;
|
customLabels: KBObjectArray;
|
||||||
|
|
@ -20,6 +23,8 @@ interface NotificationPreviewProps {
|
||||||
alertUid?: string;
|
alertUid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { preview } = alertRuleApi.endpoints;
|
||||||
|
|
||||||
// TODO the scroll position keeps resetting when we preview
|
// 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
|
// this is to be expected because the list of routes dissapears as we start the request but is very annoying
|
||||||
export const NotificationPreview = ({
|
export const NotificationPreview = ({
|
||||||
|
|
@ -30,15 +35,20 @@ export const NotificationPreview = ({
|
||||||
alertName,
|
alertName,
|
||||||
alertUid,
|
alertUid,
|
||||||
}: NotificationPreviewProps) => {
|
}: NotificationPreviewProps) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
const disabled = !condition || !folder;
|
const disabled = !condition || !folder;
|
||||||
|
|
||||||
const previewEndpoint = alertRuleApi.endpoints.preview;
|
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = preview.useMutation();
|
||||||
|
|
||||||
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = previewEndpoint.useMutation();
|
|
||||||
|
|
||||||
// potential instances are the instances that are going to be routed to the notification policies
|
// 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
|
// 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 = () => {
|
const onPreview = () => {
|
||||||
if (!folder || !condition) {
|
if (!folder || !condition) {
|
||||||
|
|
@ -56,10 +66,13 @@ export const NotificationPreview = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffectOnce(() => {
|
||||||
|
onPreview();
|
||||||
|
});
|
||||||
|
|
||||||
// Get alert managers's data source information
|
// Get alert managers's data source information
|
||||||
const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification');
|
const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification');
|
||||||
|
const singleAlertManagerConfigured = alertManagerDataSources.length === 1;
|
||||||
const onlyOneAM = alertManagerDataSources.length === 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
|
|
@ -93,22 +106,63 @@ export const NotificationPreview = ({
|
||||||
<Trans i18nKey="alerting.notification-preview.preview-routing">Preview routing</Trans>
|
<Trans i18nKey="alerting.notification-preview.preview-routing">Preview routing</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
|
{potentialInstances.length > 0 && (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<LoadingPlaceholder text={t('alerting.notification-preview.text-loading-preview', 'Loading preview...')} />
|
<LoadingPlaceholder text={t('alerting.notification-preview.text-loading-preview', 'Loading preview...')} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{alertManagerDataSources.map((alertManagerSource) => (
|
{alertManagerDataSources.map((alertManagerSource) => (
|
||||||
|
<Fragment key={alertManagerSource.name}>
|
||||||
|
{!singleAlertManagerConfigured && (
|
||||||
|
<Stack direction="row" alignItems="center">
|
||||||
|
<div className={styles.firstAlertManagerLine} />
|
||||||
|
<div className={styles.alertManagerName}>
|
||||||
|
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
|
||||||
|
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
||||||
|
{alertManagerSource.name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.secondAlertManagerLine} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{alertManagerSource.name === 'grafana' ? (
|
||||||
|
<NotificationPreviewForGrafanaManaged
|
||||||
|
alertManagerSource={alertManagerSource}
|
||||||
|
instances={potentialInstances}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<NotificationPreviewByAlertManager
|
<NotificationPreviewByAlertManager
|
||||||
alertManagerSource={alertManagerSource}
|
alertManagerSource={alertManagerSource}
|
||||||
potentialInstances={potentialInstances}
|
instances={potentialInstances}
|
||||||
onlyOneAM={onlyOneAM}
|
|
||||||
key={alertManagerSource.name}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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 { t } from '@grafana/i18n';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
|
||||||
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
|
|
||||||
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||||
|
|
||||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||||
import { AlertManagerDataSource } from '../../../utils/datasource';
|
import { AlertManagerDataSource } from '../../../utils/datasource';
|
||||||
|
|
||||||
import { NotificationRoute } from './NotificationRoute';
|
import { ExternalContactPointGroup } from './ContactPointGroup';
|
||||||
|
import { InstanceMatch } from './NotificationRoute';
|
||||||
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
|
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
|
||||||
|
|
||||||
|
const UNKNOWN_RECEIVER = 'unknown';
|
||||||
|
|
||||||
function NotificationPreviewByAlertManager({
|
function NotificationPreviewByAlertManager({
|
||||||
alertManagerSource,
|
alertManagerSource,
|
||||||
potentialInstances,
|
instances,
|
||||||
onlyOneAM,
|
|
||||||
}: {
|
}: {
|
||||||
alertManagerSource: AlertManagerDataSource;
|
alertManagerSource: AlertManagerDataSource;
|
||||||
potentialInstances: Labels[];
|
instances: Labels[];
|
||||||
onlyOneAM: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const styles = useStyles2(getStyles);
|
const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview(
|
||||||
|
|
||||||
const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview(
|
|
||||||
alertManagerSource.name,
|
alertManagerSource.name,
|
||||||
potentialInstances
|
instances
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -39,7 +37,7 @@ function NotificationPreviewByAlertManager({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<LoadingPlaceholder
|
<LoadingPlaceholder
|
||||||
text={t(
|
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 ? (
|
return matchingPoliciesFound ? (
|
||||||
<div className={styles.alertManagerRow}>
|
<Box display="flex" direction="column" gap={1} width="100%">
|
||||||
{!onlyOneAM && (
|
<Stack direction="column" gap={0}>
|
||||||
<Stack direction="row" alignItems="center">
|
{Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => (
|
||||||
<div className={styles.firstAlertManagerLine} />
|
<ExternalContactPointGroup
|
||||||
<div className={styles.alertManagerName}>
|
key={receiver}
|
||||||
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
|
name={receiver}
|
||||||
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
matchedInstancesCount={resultsForReceiver.length}
|
||||||
{alertManagerSource.name}
|
alertmanagerSourceName={alertManagerSource.name}
|
||||||
</div>
|
>
|
||||||
<div className={styles.secondAlertManagerLine} />
|
<Stack direction="column" gap={0}>
|
||||||
</Stack>
|
{resultsForReceiver.map(({ routeTree, matchDetails }) => (
|
||||||
)}
|
<InstanceMatch
|
||||||
<Stack gap={1} direction="column">
|
key={matchDetails.labels.join(',')}
|
||||||
{Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => {
|
matchedInstance={matchDetails}
|
||||||
const route = routesByIdMap.get(routeId);
|
policyTreeSpec={routeTree.expandedSpec}
|
||||||
const receiver = route?.receiver && receiversByName.get(route.receiver);
|
policyTreeMetadata={routeTree.metadata}
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</ExternalContactPointGroup>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default because we want to load the component dynamically using React.lazy
|
// 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
|
// Due to loading of the web worker we don't want to load this component when not necessary
|
||||||
export default withErrorBoundary(NotificationPreviewByAlertManager);
|
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 { css } from '@emotion/css';
|
||||||
import { uniqueId } from 'lodash';
|
|
||||||
import pluralize from 'pluralize';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useToggle } from 'react-use';
|
|
||||||
|
|
||||||
|
import { AlertLabels, RouteMatchResult, RouteWithID } from '@grafana/alerting/unstable';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { Button, TagList, getTagColorIndexFromName, useStyles2 } from '@grafana/ui';
|
import { Text, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
|
||||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||||
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
|
import { arrayLabelsToObject } from '../../../utils/labels';
|
||||||
import { AlertInstanceMatch } from '../../../utils/notification-policies';
|
|
||||||
import { CollapseToggle } from '../../CollapseToggle';
|
|
||||||
import { MetaText } from '../../MetaText';
|
|
||||||
import { Spacer } from '../../Spacer';
|
import { Spacer } from '../../Spacer';
|
||||||
|
|
||||||
import { NotificationPolicyMatchers } from './NotificationPolicyMatchers';
|
import { NotificationPolicyDrawer } from './NotificationPolicyDrawer';
|
||||||
import { NotificationRouteDetailsModal } from './NotificationRouteDetailsModal';
|
|
||||||
import UnknownContactPointDetails from './UnknownContactPointDetails';
|
|
||||||
import { RouteWithPath } from './route';
|
|
||||||
|
|
||||||
export interface ReceiverNameProps {
|
type TreeMeta = {
|
||||||
/** Receiver name taken from route definition. Used as a fallback when full receiver details cannot be found (in case of RBAC restrictions) */
|
name?: string;
|
||||||
receiverNameFromRoute?: string;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationRouteHeaderProps extends ReceiverNameProps {
|
type InstanceMatchProps = {
|
||||||
route: RouteWithPath;
|
matchedInstance: RouteMatchResult<RouteWithID>;
|
||||||
receiver?: Receiver;
|
policyTreeSpec: RouteWithID;
|
||||||
routesByIdMap: Map<string, RouteWithPath>;
|
policyTreeMetadata: TreeMeta;
|
||||||
instancesCount: number;
|
};
|
||||||
alertManagerSourceName: string;
|
|
||||||
expandRoute: boolean;
|
|
||||||
onExpandRouteClick: (expand: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationRouteHeader({
|
export function InstanceMatch({ matchedInstance, policyTreeSpec, policyTreeMetadata }: InstanceMatchProps) {
|
||||||
route,
|
|
||||||
receiver,
|
|
||||||
receiverNameFromRoute,
|
|
||||||
routesByIdMap,
|
|
||||||
instancesCount,
|
|
||||||
alertManagerSourceName,
|
|
||||||
expandRoute,
|
|
||||||
onExpandRouteClick,
|
|
||||||
}: NotificationRouteHeaderProps) {
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
|
|
||||||
const onClickDetails = () => {
|
const { labels, matchingJourney, route } = matchedInstance;
|
||||||
setShowDetails(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// @TODO: re-use component ContactPointsHoverDetails from Policy once we have it for cloud AMs.
|
// Get all match details from the final matched route in the journey
|
||||||
|
const finalRouteMatchInfo = matchingJourney.at(-1);
|
||||||
return (
|
const routeMatchLabels = arrayLabelsToObject(
|
||||||
<div className={styles.routeHeader}>
|
finalRouteMatchInfo?.matchDetails.map((detail) => labels[detail.labelIndex]) ?? []
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
const matchedRootRoute = route.id === policyTreeSpec.id;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="matching-policy-route">
|
<div className={styles.instanceListItem}>
|
||||||
<NotificationRouteHeader
|
<Stack direction="row" gap={2} alignItems="center">
|
||||||
route={route}
|
{labels.length > 0 ? (
|
||||||
receiver={receiver}
|
<AlertLabels size="sm" labels={routeMatchLabels} />
|
||||||
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)}>
|
<Text color="secondary">
|
||||||
<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>
|
<Trans i18nKey="alerting.notification-route.no-labels">No labels</Trans>
|
||||||
</div>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
<Spacer />
|
||||||
);
|
<NotificationPolicyDrawer
|
||||||
})}
|
labels={labels}
|
||||||
</div>
|
policyName={policyTreeMetadata.name}
|
||||||
|
matchedRootRoute={matchedRootRoute}
|
||||||
|
journey={matchingJourney}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
textMuted: css({
|
instanceListItem: css({
|
||||||
color: theme.colors.text.secondary,
|
padding: theme.spacing(1, 2),
|
||||||
}),
|
|
||||||
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),
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: theme.components.table.rowHoverBackground,
|
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 { useMemo } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
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 { 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 { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||||
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
||||||
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
|
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
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, instances: Labels[]) => {
|
||||||
|
|
||||||
export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, potentialInstances: Labels[]) => {
|
|
||||||
const {
|
const {
|
||||||
data: currentData,
|
data: currentData,
|
||||||
isLoading: isPoliciesLoading,
|
isLoading: isPoliciesLoading,
|
||||||
error: policiesError,
|
error: policiesError,
|
||||||
} = useNotificationPolicyRoute({ alertmanager });
|
} = useNotificationPolicyRoute({ alertmanager });
|
||||||
|
|
||||||
const {
|
// this function will use a web worker to compute matching routes
|
||||||
contactPoints,
|
const { matchInstancesToRoutes } = useRouteGroupsMatcher();
|
||||||
isLoading: contactPointsLoading,
|
|
||||||
error: contactPointsError,
|
|
||||||
} = useContactPointsWithStatus({
|
|
||||||
alertmanager,
|
|
||||||
fetchPolicies: false,
|
|
||||||
fetchStatuses: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { matchInstancesToRoute } = useRouteGroupsMatcher();
|
|
||||||
|
|
||||||
const [defaultPolicy] = currentData ?? [];
|
const [defaultPolicy] = currentData ?? [];
|
||||||
const rootRoute = useMemo(() => {
|
const rootRoute = useMemo(() => {
|
||||||
|
|
@ -40,27 +27,9 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string,
|
||||||
return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy));
|
return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy));
|
||||||
}, [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
|
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
|
||||||
const {
|
const {
|
||||||
value: matchingMap = new Map<string, AlertInstanceMatch[]>(),
|
value: treeMatchingResults = [],
|
||||||
loading: matchingLoading,
|
loading: matchingLoading,
|
||||||
error: matchingError,
|
error: matchingError,
|
||||||
} = useAsync(async () => {
|
} = useAsync(async () => {
|
||||||
|
|
@ -68,16 +37,14 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await matchInstancesToRoute(rootRoute, potentialInstances, {
|
return await matchInstancesToRoutes(rootRoute, instances, {
|
||||||
unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME,
|
unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME,
|
||||||
});
|
});
|
||||||
}, [rootRoute, potentialInstances]);
|
}, [rootRoute, instances]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
routesByIdMap,
|
treeMatchingResults,
|
||||||
receiversByName,
|
isLoading: isPoliciesLoading || matchingLoading,
|
||||||
matchingMap,
|
error: policiesError ?? matchingError,
|
||||||
loading: isPoliciesLoading || contactPointsLoading || matchingLoading,
|
|
||||||
error: policiesError ?? contactPointsError ?? matchingError,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
|
import { InstanceMatchResult, matchInstancesToRoute } from '@grafana/alerting/unstable';
|
||||||
|
|
||||||
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
|
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
|
||||||
import { Labels } from '../../../types/unified-alerting-dto';
|
import { Labels } from '../../../types/unified-alerting-dto';
|
||||||
|
|
||||||
import {
|
import { findMatchingAlertGroups, normalizeRoute, unquoteRouteMatchers } from './utils/notification-policies';
|
||||||
AlertInstanceMatch,
|
import { routeAdapter } from './utils/routeAdapter';
|
||||||
findMatchingAlertGroups,
|
|
||||||
findMatchingRoutes,
|
|
||||||
normalizeRoute,
|
|
||||||
unquoteRouteMatchers,
|
|
||||||
} from './utils/notification-policies';
|
|
||||||
|
|
||||||
export interface MatchOptions {
|
export interface MatchOptions {
|
||||||
unquoteMatchers?: boolean;
|
unquoteMatchers?: boolean;
|
||||||
|
|
@ -34,29 +31,39 @@ export const routeGroupsMatcher = {
|
||||||
return routeGroupsMap;
|
return routeGroupsMap;
|
||||||
},
|
},
|
||||||
|
|
||||||
matchInstancesToRoute(
|
matchInstancesToRoutes(routeTree: RouteWithID, instances: Labels[], options?: MatchOptions): InstanceMatchResult[] {
|
||||||
routeTree: RouteWithID,
|
const normalizedRouteTree = getNormalizedRoute(routeTree, options);
|
||||||
instancesToMatch: Labels[],
|
|
||||||
options?: MatchOptions
|
|
||||||
): Map<string, AlertInstanceMatch[]> {
|
|
||||||
const result = new Map<string, AlertInstanceMatch[]>();
|
|
||||||
|
|
||||||
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) => {
|
// Convert the RouteWithID to the alerting package format to ensure compatibility
|
||||||
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
|
const convertedRoute = routeAdapter.toPackage(normalizedRouteTree);
|
||||||
matchingRoutes.forEach(({ route, labelsMatch }) => {
|
const { expandedTree, matchedPolicies } = matchInstancesToRoute(convertedRoute, allLabels);
|
||||||
const currentRoute = result.get(route.id);
|
|
||||||
|
|
||||||
if (currentRoute) {
|
// Group results by instance
|
||||||
currentRoute.push({ instance, labelsMatch });
|
return instances.map<InstanceMatchResult>((instance, index) => {
|
||||||
} else {
|
const labels = allLabels[index];
|
||||||
result.set(route.id, [{ instance, labelsMatch }]);
|
|
||||||
}
|
// 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,
|
mockRulerGrafanaRule,
|
||||||
mockRulerRuleGroup,
|
mockRulerRuleGroup,
|
||||||
} from '../mocks';
|
} from '../mocks';
|
||||||
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
|
import { grafanaRulerRule, mockPreviewApiResponse } from '../mocks/grafanaRulerApi';
|
||||||
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
|
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
|
||||||
import { setFolderResponse } from '../mocks/server/configure';
|
import { setFolderResponse } from '../mocks/server/configure';
|
||||||
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
|
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
|
||||||
|
|
@ -103,6 +103,7 @@ describe('CloneRuleEditor', function () {
|
||||||
};
|
};
|
||||||
setupDataSources(dataSources.default);
|
setupDataSources(dataSources.default);
|
||||||
setFolderResponse(mockFolder(folder));
|
setFolderResponse(mockFolder(folder));
|
||||||
|
mockPreviewApiResponse(server, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Grafana-managed rules', function () {
|
describe('Grafana-managed rules', function () {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
import { setupMswServer } from '../mockApi';
|
import { setupMswServer } from '../mockApi';
|
||||||
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
|
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 { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
|
||||||
import { setupDataSources } from '../testSetup/datasources';
|
import { setupDataSources } from '../testSetup/datasources';
|
||||||
import { Annotation } from '../utils/constants';
|
import { Annotation } from '../utils/constants';
|
||||||
|
|
@ -23,7 +23,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||||
|
|
||||||
jest.setTimeout(60 * 1000);
|
jest.setTimeout(60 * 1000);
|
||||||
|
|
||||||
setupMswServer();
|
const server = setupMswServer();
|
||||||
|
|
||||||
function renderRuleEditor(identifier: string) {
|
function renderRuleEditor(identifier: string) {
|
||||||
return render(
|
return render(
|
||||||
|
|
@ -85,6 +85,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||||
setupDataSources(dataSources.default);
|
setupDataSources(dataSources.default);
|
||||||
setFolderResponse(mockFolder(folder));
|
setFolderResponse(mockFolder(folder));
|
||||||
setFolderResponse(mockFolder(slashedFolder));
|
setFolderResponse(mockFolder(slashedFolder));
|
||||||
|
mockPreviewApiResponse(server, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can edit grafana managed rule', async () => {
|
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 { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
|
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 { setFolderResponse } from '../mocks/server/configure';
|
||||||
import { captureRequests, serializeRequests } from '../mocks/server/events';
|
import { captureRequests, serializeRequests } from '../mocks/server/events';
|
||||||
import { setupDataSources } from '../testSetup/datasources';
|
import { setupDataSources } from '../testSetup/datasources';
|
||||||
|
|
@ -25,7 +30,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||||
|
|
||||||
jest.setTimeout(60 * 1000);
|
jest.setTimeout(60 * 1000);
|
||||||
|
|
||||||
setupMswServer();
|
const server = setupMswServer();
|
||||||
|
|
||||||
const dataSources = {
|
const dataSources = {
|
||||||
default: mockDataSource(
|
default: mockDataSource(
|
||||||
|
|
@ -62,6 +67,8 @@ describe('RuleEditor grafana managed rules', () => {
|
||||||
AccessControlAction.AlertingRuleExternalRead,
|
AccessControlAction.AlertingRuleExternalRead,
|
||||||
AccessControlAction.AlertingRuleExternalWrite,
|
AccessControlAction.AlertingRuleExternalWrite,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
mockPreviewApiResponse(server, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can create new grafana managed alert', async () => {
|
it('can create new grafana managed alert', async () => {
|
||||||
|
|
|
||||||
|
|
@ -75,19 +75,19 @@ export function useRouteGroupsMatcher() {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchInstancesToRoute = useCallback(
|
const matchInstancesToRoutes = useCallback(
|
||||||
async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => {
|
async (rootRoute: RouteWithID, instances: Labels[], options?: MatchOptions) => {
|
||||||
validateWorker(routeMatcher);
|
validateWorker(routeMatcher);
|
||||||
|
|
||||||
const startTime = performance.now();
|
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;
|
const timeSpent = performance.now() - startTime;
|
||||||
|
|
||||||
logInfo(`Instances Matched in ${timeSpent} ms`, {
|
logInfo(`Instances Matched in ${timeSpent} ms`, {
|
||||||
matchingTime: timeSpent.toString(),
|
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
|
// Counting all nested routes might be too time-consuming, so we only count the first level
|
||||||
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
|
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 { isEqual, uniqWith } from 'lodash';
|
||||||
|
|
||||||
|
import { matchLabelsSet } from '@grafana/alerting/unstable';
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
|
|
@ -17,7 +18,12 @@ import { MatcherFieldValue } from '../types/silence-form';
|
||||||
import { getAllDataSources } from './config';
|
import { getAllDataSources } from './config';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
import { objectLabelsToArray } from './labels';
|
import { objectLabelsToArray } from './labels';
|
||||||
import { MatcherFormatter, matchLabelsSet, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers';
|
import {
|
||||||
|
MatcherFormatter,
|
||||||
|
convertObjectMatcherToAlertingPackageMatcher,
|
||||||
|
parsePromQLStyleMatcherLooseSafe,
|
||||||
|
unquoteWithUnescape,
|
||||||
|
} from './matchers';
|
||||||
|
|
||||||
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||||
// add default receiver if it does not exist
|
// 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 {
|
export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean {
|
||||||
const labelsArray = objectLabelsToArray(labels);
|
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 {
|
export function combineMatcherStrings(...matcherStrings: string[]): string {
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,8 @@ export const canDeleteEntity = (k8sEntity: EntityToCheck) =>
|
||||||
export const encodeFieldSelector = (value: string): string => {
|
export const encodeFieldSelector = (value: string): string => {
|
||||||
return value.replaceAll(/\\/g, '\\\\').replaceAll(/\=/g, '\\=').replaceAll(/,/g, '\\,');
|
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 { 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 { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { Labels } from '../../../../types/unified-alerting-dto';
|
import { Labels } from '../../../../types/unified-alerting-dto';
|
||||||
|
|
@ -228,6 +228,8 @@ export const matcherFormatter = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type MatcherFormatter = keyof typeof matcherFormatter;
|
||||||
|
|
||||||
export function isPromQLStyleMatcher(input: string): boolean {
|
export function isPromQLStyleMatcher(input: string): boolean {
|
||||||
return input.startsWith('{') && input.endsWith('}');
|
return input.startsWith('{') && input.endsWith('}');
|
||||||
}
|
}
|
||||||
|
|
@ -251,81 +253,12 @@ function matcherToOperator(matcher: Matcher): MatcherOperator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare set of matchers to set of label
|
export function convertObjectMatcherToAlertingPackageMatcher(matcher: ObjectMatcher): LabelMatcher {
|
||||||
export function matchLabelsSet(matchers: ObjectMatcher[], labels: Label[]): boolean {
|
const [label, type, value] = matcher;
|
||||||
for (const matcher of matchers) {
|
|
||||||
if (!isLabelMatchInSet(matcher, labels)) {
|
return {
|
||||||
return false;
|
label,
|
||||||
}
|
type,
|
||||||
}
|
value,
|
||||||
return true;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
import { createReturnTo } from '../hooks/useReturnTo';
|
import { createReturnTo } from '../hooks/useReturnTo';
|
||||||
|
|
@ -89,3 +90,12 @@ export const rulesNav = {
|
||||||
{ skipSubPath: options?.skipSubPath }
|
{ 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 {
|
import { normalizeRoute, unquoteRouteMatchers } from './notification-policies';
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeRoute', () => {
|
describe('normalizeRoute', () => {
|
||||||
it('should map matchers property to object_matchers', function () {
|
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', () => {
|
describe('unquoteRouteMatchers', () => {
|
||||||
it('should unquote and unescape matchers values', () => {
|
it('should unquote and unescape matchers values', () => {
|
||||||
const route: RouteWithID = {
|
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 { normalizeMatchers, unquoteWithUnescape } from './matchers';
|
||||||
import { Labels } from 'app/types/unified-alerting-dto';
|
import { routeAdapter } from './routeAdapter';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a performance improvement to normalize matchers only once and use the normalized version later on
|
// This is a performance improvement to normalize matchers only once and use the normalized version later on
|
||||||
export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
|
export function normalizeRoute<T extends Route>(rootRoute: T): T {
|
||||||
function normalizeRoute(route: RouteWithID) {
|
function normalizeRoute<T extends Route>(route: T) {
|
||||||
route.object_matchers = normalizeMatchers(route);
|
route.object_matchers = normalizeMatchers(route);
|
||||||
delete route.matchers;
|
delete route.matchers;
|
||||||
delete route.match;
|
delete route.match;
|
||||||
|
|
@ -108,8 +20,8 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
|
||||||
return normalizedRootRoute;
|
return normalizedRootRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unquoteRouteMatchers(route: RouteWithID): RouteWithID {
|
export function unquoteRouteMatchers<T extends Route>(route: T): T {
|
||||||
function unquoteRoute(route: RouteWithID) {
|
function unquoteRoute(route: Route) {
|
||||||
route.object_matchers = route.object_matchers?.map(([name, operator, value]) => {
|
route.object_matchers = route.object_matchers?.map(([name, operator, value]) => {
|
||||||
return [unquoteWithUnescape(name), operator, unquoteWithUnescape(value)];
|
return [unquoteWithUnescape(name), operator, unquoteWithUnescape(value)];
|
||||||
});
|
});
|
||||||
|
|
@ -137,7 +49,11 @@ function findMatchingAlertGroups(
|
||||||
// find matching alerts in the current group
|
// find matching alerts in the current group
|
||||||
const matchingAlerts = group.alerts.filter((alert) => {
|
const matchingAlerts = group.alerts.filter((alert) => {
|
||||||
const labels = Object.entries(alert.labels);
|
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
|
// if the groups has any alerts left after matching, add it to the results
|
||||||
|
|
@ -152,66 +68,6 @@ function findMatchingAlertGroups(
|
||||||
}, matchingGroups);
|
}, 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)
|
// recursive function to rename receivers in all routes (notification policies)
|
||||||
function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
|
function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
|
||||||
const updated: Route = {
|
const updated: Route = {
|
||||||
|
|
@ -229,4 +85,4 @@ function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
|
||||||
return updated;
|
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": {
|
"inspector-yaml-tab": {
|
||||||
"apply": "Apply"
|
"apply": "Apply"
|
||||||
},
|
},
|
||||||
|
"instance-match": {
|
||||||
|
"non-matching-labels": "Non-matching labels",
|
||||||
|
"notification-policy": "View route"
|
||||||
|
},
|
||||||
"irm-integration": {
|
"irm-integration": {
|
||||||
"connection-method": "How to connect to IRM",
|
"connection-method": "How to connect to IRM",
|
||||||
"disabled-description": "Enable IRM through a Webhook integration",
|
"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": {
|
"matcher-filter": {
|
||||||
"filter-alerts-using-label-querying-without-braces": "Filter alerts using label querying without braces, ex:",
|
"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:",
|
"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-notification-policies": "Notification Policies",
|
||||||
"label-time-intervals": "Time intervals"
|
"label-time-intervals": "Time intervals"
|
||||||
},
|
},
|
||||||
|
"notification-policy-drawer": {
|
||||||
|
"view-notification-policy-tree": "View notification policy tree"
|
||||||
|
},
|
||||||
"notification-policy-matchers": {
|
"notification-policy-matchers": {
|
||||||
"default-policy": "Default policy",
|
|
||||||
"no-matchers": "No matchers"
|
"no-matchers": "No matchers"
|
||||||
},
|
},
|
||||||
"notification-preview": {
|
"notification-preview": {
|
||||||
|
|
@ -1993,22 +2003,12 @@
|
||||||
},
|
},
|
||||||
"notification-route": {
|
"notification-route": {
|
||||||
"no-labels": "No labels",
|
"no-labels": "No labels",
|
||||||
"no-matching-labels": "No matching labels"
|
"notification-policy": "Notification policy"
|
||||||
},
|
|
||||||
"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-route-header": {
|
"notification-route-header": {
|
||||||
"aria-label-expand-policy-route": "Expand policy route",
|
"aria-label-expand-policy-route": "Expand policy route",
|
||||||
"delivered-to": "@ Delivered to",
|
"delivered-to": "Delivered to",
|
||||||
"notification-policy": "Notification policy",
|
"instances": "instances"
|
||||||
"see-details": "See details"
|
|
||||||
},
|
},
|
||||||
"notification-templates": {
|
"notification-templates": {
|
||||||
"duplicate": {
|
"duplicate": {
|
||||||
|
|
@ -2137,10 +2137,6 @@
|
||||||
"label-new-sibling-above": "New sibling above",
|
"label-new-sibling-above": "New sibling above",
|
||||||
"label-new-sibling-below": "New sibling below"
|
"label-new-sibling-below": "New sibling below"
|
||||||
},
|
},
|
||||||
"policy-path": {
|
|
||||||
"default-policy": "Default policy",
|
|
||||||
"no-matchers": "No matchers"
|
|
||||||
},
|
|
||||||
"preview-rule": {
|
"preview-rule": {
|
||||||
"body-preview-is-not-available": "Cannot display the query preview. Some of the data sources used in the queries are not available.",
|
"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",
|
"preview-alerts": "Preview alerts",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue