From 9a252c763a8c2becbe1deed62794bc2e42d3b1fe Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 19 Jun 2023 13:32:17 +0200 Subject: [PATCH] Alerting: Add notification policies preview in alert creation (#68839) * Add notification policies preview in alert rule form Co-authored-by: Konrad Lalik * Refactor using new useGetPotentialInstances hook and apply some style changes * Add notification policy detail modal * Use backtesting api for simulating potential alert instances * Fix logic to travserse all the children from the root route * Split notification preview by alert manager * Add instance count to matching policy header and fix some styles * Move some logic to a new hook useGetAlertManagersSourceNames to make the code more clean * Fix some tests * Add initial test for NotificationPreview * Use button to preview potential instances * Add link to contact point details * Add route matching result details * Show AlertManager image in the routing preview list * Add tests setup, add single AM preview test * Handle no matchers and no labels use case * Update some style in collapse component and fix policy path in modal * Update modal styles * Update styles * Update collapse header styling * Normalize tree nodes should happen before findMatchingRoutes call * Fix findMatchingRoutes and findMatchingAlertGroups methods after reabasing * Move instances matching to the web worker code * Fix config fetching for vanilla prometheus AMs * Add tests * Add tests mocks * Fix tests after adding web worker * Display matching labels for each matching alert instance * Add minor css improvements * Revert changes added in Collapse component as we don't use it anymore * Move the route details modal to a separate file * Move NotificationRoute and preview hook into separate files * Fix Alertmanager preview tests * Fix tests * Move matcher code to a separate file, improve matcher mock * Add permissions control for contact point edit view link * Fix from and to for the temporal use of backtesting api * Fix tests, add lazy loading of the preview component Co-authored-by: Sonia Aguilar * Fix preview test * Add onclick on the header div so it collapse and expands when clicking on it, and update styles to be consistent with the rest of tables * Adapt the code to the new rule testing endpoint definition * Fix tests * small changes after reviewing the final code * compute entire inherited tree before computing the routes map * Throw error in case of not having receiver in routesByIdMap and add test for the use case of inheriting receiver from parent to check UI throws no errors * Add list of labels in the policy route path that produces the policy matchers to match potential instances * Use color determined by the key, in label tags when hovering matchers in the policy tree * Remove labels in modal and handle empty string as receiver to inherit from parent as we do with undefined * Revert "Add list of labels in the policy route path that produces the policy matchers to match potential instances" This reverts commit ee73ae9cf9caf10c33bcd58973279f23437a9146. * fix inheritance for computeInheritedTree * Fix message shown when preview has not been executed yet * First round for adressing PR review comments * Adress the rest of PR review commments * Update texts and rename id prop in NotificaitonStep to alertUid --------- Co-authored-by: Konrad Lalik Co-authored-by: Gilles De Mey --- .../src/components/Tags/TagList.tsx | 47 +- packages/grafana-ui/src/utils/tags.ts | 8 +- .../alerting/unified/AlertGroups.test.tsx | 1 + .../alerting/unified/CloneRuleEditor.test.tsx | 28 ++ .../alerting/unified/ExistingRuleEditor.tsx | 5 +- .../unified/NotificationPolicies.test.tsx | 2 + .../alerting/unified/NotificationPolicies.tsx | 18 +- .../features/alerting/unified/RuleEditor.tsx | 4 +- .../unified/RuleEditorGrafanaRules.test.tsx | 3 +- .../__mocks__/useRouteGroupsMatcher.ts | 22 +- .../PanelAlertTabContent.test.tsx.snap | 4 - .../alerting/unified/api/alertRuleApi.ts | 80 ++++ .../notification-policies/Filters.tsx | 24 +- .../components/rule-editor/AlertRuleForm.tsx | 9 +- .../components/rule-editor/FolderAndGroup.tsx | 6 +- .../rule-editor/GrafanaEvaluationBehavior.tsx | 11 +- .../rule-editor/NotificationsStep.tsx | 29 +- .../NotificationPolicyMatchers.tsx | 31 ++ .../NotificationPreview.test.tsx | 415 ++++++++++++++++++ .../NotificationPreview.tsx | 146 ++++++ .../NotificationPreviewByAlertManager.tsx | 117 +++++ .../notificaton-preview/NotificationRoute.tsx | 238 ++++++++++ .../NotificationRouteDetailsModal.tsx | 176 ++++++++ .../rule-editor/notificaton-preview/route.ts | 26 ++ ...eAlertmanagerNotificationRoutingPreview.ts | 72 +++ ...useGetAlertManagersSourceNamesAndImage.tsx | 28 ++ .../components/rules/EditRuleGroupModal.tsx | 66 +-- .../unified/hooks/useAlertmanagerConfig.ts | 26 ++ .../app/features/alerting/unified/mockApi.ts | 141 ++++++ .../alerting/unified/mocks/alertRuleApi.ts | 8 + .../alerting/unified/mocks/alertmanagerApi.ts | 22 +- .../alerting/unified/routeGroupsMatcher.ts | 54 +++ .../unified/routeGroupsMatcher.worker.ts | 26 +- .../alerting/unified/state/actions.ts | 4 +- .../alerting/unified/types/rule-form.ts | 10 +- .../alerting/unified/useRouteGroupsMatcher.ts | 48 +- .../__snapshots__/rule-form.test.ts.snap | 2 + .../alerting/unified/utils/amroutes.ts | 11 +- ...etNumberEvaluationsToStartAlerting.test.ts | 3 +- .../features/alerting/unified/utils/labels.ts | 14 + .../utils/notification-policies.test.ts | 34 +- .../unified/utils/notification-policies.ts | 141 +++++- .../alerting/unified/utils/rule-form.ts | 18 +- .../features/alerting/unified/utils/rules.ts | 44 ++ .../features/alerting/unified/utils/time.ts | 8 + 45 files changed, 1998 insertions(+), 232 deletions(-) create mode 100644 public/app/features/alerting/unified/api/alertRuleApi.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx create mode 100644 public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts create mode 100644 public/app/features/alerting/unified/mockApi.ts create mode 100644 public/app/features/alerting/unified/mocks/alertRuleApi.ts create mode 100644 public/app/features/alerting/unified/routeGroupsMatcher.ts rename public/app/features/alerting/unified/{components/rules => utils}/getNumberEvaluationsToStartAlerting.test.ts (91%) diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index b9c8af15446..d0e903f4624 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -19,29 +19,40 @@ export interface Props { className?: string; /** aria-label for the `i`-th Tag component */ getAriaLabel?: (name: string, i: number) => string; + //** Should return an index of a color defined in the TAG_COLORS array */ + getColorIndex?: (name: string, i: number) => number; /** Icon to show next to tag label */ icon?: IconName; } const TagListComponent = memo( - forwardRef(({ displayMax, tags, icon, onClick, className, getAriaLabel }, ref) => { - const theme = useTheme2(); - const styles = getStyles(theme, Boolean(displayMax && displayMax > 0)); - const numTags = tags.length; - const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags; - return ( -
    - {tagsToDisplay.map((tag, i) => ( -
  • - -
  • - ))} - {displayMax && displayMax > 0 && numTags - displayMax > 0 && ( - + {numTags - displayMax} - )} -
- ); - }) + forwardRef( + ({ displayMax, tags, icon, onClick, className, getAriaLabel, getColorIndex }, ref) => { + const theme = useTheme2(); + const styles = getStyles(theme, Boolean(displayMax && displayMax > 0)); + const numTags = tags.length; + const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags; + return ( +
    + {tagsToDisplay.map((tag, i) => ( +
  • + +
  • + ))} + {displayMax && displayMax > 0 && numTags - displayMax > 0 && ( + + {numTags - displayMax} + )} +
+ ); + } + ) ); TagListComponent.displayName = 'TagList'; diff --git a/packages/grafana-ui/src/utils/tags.ts b/packages/grafana-ui/src/utils/tags.ts index ab845c6178f..cca4c2a4c58 100644 --- a/packages/grafana-ui/src/utils/tags.ts +++ b/packages/grafana-ui/src/utils/tags.ts @@ -62,13 +62,17 @@ const TAG_BORDER_COLORS = [ '#655181', ]; +export function getTagColorIndexFromName(name = ''): number { + const hash = djb2(name.toLowerCase()); + return Math.abs(hash % TAG_COLORS.length); +} + /** * Returns tag badge background and border colors based on hashed tag name. * @param name tag name */ export function getTagColorsFromName(name = ''): { color: string; borderColor: string } { - const hash = djb2(name.toLowerCase()); - const index = Math.abs(hash % TAG_COLORS.length); + const index = getTagColorIndexFromName(name); return getTagColor(index); } diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index f0ce4a2ddd5..e6c6c87163c 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -12,6 +12,7 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSr import { DataSourceType } from './utils/datasource'; jest.mock('./api/alertmanager'); + jest.mock('app/core/services/context_srv', () => ({ contextSrv: { isEditor: true, diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 408898d6566..da84f5cb16b 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -8,6 +8,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors/src'; import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import 'whatwg-fetch'; import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto'; @@ -15,10 +16,12 @@ import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto'; import { CloneRuleEditor } from './CloneRuleEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks'; +import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi'; import { mockSearchApiResponse } from './mocks/grafanaApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi'; import { RuleFormValues } from './types/rule-form'; import { Annotation } from './utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { getDefaultFormValues } from './utils/rule-form'; import { hashRulerRule } from './utils/rule-id'; @@ -29,6 +32,12 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); +// For simplicity of the test we mock the NotificationPreview component +// Otherwise we would need to mock a few more HTTP api calls which are not relevant for these tests +jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () => ({ + NotificationPreview: () =>
, +})); + const server = setupServer(); beforeAll(() => { @@ -97,6 +106,23 @@ function getProvidersWrapper() { }; } +const amConfig: AlertManagerCortexConfig = { + alertmanager_config: { + receivers: [{ name: 'default' }, { name: 'critical' }], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + }, + ], + }, + templates: [], + }, + template_files: {}, +}; + describe('CloneRuleEditor', function () { describe('Grafana-managed rules', function () { it('should populate form values from the existing alert rule', async function () { @@ -116,6 +142,7 @@ describe('CloneRuleEditor', function () { }); mockSearchApiResponse(server, []); + mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render(, { wrapper: getProvidersWrapper(), @@ -166,6 +193,7 @@ describe('CloneRuleEditor', function () { }); mockSearchApiResponse(server, []); + mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render( (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState)); const { @@ -61,5 +62,5 @@ export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) { return Sorry! You do not have permission to edit this rule.; } - return ; + return ; } diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx index fc80a85b6da..06ff0e59a56 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx @@ -29,6 +29,8 @@ import { getAllDataSources } from './utils/config'; import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import 'core-js/stable/structured-clone'; + jest.mock('./api/alertmanager'); jest.mock('./utils/config'); jest.mock('app/core/services/context_srv'); diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index dd84520d85a..36087bc1e97 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -34,8 +34,8 @@ import { import { Policy } from './components/notification-policies/Policy'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; -import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; +import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig'; +import { updateAlertManagerConfigAction } from './state/actions'; import { FormAmRoute } from './types/amroutes'; import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from './utils/amroutes'; @@ -68,27 +68,15 @@ const AmRoutes = () => { const alertManagers = useAlertManagersByPermission('notification'); const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); - const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); const contactPointsState = useGetContactPointsState(alertManagerSourceName ?? ''); - useEffect(() => { - if (alertManagerSourceName) { - dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); - } - }, [alertManagerSourceName, dispatch]); + const { result, config, loading: resultLoading, error: resultError } = useAlertmanagerConfig(alertManagerSourceName); const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery( { amSourceName: alertManagerSourceName ?? '' }, { skip: !alertManagerSourceName } ); - const { - result, - loading: resultLoading, - error: resultError, - } = (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; - - const config = result?.alertmanager_config; const receivers = config?.receivers ?? []; const rootRoute = useMemo(() => { diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index e97c94f195e..6f5c2014dbd 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -64,13 +64,13 @@ const RuleEditor = ({ match }: RuleEditorProps) => { } if (identifier) { - return ; + return ; } if (copyFromIdentifier) { return ; } - + // new alert rule return ; }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index 99e943fb74f..d9d959098c6 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -1,4 +1,4 @@ -import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; import React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; @@ -163,6 +163,7 @@ describe('RuleEditor grafana managed rules', () => { is_paused: false, no_data_state: 'NoData', title: 'my great new rule', + uid: '', }, }, ], diff --git a/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts index 94ac4ea7a2d..fc4b41be7b7 100644 --- a/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts @@ -1,20 +1,18 @@ import { useCallback } from 'react'; +import { Labels } from '@grafana/data'; + import { AlertmanagerGroup, RouteWithID } from '../../../../plugins/datasource/alertmanager/types'; +import { routeGroupsMatcher } from '../routeGroupsMatcher'; export function useRouteGroupsMatcher() { - const getRouteGroupsMap = useCallback(async (route: RouteWithID, __: AlertmanagerGroup[]) => { - const groupsMap = new Map(); - function addRoutes(route: RouteWithID) { - groupsMap.set(route.id, []); - - route.routes?.forEach((r) => addRoutes(r)); - } - - addRoutes(route); - - return groupsMap; + const getRouteGroupsMap = useCallback(async (route: RouteWithID, groups: AlertmanagerGroup[]) => { + return routeGroupsMatcher.getRouteGroupsMap(route, groups); }, []); - return { getRouteGroupsMap }; + const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { + return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + }, []); + + return { getRouteGroupsMap, matchInstancesToRoute }; } diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index c1d0f7b6949..c6d28162c50 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -13,10 +13,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button }, ], "condition": "C", - "folder": { - "id": 1, - "title": "super folder", - }, "name": "mypanel", "queries": [ { diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts new file mode 100644 index 00000000000..836848e876c --- /dev/null +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -0,0 +1,80 @@ +import { RelativeTimeRange } from '@grafana/data'; +import { AlertQuery, Annotations, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto'; + +import { Folder } from '../components/rule-editor/RuleFolderPicker'; +import { arrayKeyValuesToObject } from '../utils/labels'; + +import { alertingApi } from './alertingApi'; + +export type ResponseLabels = { + labels: AlertInstances[]; +}; + +export type PreviewResponse = ResponseLabels[]; +export interface Datasource { + type: string; + uid: string; +} + +export const PREVIEW_URL = '/api/v1/rule/test/grafana'; +export interface Data { + refId: string; + relativeTimeRange: RelativeTimeRange; + queryType: string; + datasourceUid: string; + model: AlertQuery; +} +export interface GrafanaAlert { + data?: Data; + condition: string; + no_data_state: GrafanaAlertStateDecision; + title: string; +} + +export interface Rule { + grafana_alert: GrafanaAlert; + for: string; + labels: Labels; + annotations: Annotations; +} +export type AlertInstances = Record; + +export const alertRuleApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + preview: build.mutation< + PreviewResponse, + { + alertQueries: AlertQuery[]; + condition: string; + folder: Folder; + customLabels: Array<{ + key: string; + value: string; + }>; + alertName?: string; + alertUid?: string; + } + >({ + query: ({ alertQueries, condition, customLabels, folder, alertName, alertUid }) => ({ + url: PREVIEW_URL, + data: { + rule: { + grafana_alert: { + data: alertQueries, + condition: condition, + no_data_state: 'Alerting', + title: alertName, + uid: alertUid ?? 'N/A', + }, + for: '0s', + labels: arrayKeyValuesToObject(customLabels), + annotations: {}, + }, + folderUid: folder.uid, + folderTitle: folder.title, + }, + method: 'POST', + }), + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx index 33ee37d8042..ed07c0e1e36 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx @@ -1,14 +1,15 @@ import { css } from '@emotion/css'; -import { debounce, pick } from 'lodash'; +import { debounce } from 'lodash'; import React, { useCallback, useEffect, useRef } from 'react'; import { SelectableValue } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Button, Field, Icon, Input, Label as LabelElement, Select, Tooltip, useStyles2 } from '@grafana/ui'; -import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { ObjectMatcher, Receiver, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { matcherToObjectMatcher, parseMatchers } from '../../utils/alertmanager'; +import { getInheritedProperties } from '../../utils/notification-policies'; interface NotificationPoliciesFilterProps { receivers: Receiver[]; @@ -131,22 +132,15 @@ export function findRoutesMatchingPredicate(routeTree: RouteWithID, predicateFn: /** * This function will compute the full tree with inherited properties – this is mostly used for search and filtering */ -export function computeInheritedTree(routeTree: RouteWithID): RouteWithID { +export function computeInheritedTree(parent: T): T { return { - ...routeTree, - routes: routeTree.routes?.map((route) => { - const inheritableProperties = pick(routeTree, [ - 'receiver', - 'group_by', - 'group_wait', - 'group_interval', - 'repeat_interval', - 'mute_time_intervals', - ]); + ...parent, + routes: parent.routes?.map((child) => { + const inheritedProperties = getInheritedProperties(parent, child); return computeInheritedTree({ - ...inheritableProperties, - ...route, + ...child, + ...inheritedProperties, }); }), }; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 85fd1eb5de6..737ba5db488 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -20,7 +20,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect import { deleteRuleAction, saveRuleFormAction } from '../../state/actions'; import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { initialAsyncRequestState } from '../../utils/redux'; -import { getDefaultFormValues, getDefaultQueries, rulerRuleToFormValues } from '../../utils/rule-form'; +import { getDefaultFormValues, getDefaultQueries, MINUTE, rulerRuleToFormValues } from '../../utils/rule-form'; import * as ruleId from '../../utils/rule-id'; import { CloudEvaluationBehavior } from './CloudEvaluationBehavior'; @@ -69,14 +69,13 @@ const AlertRuleNameInput = () => { ); }; -export const MINUTE = '1m'; - type Props = { existing?: RuleWithLocation; prefill?: Partial; // Existing implies we modify existing rule. Prefill only provides default form values + id?: string; }; -export const AlertRuleForm = ({ existing, prefill }: Props) => { +export const AlertRuleForm = ({ existing, prefill, id }: Props) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const notifyApp = useAppNotification(); @@ -254,7 +253,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { )} - + )}
diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index 7468a50e883..2335dc2910d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -13,19 +13,19 @@ import { CombinedRuleGroup } from 'app/types/unified-alerting'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions'; -import { RuleForm, RuleFormValues } from '../../types/rule-form'; +import { RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { MINUTE } from '../../utils/rule-form'; import { isGrafanaRulerRule } from '../../utils/rules'; import { InfoIcon } from '../InfoIcon'; -import { MINUTE } from './AlertRuleForm'; import { Folder, RuleFolderPicker } from './RuleFolderPicker'; import { checkForPathSeparator } from './util'; export const SLICE_GROUP_RESULTS_TO = 1000; interface FolderAndGroupProps { - initialFolder: RuleForm | null; + initialFolder: Folder | null; } export const useGetGroupOptionsFromFolder = (folderTitle: string) => { diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 94bb9d33f1a..2e8c3fe90fa 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -4,23 +4,24 @@ import { RegisterOptions, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Button, Field, InlineLabel, Input, InputControl, useStyles2, Switch, Tooltip, Icon } from '@grafana/ui'; +import { Button, Field, Icon, InlineLabel, Input, InputControl, Switch, Tooltip, useStyles2 } from '@grafana/ui'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { logInfo, LogMessages } from '../../Analytics'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; -import { RuleForm, RuleFormValues } from '../../types/rule-form'; +import { RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { MINUTE } from '../../utils/rule-form'; import { parsePrometheusDuration } from '../../utils/time'; import { CollapseToggle } from '../CollapseToggle'; import { EditCloudGroupModal, evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; -import { MINUTE } from './AlertRuleForm'; import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { RuleEditorSection } from './RuleEditorSection'; +import { Folder } from './RuleFolderPicker'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds @@ -118,7 +119,7 @@ function FolderGroupAndEvaluationInterval({ evaluateEvery, setEvaluateEvery, }: { - initialFolder: RuleForm | null; + initialFolder: Folder | null; evaluateEvery: string; setEvaluateEvery: (value: string) => void; }) { @@ -253,7 +254,7 @@ export function GrafanaEvaluationBehavior({ setEvaluateEvery, existing, }: { - initialFolder: RuleForm | null; + initialFolder: Folder | null; evaluateEvery: string; setEvaluateEvery: (value: string) => void; existing: boolean; diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index 26432889163..4e755f31439 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -10,16 +10,29 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import LabelsField from './LabelsField'; import { RuleEditorSection } from './RuleEditorSection'; +import { NotificationPreview } from './notificaton-preview/NotificationPreview'; -export const NotificationsStep = () => { +type NotificationsStepProps = { + alertUid?: string; +}; +export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { const styles = useStyles2(getStyles); const { watch, getValues } = useFormContext(); - const type = watch('type'); + const [type, labels, queries, condition, folder, alertName] = watch([ + 'type', + 'labels', + 'queries', + 'condition', + 'folder', + 'name', + ]); const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; const hasLabelsDefined = getNonEmptyLabels(getValues('labels')).length > 0; + const shouldRenderPreview = Boolean(condition) && Boolean(folder) && type === RuleFormType.grafana; + return ( { + {shouldRenderPreview && + condition && + folder && ( // need to check for condition and folder again because of typescript + + )} ); }; diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx new file mode 100644 index 00000000000..ad459deedb0 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx @@ -0,0 +1,31 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { Matchers } from '../../notification-policies/Matchers'; + +import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; + +export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) { + const styles = useStyles2(getStyles); + if (isDefaultPolicy(route)) { + return
Default policy
; + } else if (hasEmptyMatchers(route)) { + return
No matchers
; + } else { + return ; + } +} + +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}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx new file mode 100644 index 00000000000..e9f00220b51 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx @@ -0,0 +1,415 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { byRole, byTestId, byText } from 'testing-library-selector'; + +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types/accessControl'; + +import 'core-js/stable/structured-clone'; +import { TestProvider } from '../../../../../../../test/helpers/TestProvider'; +import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types'; +import { Labels } from '../../../../../../types/unified-alerting-dto'; +import { mockApi, setupMswServer } from '../../../mockApi'; +import { mockAlertQuery } from '../../../mocks'; +import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi'; +import * as dataSource from '../../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; +import { Folder } from '../RuleFolderPicker'; + +import { NotificationPreview } from './NotificationPreview'; +import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager'; +import * as notificationPreview from './useGetAlertManagersSourceNamesAndImage'; +import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage'; + +jest.mock('../../../useRouteGroupsMatcher'); + +jest + .spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage') + .mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]); + +jest.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage').mockReturnValue([ + { name: GRAFANA_RULES_SOURCE_NAME, img: '' }, + { name: GRAFANA_RULES_SOURCE_NAME, img: '' }, +]); + +jest.spyOn(dataSource, 'getDatasourceAPIUid').mockImplementation((ds: string) => ds); +jest.mock('app/core/services/context_srv'); +const contextSrvMock = jest.mocked(contextSrv); + +const useGetAlertManagersSourceNamesAndImageMock = useGetAlertManagersSourceNamesAndImage as jest.MockedFunction< + typeof useGetAlertManagersSourceNamesAndImage +>; + +const ui = { + route: byTestId('matching-policy-route'), + routeButton: byRole('button', { name: /Expand policy route/ }), + routeMatchingInstances: byTestId('route-matching-instance'), + loadingIndicator: byText(/Loading/), + previewButton: byRole('button', { name: /preview routing/i }), + grafanaAlertManagerLabel: byText(/alert manager:grafana/i), + otherAlertManagerLabel: byText(/alert manager:other_am/i), + seeDetails: byText(/see details/i), + details: { + title: byRole('heading', { name: /routing details/i }), + modal: byRole('dialog'), + linkToContactPoint: byRole('link', { name: /see details/i }), + }, +}; + +const server = setupMswServer(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const alertQuery = mockAlertQuery({ datasourceUid: 'whatever', refId: 'A' }); + +function mockOneAlertManager() { + useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]); + mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) => + amConfigBuilder + .withRoute((routeBuilder) => + routeBuilder + .withReceiver('email') + .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red')) + .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')) + ); +} + +function mockTwoAlertManagers() { + useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([ + { name: GRAFANA_RULES_SOURCE_NAME, img: '' }, + { name: 'OTHER_AM', img: '' }, + ]); + mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) => + amConfigBuilder + .withRoute((routeBuilder) => + routeBuilder + .withReceiver('email') + .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red')) + .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')) + ); + mockApi(server).getAlertmanagerConfig('OTHER_AM', (amConfigBuilder) => + amConfigBuilder + .withRoute((routeBuilder) => + routeBuilder + .withReceiver('email') + .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red')) + .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')) + ); +} + +function mockHasEditPermission(enabled: boolean) { + contextSrvMock.accessControlEnabled.mockReturnValue(true); + contextSrvMock.hasAccess.mockImplementation((action) => { + const onlyReadPermissions: string[] = [ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]; + const readAndWritePermissions: string[] = [ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsExternalWrite, + ]; + return enabled ? readAndWritePermissions.includes(action) : onlyReadPermissions.includes(action); + }); +} + +const folder: Folder = { + uid: '1', + title: 'title', +}; + +describe('NotificationPreview', () => { + it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => { + mockOneAlertManager(); + mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + + render(, { + wrapper: TestProvider, + }); + + await userEvent.click(ui.previewButton.get()); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + // we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts + expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument(); + expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument(); + + const matchingPoliciesElements = ui.route.queryAll(); + expect(matchingPoliciesElements).toHaveLength(1); + 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 () => { + // two alert managers configured to receive alerts + mockTwoAlertManagers(); + mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + + render(, { + wrapper: TestProvider, + }); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + + await userEvent.click(ui.previewButton.get()); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + + // we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts + expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument(); + + expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument(); + + const matchingPoliciesElements = ui.route.queryAll(); + expect(matchingPoliciesElements).toHaveLength(2); + expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); + expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/); + }); + it('should render details modal when clicking see details button', async () => { + // two alert managers configured to receive alerts + mockOneAlertManager(); + mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + mockHasEditPermission(true); + + render(, { + wrapper: TestProvider, + }); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + + await userEvent.click(ui.previewButton.get()); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + //open details modal + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + await userEvent.click(ui.seeDetails.get()); + expect(ui.details.title.query()).toBeInTheDocument(); + //we expect seeing the default policy + expect(screen.getByText(/default policy/i)).toBeInTheDocument(); + const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers'); + expect(matchingPoliciesElements).toHaveLength(1); + expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); + expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument(); + expect(ui.details.linkToContactPoint.get()).toBeInTheDocument(); + }); + it('should not render contact point link in details modal if user has no permissions for editing contact points', async () => { + // two alert managers configured to receive alerts + mockOneAlertManager(); + mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + mockHasEditPermission(false); + + render(, { + wrapper: TestProvider, + }); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + + await userEvent.click(ui.previewButton.get()); + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + //open details modal + await waitFor(() => { + expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + }); + await userEvent.click(ui.seeDetails.get()); + expect(ui.details.title.query()).toBeInTheDocument(); + //we expect seeing the default policy + expect(screen.getByText(/default policy/i)).toBeInTheDocument(); + const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers'); + expect(matchingPoliciesElements).toHaveLength(1); + expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); + expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument(); + expect(ui.details.linkToContactPoint.query()).not.toBeInTheDocument(); + }); +}); + +describe('NotificationPreviewByAlertmanager', () => { + it('should render route matching preview for alertmanager', async () => { + const potentialInstances: Labels[] = [ + { foo: 'bar', severity: 'critical' }, + { job: 'prometheus', severity: 'warning' }, + ]; + + mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (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')) + ); + + const user = userEvent.setup(); + + render( + , + { wrapper: TestProvider } + ); + + 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' }, + ]; + + mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (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')) + ); + + const user = userEvent.setup(); + + render( + , + { wrapper: TestProvider } + ); + + 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' }, + ]; + + mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (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')) + ); + + const user = userEvent.setup(); + + render( + , + { wrapper: TestProvider } + ); + + 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/); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx new file mode 100644 index 00000000000..b625e659b98 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -0,0 +1,146 @@ +import { css } from '@emotion/css'; +import { compact } from 'lodash'; +import React, { lazy, Suspense } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { H4 } from '@grafana/ui/src/unstable'; +import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; +import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { Folder } from '../RuleFolderPicker'; + +import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage'; + +const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager')); + +interface NotificationPreviewProps { + customLabels: Array<{ + key: string; + value: string; + }>; + alertQueries: AlertQuery[]; + condition: string; + folder: Folder; + alertName?: string; + alertUid?: string; +} + +export const NotificationPreview = ({ + alertQueries, + customLabels, + condition, + folder, + alertName, + alertUid, +}: NotificationPreviewProps) => { + const styles = useStyles2(getStyles); + + const { usePreviewMutation } = alertRuleApi; + + const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = usePreviewMutation(); + + // potential instances are the instances that are going to be routed to the notification policies + // convert data to list of labels: are the representation of the potential instances + const potentialInstances = compact(data.flatMap((label) => label?.labels)); + + const onPreview = () => { + // Get the potential labels given the alert queries, the condition and the custom labels (autogenerated labels are calculated on the BE side) + trigger({ + alertQueries: alertQueries, + condition: condition, + customLabels: customLabels, + folder: folder, + alertName: alertName, + alertUid: alertUid, + }); + }; + + // Get list of alert managers source name + image + const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage(); + + const onlyOneAM = alertManagerSourceNamesAndImage.length === 1; + const renderHowToPreview = !Boolean(data?.length) && !isLoading; + + return ( + +
+
+

Alert instance routing preview

+
+
+ +
+
+ {!renderHowToPreview && ( +
+ Based on the labels added, alert instances are routed to the following notification policies. Expand each + notification policy below to view more details. +
+ )} + {isLoading &&
Loading...
} + {renderHowToPreview && ( +
+ {`When your query and labels are configured, click "Preview routing" to see the results here.`} +
+ )} + {!isLoading && !previewUninitialized && potentialInstances.length > 0 && ( + }> + {alertManagerSourceNamesAndImage.map((alertManagerSource) => ( + + ))} + + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + collapsableSection: css` + width: auto; + border: 0; + `, + textMuted: css` + color: ${theme.colors.text.secondary}; + `, + previewHowToText: css` + display: flex; + color: ${theme.colors.text.secondary}; + justify-content: center; + font-size: ${theme.typography.size.sm}; + `, + previewHeader: css` + margin: 0; + `, + routePreviewHeaderRow: css` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + `, + collapseLabel: css` + flex: 1; + `, + button: css` + justify-content: flex-end; + display: flex; + `, + tagsInDetails: css` + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + `, + policyPathItemMatchers: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx new file mode 100644 index 00000000000..c99c14239cd --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx @@ -0,0 +1,117 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; + +import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { Labels } from '../../../../../../types/unified-alerting-dto'; + +import { NotificationRoute } from './NotificationRoute'; +import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview'; +import { AlertManagerNameWithImage } from './useGetAlertManagersSourceNamesAndImage'; + +function NotificationPreviewByAlertManager({ + alertManagerSource, + potentialInstances, + onlyOneAM, +}: { + alertManagerSource: AlertManagerNameWithImage; + potentialInstances: Labels[]; + onlyOneAM: boolean; +}) { + const styles = useStyles2(getStyles); + + const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview( + alertManagerSource.name, + potentialInstances + ); + + if (error) { + return ( + + {error.message} + + ); + } + + if (loading) { + return ; + } + + const matchingPoliciesFound = matchingMap.size > 0; + + return matchingPoliciesFound ? ( +
+ {!onlyOneAM && ( + +
+
+ {' '} + Alert manager: + + {alertManagerSource.name} +
+
+
+ )} + + {Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => { + const route = routesByIdMap.get(routeId); + const receiver = route?.receiver && receiversByName.get(route.receiver); + + if (!route) { + return null; + } + if (!receiver) { + throw new Error('Receiver not found'); + } + return ( + + ); + })} + +
+ ) : null; +} + +// export default because we want to load the component dynamically using React.lazy +// Due to loading of the web worker we don't want to load this component when not necessary +export default withErrorBoundary(NotificationPreviewByAlertManager); + +const getStyles = (theme: GrafanaTheme2) => ({ + alertManagerRow: css` + margin-top: ${theme.spacing(2)}; + display: flex; + flex-direction: column; + gap: ${theme.spacing(1)}; + width: 100%; + `, + firstAlertManagerLine: css` + height: 1px; + width: ${theme.spacing(4)}; + background-color: ${theme.colors.secondary.main}; + `, + alertManagerName: css` + width: fit-content; + `, + secondAlertManagerLine: css` + height: 1px; + width: 100%; + flex: 1; + background-color: ${theme.colors.secondary.main}; + `, + img: css` + margin-left: ${theme.spacing(2)}; + width: ${theme.spacing(3)}; + height: ${theme.spacing(3)}; + margin-right: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx new file mode 100644 index 00000000000..de5ee4e12b2 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx @@ -0,0 +1,238 @@ +import { css, cx } from '@emotion/css'; +import { uniqueId } from 'lodash'; +import pluralize from 'pluralize'; +import React, { useState } from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/ui'; + +import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; +import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { AlertInstanceMatch } from '../../../utils/notification-policies'; +import { CollapseToggle } from '../../CollapseToggle'; +import { MetaText } from '../../MetaText'; +import { Spacer } from '../../Spacer'; + +import { NotificationPolicyMatchers } from './NotificationPolicyMatchers'; +import { NotificationRouteDetailsModal } from './NotificationRouteDetailsModal'; +import { RouteWithPath } from './route'; + +function NotificationRouteHeader({ + route, + receiver, + routesByIdMap, + instancesCount, + alertManagerSourceName, + expandRoute, + onExpandRouteClick, +}: { + route: RouteWithPath; + receiver: Receiver; + routesByIdMap: Map; + instancesCount: number; + alertManagerSourceName: string; + expandRoute: boolean; + onExpandRouteClick: (expand: boolean) => void; +}) { + const styles = useStyles2(getStyles); + const [showDetails, setShowDetails] = useState(false); + + const onClickDetails = () => { + setShowDetails(true); + }; + + // @TODO: re-use component ContactPointsHoverDetails from Policy once we have it for cloud AMs. + + return ( +
+ onExpandRouteClick(!isCollapsed)} + aria-label="Expand policy route" + /> + + +
onExpandRouteClick(!expandRoute)} className={styles.expandable}> + + Notification policy + + +
+ + + + {instancesCount ?? '-'} + {pluralize('instance', instancesCount)} + + +
+ @ Delivered to {receiver.name} +
+ +
+ + + + + + {showDetails && ( + setShowDetails(false)} + route={route} + receiver={receiver} + routesByIdMap={routesByIdMap} + alertManagerSourceName={alertManagerSourceName} + /> + )} +
+ ); +} + +interface NotificationRouteProps { + route: RouteWithPath; + receiver: Receiver; + instanceMatches: AlertInstanceMatch[]; + routesByIdMap: Map; + alertManagerSourceName: string; +} + +export function NotificationRoute({ + route, + instanceMatches, + receiver, + 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 ( +
+ + {expandRoute && ( + +
+ {instanceMatches.map((instanceMatch) => { + const matchArray = Array.from(instanceMatch.labelsMatch); + let 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 ( +
+ {matchArray.length > 0 ? ( + <> + {matchingLabels.length > 0 ? ( + mr.label)} + className={styles.labelList} + getColorIndex={(_, index) => matchingLabels[index].colorIndex} + /> + ) : ( +
No matching labels
+ )} +
+ mr.label)} + className={styles.labelList} + getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex} + /> + + ) : ( +
No labels
+ )} +
+ ); + })} +
+ + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + textMuted: css` + color: ${theme.colors.text.secondary}; + `, + textItalic: css` + font-style: italic; + `, + expandable: css` + cursor: pointer; + `, + routeHeader: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + align-items: center; + border-bottom: 1px solid ${theme.colors.border.weak}; + &:hover { + background-color: ${theme.components.table.rowHoverBackground}; + } + padding: ${theme.spacing(0.5, 0.5, 0.5, 0)}; + `, + labelList: css` + flex: 0 1 auto; + justify-content: flex-start; + `, + labelSeparator: css` + width: 1px; + background-color: ${theme.colors.border.weak}; + `, + tagListCard: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(2)}; + + position: relative; + background: ${theme.colors.background.secondary}; + padding: ${theme.spacing(1)}; + + border-radius: ${theme.shape.borderRadius(2)}; + border: solid 1px ${theme.colors.border.weak}; + `, + routeInstances: css` + padding: ${theme.spacing(1, 0, 1, 4)}; + position: relative; + + display: flex; + flex-direction: column; + gap: ${theme.spacing(1)}; + + &:before { + content: ''; + position: absolute; + left: ${theme.spacing(2)}; + height: calc(100% - ${theme.spacing(2)}); + width: ${theme.spacing(4)}; + border-left: solid 1px ${theme.colors.border.weak}; + } + `, + verticalBar: css` + width: 1px; + height: 20px; + background-color: ${theme.colors.secondary.main}; + margin-left: ${theme.spacing(1)}; + margin-right: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx new file mode 100644 index 00000000000..57a246b23eb --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx @@ -0,0 +1,176 @@ +import { css, cx } from '@emotion/css'; +import { compact } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Icon, Modal, useStyles2 } from '@grafana/ui'; + +import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; +import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { getNotificationsPermissions } from '../../../utils/access-control'; +import { makeAMLink } from '../../../utils/misc'; +import { Authorize } from '../../Authorize'; +import { Matchers } from '../../notification-policies/Matchers'; + +import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; + +function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map; route: RouteWithPath }) { + const styles = useStyles2(getStyles); + const routePathIds = route.path?.slice(1) ?? []; + const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; + + return ( +
+
Default policy
+ {routePathObjects.map((pathRoute, index) => { + return ( +
+
+ {hasEmptyMatchers(pathRoute) ? ( +
No matchers
+ ) : ( + + )} +
+
+ ); + })} +
+ ); +} + +interface NotificationRouteDetailsModalProps { + onClose: () => void; + route: RouteWithPath; + receiver: Receiver; + routesByIdMap: Map; + alertManagerSourceName: string; +} + +export function NotificationRouteDetailsModal({ + onClose, + route, + receiver, + routesByIdMap, + alertManagerSourceName, +}: NotificationRouteDetailsModalProps) { + const styles = useStyles2(getStyles); + const isDefault = isDefaultPolicy(route); + + const permissions = getNotificationsPermissions(alertManagerSourceName); + return ( + + +
Your alert instances are routed as follows.
+
Notification policy path
+ {isDefault &&
Default policy
} +
+ {!isDefault && ( + <> + + + )} +
+
+ + Contact point: + {receiver.name} + + + + + See details + + + +
+
+ +
+ + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + textMuted: css` + color: ${theme.colors.text.secondary}; + `, + link: css` + display: block; + color: ${theme.colors.text.link}; + `, + button: css` + justify-content: flex-end; + display: flex; + `, + detailsModal: css` + max-width: 560px; + `, + defaultPolicy: css` + padding: ${theme.spacing(0.5)}; + background: ${theme.colors.background.secondary}; + width: fit-content; + `, + contactPoint: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.spacing(1)}; + `, + policyPathWrapper: css` + display: flex; + flex-direction: column; + margin-top: ${theme.spacing(1)}; + `, + separator: (units: number) => css` + margin-top: ${theme.spacing(units)}; + `, + marginBottom: (units: number) => css` + margin-bottom: ${theme.spacing(theme.spacing(units))}; + `, + policyInPath: (index = 0, higlight = false) => css` + margin-left: ${30 + index * 30}px; + padding: ${theme.spacing(1)}; + margin-top: ${theme.spacing(1)}; + border: solid 1px ${theme.colors.border.weak}; + background: ${theme.colors.background.secondary}; + width: fit-content; + position: relative; + + ${ + higlight && + css` + border: solid 1px ${theme.colors.info.border}; + ` + }, + &:before { + content: ''; + position: absolute; + height: calc(100% - 10px); + width: ${theme.spacing(1)}; + border-left: solid 1px ${theme.colors.border.weak}; + border-bottom: solid 1px ${theme.colors.border.weak}; + margin-top: ${theme.spacing(-2)}; + margin-left: -17px; + } + } `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts new file mode 100644 index 00000000000..8e2647400be --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts @@ -0,0 +1,26 @@ +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 +export function getRoutesByIdMap(rootRoute: RouteWithID): Map { + const map = new Map(); + + 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; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts new file mode 100644 index 00000000000..77f99002f68 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react'; +import { useAsync } from 'react-use'; + +import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; +import { Labels } from '../../../../../../types/unified-alerting-dto'; +import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; +import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher'; +import { addUniqueIdentifierToRoute } from '../../../utils/amroutes'; +import { AlertInstanceMatch, normalizeRoute } from '../../../utils/notification-policies'; +import { computeInheritedTree } from '../../notification-policies/Filters'; + +import { getRoutesByIdMap, RouteWithPath } from './route'; + +export const useAlertmanagerNotificationRoutingPreview = ( + alertManagerSourceName: string, + potentialInstances: Labels[] +) => { + const { + config: AMConfig, + loading: configLoading, + error: configError, + } = useAlertmanagerConfig(alertManagerSourceName); + + const { matchInstancesToRoute } = useRouteGroupsMatcher(); + + // to create the list of matching contact points we need to first get the rootRoute + const { rootRoute, receivers } = useMemo(() => { + if (!AMConfig) { + return { + receivers: [], + rootRoute: undefined, + }; + } + + return { + rootRoute: AMConfig.route ? normalizeRoute(addUniqueIdentifierToRoute(AMConfig.route)) : undefined, + receivers: AMConfig.receivers ?? [], + }; + }, [AMConfig]); + + // 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: Map = rootRoute + ? getRoutesByIdMap(computeInheritedTree(rootRoute)) + : new Map(); + + // create map for receivers to be get by name + const receiversByName = + receivers.reduce((map, receiver) => { + return map.set(receiver.name, receiver); + }, new Map()) ?? new Map(); + + // match labels in the tree => map of notification policies and the alert instances (list of labels) in each one + const { + value: matchingMap = new Map(), + loading: matchingLoading, + error: matchingError, + } = useAsync(async () => { + if (!rootRoute) { + return; + } + return await matchInstancesToRoute(rootRoute, potentialInstances); + }, [rootRoute, potentialInstances]); + + return { + routesByIdMap, + receiversByName, + matchingMap, + loading: configLoading || matchingLoading, + error: configError ?? matchingError, + }; +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx new file mode 100644 index 00000000000..d858c55b237 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx @@ -0,0 +1,28 @@ +import { AlertmanagerChoice } from '../../../../../../plugins/datasource/alertmanager/types'; +import { alertmanagerApi } from '../../../api/alertmanagerApi'; +import { useExternalDataSourceAlertmanagers } from '../../../hooks/useExternalAmSelector'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; + +export interface AlertManagerNameWithImage { + name: string; + img: string; +} + +export const useGetAlertManagersSourceNamesAndImage = () => { + //get current alerting config + const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined); + + const externalDsAlertManagers: AlertManagerNameWithImage[] = useExternalDataSourceAlertmanagers().map((ds) => ({ + name: ds.dataSource.name, + img: ds.dataSource.meta.info.logos.small, + })); + const alertmanagerChoice = amConfigStatus?.alertmanagersChoice; + const alertManagerSourceNamesWithImage: AlertManagerNameWithImage[] = + alertmanagerChoice === AlertmanagerChoice.Internal + ? [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }] + : alertmanagerChoice === AlertmanagerChoice.External + ? externalDsAlertManagers + : [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }, ...externalDsAlertManagers]; + + return alertManagerSourceNamesWithImage; +}; diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 0ee23bfdd6a..87e5efbeb59 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -10,15 +10,15 @@ import { useAppNotification } from 'app/core/copy/appNotification'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; -import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; -import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules'; -import { parsePrometheusDuration } from '../../utils/time'; +import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules'; +import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { InfoIcon } from '../InfoIcon'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; @@ -26,12 +26,6 @@ import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior' const ITEMS_PER_PAGE = 10; -interface AlertInfo { - alertName: string; - forDuration: string; - evaluationsToFire: number; -} - function ForBadge({ message, error }: { message: string; error?: boolean }) { if (error) { return ; @@ -40,43 +34,7 @@ function ForBadge({ message, error }: { message: string; error?: boolean }) { } } -export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => { - const evalNumberMs = safeParseDurationstr(currentEvaluation); - const forNumber = safeParseDurationstr(forDuration); - if (forNumber === 0 && evalNumberMs !== 0) { - return 1; - } - if (evalNumberMs === 0) { - return 0; - } else { - const evaluationsBeforeCeil = forNumber / evalNumberMs; - return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1; - } -}; - -export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => { - const emptyAlert: AlertInfo = { - alertName: '', - forDuration: '0s', - evaluationsToFire: 0, - }; - if (isGrafanaRulerRule(alert)) { - return { - alertName: alert.grafana_alert.title, - forDuration: alert.for, - evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation), - }; - } - if (isAlertingRulerRule(alert)) { - return { - alertName: alert.alert, - forDuration: alert.for ?? '1m', - evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation), - }; - } - return emptyAlert; -}; -export const isValidEvaluation = (evaluation: string) => { +const isValidEvaluation = (evaluation: string) => { try { const duration = parsePrometheusDuration(evaluation); @@ -94,22 +52,6 @@ export const isValidEvaluation = (evaluation: string) => { } }; -export const getGroupFromRuler = ( - rulerRules: RulerRulesConfigDTO | null | undefined, - groupName: string, - folderName: string -) => { - const folderObj: Array> = rulerRules ? rulerRules[folderName] : []; - return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName); -}; -export const safeParseDurationstr = (duration: string): number => { - try { - return parsePrometheusDuration(duration); - } catch (e) { - return 0; - } -}; - type AlertsWithForTableColumnProps = DynamicTableColumnProps; type AlertsWithForTableProps = DynamicTableItemProps; diff --git a/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts b/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts new file mode 100644 index 00000000000..be9e98a7c38 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import { useDispatch } from 'app/types'; + +import { fetchAlertManagerConfigAction } from '../state/actions'; +import { initialAsyncRequestState } from '../utils/redux'; + +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; + +export function useAlertmanagerConfig(amSourceName?: string) { + const dispatch = useDispatch(); + + useEffect(() => { + if (amSourceName) { + dispatch(fetchAlertManagerConfigAction(amSourceName)); + } + }, [amSourceName, dispatch]); + + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + + const { result, loading, error } = (amSourceName && amConfigs[amSourceName]) || initialAsyncRequestState; + + const config = result?.alertmanager_config; + + return { result, config, loading, error }; +} diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts new file mode 100644 index 00000000000..7ce3e754d48 --- /dev/null +++ b/public/app/features/alerting/unified/mockApi.ts @@ -0,0 +1,141 @@ +import { rest } from 'msw'; +import { setupServer, SetupServer } from 'msw/node'; +import 'whatwg-fetch'; + +import { setBackendSrv } from '@grafana/runtime'; + +import { backendSrv } from '../../../core/services/backend_srv'; +import { + AlertmanagerConfig, + AlertManagerCortexConfig, + EmailConfig, + MatcherOperator, + Receiver, + Route, +} from '../../../plugins/datasource/alertmanager/types'; + +class AlertmanagerConfigBuilder { + private alertmanagerConfig: AlertmanagerConfig = { receivers: [] }; + + addReceivers(configure: (builder: AlertmanagerReceiverBuilder) => void): AlertmanagerConfigBuilder { + const receiverBuilder = new AlertmanagerReceiverBuilder(); + configure(receiverBuilder); + this.alertmanagerConfig.receivers?.push(receiverBuilder.build()); + return this; + } + + withRoute(configure: (routeBuilder: AlertmanagerRouteBuilder) => void): AlertmanagerConfigBuilder { + const routeBuilder = new AlertmanagerRouteBuilder(); + configure(routeBuilder); + + this.alertmanagerConfig.route = routeBuilder.build(); + + return this; + } + + build() { + return this.alertmanagerConfig; + } +} + +class AlertmanagerRouteBuilder { + private route: Route = { routes: [], object_matchers: [] }; + + withReceiver(receiver: string): AlertmanagerRouteBuilder { + this.route.receiver = receiver; + return this; + } + withoutReceiver(): AlertmanagerRouteBuilder { + return this; + } + withEmptyReceiver(): AlertmanagerRouteBuilder { + this.route.receiver = ''; + return this; + } + + addRoute(configure: (builder: AlertmanagerRouteBuilder) => void): AlertmanagerRouteBuilder { + const routeBuilder = new AlertmanagerRouteBuilder(); + configure(routeBuilder); + this.route.routes?.push(routeBuilder.build()); + return this; + } + + addMatcher(key: string, operator: MatcherOperator, value: string): AlertmanagerRouteBuilder { + this.route.object_matchers?.push([key, operator, value]); + return this; + } + + build() { + return this.route; + } +} + +class EmailConfigBuilder { + private emailConfig: EmailConfig = { to: '' }; + + withTo(to: string): EmailConfigBuilder { + this.emailConfig.to = to; + return this; + } + + build() { + return this.emailConfig; + } +} + +class AlertmanagerReceiverBuilder { + private receiver: Receiver = { name: '', email_configs: [] }; + + withName(name: string): AlertmanagerReceiverBuilder { + this.receiver.name = name; + return this; + } + + addEmailConfig(configure: (builder: EmailConfigBuilder) => void): AlertmanagerReceiverBuilder { + const builder = new EmailConfigBuilder(); + configure(builder); + this.receiver.email_configs?.push(builder.build()); + return this; + } + + build() { + return this.receiver; + } +} + +export function mockApi(server: SetupServer) { + return { + getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => { + const builder = new AlertmanagerConfigBuilder(); + configure(builder); + + server.use( + rest.get(`api/alertmanager/${amName}/config/api/v1/alerts`, (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + alertmanager_config: builder.build(), + template_files: {}, + }) + ) + ) + ); + }, + }; +} + +// Creates a MSW server and sets up beforeAll and afterAll handlers for it +export function setupMswServer() { + const server = setupServer(); + + beforeAll(() => { + setBackendSrv(backendSrv); + server.listen({ onUnhandledRequest: 'error' }); + }); + + afterAll(() => { + server.close(); + }); + + return server; +} diff --git a/public/app/features/alerting/unified/mocks/alertRuleApi.ts b/public/app/features/alerting/unified/mocks/alertRuleApi.ts new file mode 100644 index 00000000000..8cab914111e --- /dev/null +++ b/public/app/features/alerting/unified/mocks/alertRuleApi.ts @@ -0,0 +1,8 @@ +import { rest } from 'msw'; +import { SetupServer } from 'msw/node'; + +import { PreviewResponse, PREVIEW_URL } from '../api/alertRuleApi'; + +export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) { + server.use(rest.post(PREVIEW_URL, (req, res, ctx) => res(ctx.json(result)))); +} diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index 56555a1e237..90e63a4adb5 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -1,13 +1,29 @@ import { rest } from 'msw'; import { SetupServer } from 'msw/node'; -import { ExternalAlertmanagersResponse } from '../../../../plugins/datasource/alertmanager/types'; +import { + AlertManagerCortexConfig, + ExternalAlertmanagersResponse, +} from '../../../../plugins/datasource/alertmanager/types'; import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi'; +import { getDatasourceAPIUid } from '../utils/datasource'; -export function mockAlertmanagerChoiceResponse(server: SetupServer, respose: AlertmanagersChoiceResponse) { - server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(respose)))); +export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) { + server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); } export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) { server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); } + +export function mockAlertmanagerConfigResponse( + server: SetupServer, + alertManagerSourceName: string, + response: AlertManagerCortexConfig +) { + server.use( + rest.get(`/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/alerts`, (req, res, ctx) => + res(ctx.status(200), ctx.json(response)) + ) + ); +} diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.ts b/public/app/features/alerting/unified/routeGroupsMatcher.ts new file mode 100644 index 00000000000..9b5dd3c8c6f --- /dev/null +++ b/public/app/features/alerting/unified/routeGroupsMatcher.ts @@ -0,0 +1,54 @@ +import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; +import { Labels } from '../../../types/unified-alerting-dto'; + +import { + AlertInstanceMatch, + findMatchingAlertGroups, + findMatchingRoutes, + normalizeRoute, +} from './utils/notification-policies'; + +export const routeGroupsMatcher = { + getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map { + const normalizedRootRoute = normalizeRoute(rootRoute); + + function addRouteGroups(route: RouteWithID, acc: Map) { + const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); + acc.set(route.id, routeGroups); + + route.routes?.forEach((r) => addRouteGroups(r, acc)); + } + + const routeGroupsMap = new Map(); + addRouteGroups(normalizedRootRoute, routeGroupsMap); + + return routeGroupsMap; + }, + + matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map { + const result = new Map(); + + const normalizedRootRoute = normalizeRoute(routeTree); + + instancesToMatch.forEach((instance) => { + const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance)); + matchingRoutes.forEach(({ route, details, labelsMatch }) => { + // Only to convert Label[] to Labels[] - needs better approach + const matchDetails = new Map( + Array.from(details.entries()).map(([matcher, labels]) => [matcher, Object.fromEntries(labels)]) + ); + + const currentRoute = result.get(route.id); + if (currentRoute) { + currentRoute.push({ instance, matchDetails, labelsMatch }); + } else { + result.set(route.id, [{ instance, matchDetails, labelsMatch }]); + } + }); + }); + + return result; + }, +}; + +export type RouteGroupsMatcher = typeof routeGroupsMatcher; diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts b/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts index f4eac245b3a..75caff53e7d 100644 --- a/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts +++ b/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts @@ -1,27 +1,7 @@ import * as comlink from 'comlink'; -import type { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; - -import { findMatchingAlertGroups, normalizeRoute } from './utils/notification-policies'; - -const routeGroupsMatcher = { - getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map { - const normalizedRootRoute = normalizeRoute(rootRoute); - - function addRouteGroups(route: RouteWithID, acc: Map) { - const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); - acc.set(route.id, routeGroups); - - route.routes?.forEach((r) => addRouteGroups(r, acc)); - } - - const routeGroupsMap = new Map(); - addRouteGroups(normalizedRootRoute, routeGroupsMap); - - return routeGroupsMap; - }, -}; - -export type RouteGroupsMatcher = typeof routeGroupsMatcher; +import { routeGroupsMatcher } from './routeGroupsMatcher'; +// Worker is only a thin wrapper around routeGroupsMatcher to move processing to a separate thread +// routeGroupsMatcher should be used in mocks and tests because it's difficult to tests code with workers comlink.expose(routeGroupsMatcher); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b24f08115b8..7c96f819d83 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -61,7 +61,6 @@ import { FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; -import { getAlertInfo, safeParseDurationstr } from '../components/rules/EditRuleGroupModal'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { @@ -75,7 +74,8 @@ import { makeAMLink, retryWhile } from '../utils/misc'; import { AsyncRequestMapSlice, messageFromError, withAppEvents, withSerializedError } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; import { getRulerClient } from '../utils/rulerClient'; -import { isRulerNotSupportedResponse } from '../utils/rules'; +import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules'; +import { safeParseDurationstr } from '../utils/time'; const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000; diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 5a8e602b9df..8291878edaa 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -1,19 +1,17 @@ import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; +import { Folder } from '../components/rule-editor/RuleFolderPicker'; + export enum RuleFormType { grafana = 'grafana', cloudAlerting = 'cloud-alerting', cloudRecording = 'cloud-recording', } -export interface RuleForm { - title: string; - id: number; -} - export interface RuleFormValues { // common name: string; + uid: string; type?: RuleFormType; dataSourceName: string | null; group: string; @@ -26,7 +24,7 @@ export interface RuleFormValues { condition: string | null; // refId of the query that gets alerted on noDataState: GrafanaAlertStateDecision; execErrState: GrafanaAlertStateDecision; - folder: RuleForm | null; + folder: Folder | null; evaluateEvery: string; evaluateFor: string; isPaused?: boolean; diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index 324a5e0c238..36da0399851 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -5,11 +5,12 @@ import { useEnabled } from 'react-enable'; import { logError } from '@grafana/runtime'; import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; +import { Labels } from '../../../types/unified-alerting-dto'; import { logInfo } from './Analytics'; import { createWorker } from './createRouteGroupsMatcherWorker'; import { AlertingFeature } from './features'; -import type { RouteGroupsMatcher } from './routeGroupsMatcher.worker'; +import type { RouteGroupsMatcher } from './routeGroupsMatcher'; let routeMatcher: comlink.Remote | undefined; @@ -44,6 +45,19 @@ function loadWorker() { return { disposeWorker }; } +function validateWorker( + toggleEnabled: boolean, + matcher: typeof routeMatcher +): asserts matcher is comlink.Remote { + if (!toggleEnabled) { + throw new Error('Matching routes preview is disabled'); + } + + if (!routeMatcher) { + throw new Error('Route Matcher has not been initialized'); + } +} + export function useRouteGroupsMatcher() { const workerPreviewEnabled = useEnabled(AlertingFeature.NotificationPoliciesV2MatchingInstances); @@ -58,13 +72,7 @@ export function useRouteGroupsMatcher() { const getRouteGroupsMap = useCallback( async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => { - if (!workerPreviewEnabled) { - throw new Error('Matching routes preview is disabled'); - } - - if (!routeMatcher) { - throw new Error('Route Matcher has not been initialized'); - } + validateWorker(workerPreviewEnabled, routeMatcher); const startTime = performance.now(); @@ -84,5 +92,27 @@ export function useRouteGroupsMatcher() { [workerPreviewEnabled] ); - return { getRouteGroupsMap }; + const matchInstancesToRoute = useCallback( + async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { + validateWorker(workerPreviewEnabled, routeMatcher); + + const startTime = performance.now(); + + const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + + const timeSpent = performance.now() - startTime; + + logInfo(`Instances Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + instancesToMatchCount: instancesToMatch.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); + + return result; + }, + [workerPreviewEnabled] + ); + + return { getRouteGroupsMap, matchInstancesToRoute }; } diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index bfd1da8464e..c208aea1ff1 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -15,6 +15,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu "is_paused": false, "no_data_state": "NoData", "title": "", + "uid": "", }, "labels": { "": "", @@ -53,6 +54,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range "is_paused": false, "no_data_state": "NoData", "title": "", + "uid": "", }, "labels": { "": "", diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 6b93493ab19..f56baec8aa9 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -3,7 +3,6 @@ import { uniqueId } from 'lodash'; import { SelectableValue } from '@grafana/data'; import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; -import { safeParseDurationstr } from '../components/rules/EditRuleGroupModal'; import { FormAmRoute } from '../types/amroutes'; import { MatcherFieldValue } from '../types/silence-form'; @@ -11,7 +10,7 @@ import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { normalizeMatchers, parseMatcher } from './matchers'; import { findExistingRoute } from './routeTree'; -import { isValidPrometheusDuration } from './time'; +import { isValidPrometheusDuration, safeParseDurationstr } from './time'; const matchersToArrayFieldMatchers = ( matchers: Record | undefined, @@ -230,6 +229,14 @@ export function promDurationValidator(duration: string) { return isValidPrometheusDuration(duration) || 'Invalid duration format. Must be {number}{time_unit}'; } +// function to convert ObjectMatchers to a array of strings +export const objectMatchersToString = (matchers: ObjectMatcher[]): string[] => { + return matchers.map((matcher) => { + const [name, operator, value] = matcher; + return `${name}${operator}${value}`; + }); +}; + export const repeatIntervalValidator = (repeatInterval: string, groupInterval: string) => { if (repeatInterval.length === 0) { return true; diff --git a/public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts b/public/app/features/alerting/unified/utils/getNumberEvaluationsToStartAlerting.test.ts similarity index 91% rename from public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts rename to public/app/features/alerting/unified/utils/getNumberEvaluationsToStartAlerting.test.ts index f056e741a90..d3053bd3c52 100644 --- a/public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts +++ b/public/app/features/alerting/unified/utils/getNumberEvaluationsToStartAlerting.test.ts @@ -1,4 +1,5 @@ -import { getNumberEvaluationsToStartAlerting } from './EditRuleGroupModal'; +import { getNumberEvaluationsToStartAlerting } from './rules'; + describe('getNumberEvaluationsToStartAlerting method', () => { it('should return 0 in case of invalid data', () => { expect(getNumberEvaluationsToStartAlerting('sd', 'ksdh')).toBe(0); diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index 5471520a3f5..49da17ab97a 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -18,3 +18,17 @@ export function arrayLabelsToObject(labels: Label[]): Labels { }); return labelsObject; } + +export function arrayKeyValuesToObject( + labels: Array<{ + key: string; + value: string; + }> +): Labels { + const labelsObject: Labels = {}; + labels.forEach((label) => { + label.key && (labelsObject[label.key] = label.value); + }); + + return labelsObject; +} diff --git a/public/app/features/alerting/unified/utils/notification-policies.test.ts b/public/app/features/alerting/unified/utils/notification-policies.test.ts index fe09c3f8810..9f31d88aff1 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.test.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.test.ts @@ -41,13 +41,13 @@ describe('findMatchingRoutes', () => { it('should match root route with no matching labels', () => { const matches = findMatchingRoutes(policies, []); expect(matches).toHaveLength(1); - expect(matches[0]).toHaveProperty('receiver', 'ROOT'); + 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]).toHaveProperty('receiver', 'A'); + expect(matches[0].route).toHaveProperty('receiver', 'A'); }); it('should match child route of matching parent', () => { @@ -56,28 +56,28 @@ describe('findMatchingRoutes', () => { ['region', 'europe'], ]); expect(matches).toHaveLength(1); - expect(matches[0]).toHaveProperty('receiver', 'B1'); + 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]).toHaveProperty('receiver', 'C'); + expect(matches[0].route).toHaveProperty('receiver', 'C'); }); it('should match catch-all route', () => { - const policiesWithAll = { + const policiesWithAll: Route = { ...policies, routes: [CATCH_ALL_ROUTE, ...(policies.routes ?? [])], }; const matches = findMatchingRoutes(policiesWithAll, []); expect(matches).toHaveLength(1); - expect(matches[0]).toHaveProperty('receiver', 'ALL'); + expect(matches[0].route).toHaveProperty('receiver', 'ALL'); }); it('should match multiple routes with continue', () => { - const policiesWithAll = { + const policiesWithAll: Route = { ...policies, routes: [ { @@ -90,8 +90,8 @@ describe('findMatchingRoutes', () => { const matches = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]); expect(matches).toHaveLength(2); - expect(matches[0]).toHaveProperty('receiver', 'ALL'); - expect(matches[1]).toHaveProperty('receiver', 'C'); + 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', () => { @@ -117,7 +117,7 @@ describe('findMatchingRoutes', () => { const matches = findMatchingRoutes(policies, [['foo', 'bar']]); expect(matches).toHaveLength(1); - expect(matches[0]).toHaveProperty('receiver', 'PARENT'); + expect(matches[0].route).toHaveProperty('receiver', 'PARENT'); }); }); @@ -242,6 +242,20 @@ describe('getInheritedProperties()', () => { 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'); + }); }); }); diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index 8cb49720dcc..a42bcda480b 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -1,28 +1,121 @@ import { isArray, merge, pick, reduce } from 'lodash'; -import { AlertmanagerGroup, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { + AlertmanagerGroup, + MatcherOperator, + ObjectMatcher, + Route, + RouteWithID, +} from 'app/plugins/datasource/alertmanager/types'; +import { Labels } from 'app/types/unified-alerting-dto'; -import { Label, normalizeMatchers, labelsMatchObjectMatchers } from './matchers'; +import { Label, normalizeMatchers } from './matchers'; + +type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean; + +const OperatorFunctions: Record = { + [MatcherOperator.equal]: (lv, mv) => lv === mv, + [MatcherOperator.notEqual]: (lv, mv) => lv !== mv, + [MatcherOperator.regex]: (lv, mv) => Boolean(lv.match(new RegExp(mv))), + [MatcherOperator.notRegex]: (lv, mv) => !Boolean(lv.match(new RegExp(mv))), +}; + +function isLabelMatch(matcher: ObjectMatcher, label: Label) { + const [labelKey, labelValue] = label; + const [matcherKey, operator, matcherValue] = matcher; + + // not interested, keys don't match + if (labelKey !== matcherKey) { + return false; + } + + const matchFunction = OperatorFunctions[operator]; + if (!matchFunction) { + throw new Error(`no such operator: ${operator}`); + } + + return matchFunction(labelValue, matcherValue); +} + +interface LabelMatchResult { + match: boolean; + matchers: ObjectMatcher[]; +} + +interface MatchingResult { + matches: boolean; + details: Map; + labelsMatch: Map; +} + +// check if every matcher returns "true" for the set of labels +function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult { + const details = new Map(); + + // 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 empty array of matchers as an indicator of no match + const labelsMatch = new Map( + labels.map((label) => [label, { match: false, matchers: [] }]) + ); + + const matches = matchers.every((matcher) => { + const matchingLabels = labels.filter((label) => isLabelMatch(matcher, label)); + + matchingLabels.forEach((label) => { + const labelMatch = labelsMatch.get(label); + // The condition is just to satisfy TS. The map should have all the labels due to the previous map initialization + if (labelMatch) { + labelMatch.match = true; + labelMatch.matchers.push(matcher); + } + }); + + if (matchingLabels.length === 0) { + return false; + } + + details.set(matcher, matchingLabels); + return matchingLabels.length > 0; + }); + + return { matches, details, labelsMatch }; +} + +export interface AlertInstanceMatch { + instance: Labels; + matchDetails: Map; + labelsMatch: Map; +} + +export interface RouteMatchResult { + route: T; + details: Map; + labelsMatch: Map; +} // Match does a depth-first left-to-right search through the route tree // and returns the matching routing nodes. -function findMatchingRoutes(root: Route, labels: Label[]): Route[] { - const matches: Route[] = []; + +// If the current node is not a match, return nothing +// const normalizedMatchers = normalizeMatchers(root); +// Normalization should have happened earlier in the code +function findMatchingRoutes(root: T, labels: Label[]): Array> { + let matches: Array> = []; // If the current node is not a match, return nothing - // const normalizedMatchers = normalizeMatchers(root); - // Normalization should have happened earlier in the code - if (!root.object_matchers || !labelsMatchObjectMatchers(root.object_matchers, labels)) { + const matchResult = matchLabels(root.object_matchers ?? [], labels); + if (!matchResult.matches) { return []; } // If the current node matches, recurse through child nodes if (root.routes) { - for (const child of root.routes) { - const matchingChildren = findMatchingRoutes(child, labels); - - matches.push(...matchingChildren); - + for (let index = 0; index < root.routes.length; index++) { + let child = root.routes[index]; + let matchingChildren = findMatchingRoutes(child, labels); + // TODO how do I solve this typescript thingy? It looks correct to me /shrug + // @ts-ignore + matches = matches.concat(matchingChildren); // we have matching children and we don't want to continue, so break here if (matchingChildren.length && !child.continue) { break; @@ -32,7 +125,7 @@ function findMatchingRoutes(root: Route, labels: Label[]): Route[] { // If no child nodes were matches, the current node itself is a match. if (matches.length === 0) { - matches.push(root); + matches.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch }); } return matches; @@ -69,7 +162,7 @@ function findMatchingAlertGroups( // find matching alerts in the current group const matchingAlerts = group.alerts.filter((alert) => { const labels = Object.entries(alert.labels); - return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute === route); + return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute.route === route); }); // if the groups has any alerts left after matching, add it to the results @@ -110,21 +203,27 @@ function getInheritedProperties( const inherited = reduce( inheritableProperties, (inheritedProperties: Partial = {}, parentValue, property) => { + const parentHasValue = parentValue !== undefined; + // @ts-ignore - const inheritFromParent = parentValue !== undefined && childRoute[property] === undefined; + const inheritFromParentUndefined = parentHasValue && childRoute[property] === undefined; + // @ts-ignore + const inheritFromParentEmptyString = parentHasValue && childRoute[property] === ''; + const inheritEmptyGroupByFromParent = - property === 'group_by' && isArray(childRoute[property]) && childRoute[property]?.length === 0; + property === 'group_by' && + parentHasValue && + isArray(childRoute[property]) && + childRoute[property]?.length === 0; + + const inheritFromParent = + inheritFromParentUndefined || inheritFromParentEmptyString || inheritEmptyGroupByFromParent; if (inheritFromParent) { // @ts-ignore inheritedProperties[property] = parentValue; } - if (inheritEmptyGroupByFromParent) { - // @ts-ignore - inheritedProperties[property] = parentValue; - } - return inheritedProperties; }, {} diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 7c3bf3d18c1..fd3bdfffe01 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,5 +1,6 @@ import { DataQuery, + DataSourceInstanceSettings, DataSourceRef, getDefaultRelativeTimeRange, IntervalValues, @@ -7,14 +8,13 @@ import { RelativeTimeRange, ScopedVars, TimeRange, - DataSourceInstanceSettings, } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DataSourceJsonData } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; -import { ExpressionQuery, ExpressionQueryType, ExpressionDatasourceUID } from 'app/features/expressions/types'; +import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; @@ -31,7 +31,6 @@ import { } from 'app/types/unified-alerting-dto'; import { EvalFunction } from '../../state/alertDef'; -import { MINUTE } from '../components/rule-editor/AlertRuleForm'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { getRulesAccess } from './access-control'; @@ -43,11 +42,14 @@ import { parseInterval } from './time'; export type PromOrLokiQuery = PromQuery | LokiQuery; +export const MINUTE = '1m'; + export const getDefaultFormValues = (): RuleFormValues => { const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); return Object.freeze({ name: '', + uid: '', labels: [{ key: '', value: '' }], annotations: [ { key: Annotation.summary, value: '' }, @@ -106,6 +108,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl return { grafana_alert: { title: name, + uid: values.uid, condition, no_data_state: noDataState, exec_err_state: execErrState, @@ -131,6 +134,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF return { ...defaultFormValues, name: ga.title, + uid: ga.uid, type: RuleFormType.grafana, group: group.name, evaluateEvery: group.interval || defaultFormValues.evaluateEvery, @@ -141,7 +145,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF condition: ga.condition, annotations: listifyLabelsOrAnnotations(rule.annotations), labels: listifyLabelsOrAnnotations(rule.labels), - folder: { title: namespace, id: ga.namespace_id }, + folder: { title: namespace, uid: ga.namespace_uid }, isPaused: ga.is_paused, }; } else { @@ -416,14 +420,14 @@ export const panelToRuleFormValues = async ( queries.push(thresholdExpression); } - const { folderId, folderTitle } = dashboard.meta; + const { folderTitle, folderUid } = dashboard.meta; const formValues = { type: RuleFormType.grafana, folder: - folderId && folderTitle + folderUid && folderTitle ? { - id: folderId, + uid: folderUid, title: folderTitle, } : undefined, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index c90e8f7b021..5cc7210935b 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -35,6 +35,7 @@ import { RuleHealth } from '../search/rulesSearchParser'; import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { getRulesSourceName } from './datasource'; import { AsyncRequestState } from './redux'; +import { safeParseDurationstr } from './time'; export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { return typeof rule === 'object' && rule.type === PromRuleType.Alerting; @@ -205,3 +206,46 @@ export function getRuleName(rule: RulerRuleDTO) { return ''; } + +export interface AlertInfo { + alertName: string; + forDuration: string; + evaluationsToFire: number; +} + +export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => { + const emptyAlert: AlertInfo = { + alertName: '', + forDuration: '0s', + evaluationsToFire: 0, + }; + if (isGrafanaRulerRule(alert)) { + return { + alertName: alert.grafana_alert.title, + forDuration: alert.for, + evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation), + }; + } + if (isAlertingRulerRule(alert)) { + return { + alertName: alert.alert, + forDuration: alert.for ?? '1m', + evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation), + }; + } + return emptyAlert; +}; + +export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => { + const evalNumberMs = safeParseDurationstr(currentEvaluation); + const forNumber = safeParseDurationstr(forDuration); + if (forNumber === 0 && evalNumberMs !== 0) { + return 1; + } + if (evalNumberMs === 0) { + return 0; + } else { + const evaluationsBeforeCeil = forNumber / evalNumberMs; + return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1; + } +}; diff --git a/public/app/features/alerting/unified/utils/time.ts b/public/app/features/alerting/unified/utils/time.ts index 9a96fa1f4aa..8c65c35b93f 100644 --- a/public/app/features/alerting/unified/utils/time.ts +++ b/public/app/features/alerting/unified/utils/time.ts @@ -95,6 +95,14 @@ export function parsePrometheusDuration(duration: string): number { return totalDuration; } +export const safeParseDurationstr = (duration: string): number => { + try { + return parsePrometheusDuration(duration); + } catch (e) { + return 0; + } +}; + export const isNullDate = (date: string) => { return date.includes('0001-01-01T00'); };