mirror of https://github.com/grafana/grafana
Alerting: Add notification policies preview in alert creation (#68839)
* Add notification policies preview in alert rule form
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
* 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 <soniaaguilarpeiron@gmail.com>
* 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 ee73ae9cf9
.
* 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 <konrad.lalik@grafana.com>
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
pull/70320/head
parent
1a985c488c
commit
9a252c763a
@ -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<string, AlertmanagerGroup[]>(); |
||||
function addRoutes(route: RouteWithID) { |
||||
groupsMap.set(route.id, []); |
||||
|
||||
route.routes?.forEach((r) => addRoutes(r)); |
||||
} |
||||
|
||||
addRoutes(route); |
||||
const getRouteGroupsMap = useCallback(async (route: RouteWithID, groups: AlertmanagerGroup[]) => { |
||||
return routeGroupsMatcher.getRouteGroupsMap(route, groups); |
||||
}, []); |
||||
|
||||
return groupsMap; |
||||
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { |
||||
return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); |
||||
}, []); |
||||
|
||||
return { getRouteGroupsMap }; |
||||
return { getRouteGroupsMap, matchInstancesToRoute }; |
||||
} |
||||
|
@ -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<string, string>; |
||||
|
||||
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', |
||||
}), |
||||
}), |
||||
}), |
||||
}); |
@ -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 <div className={styles.defaultPolicy}>Default policy</div>; |
||||
} else if (hasEmptyMatchers(route)) { |
||||
return <div className={styles.textMuted}>No matchers</div>; |
||||
} else { |
||||
return <Matchers matchers={route.object_matchers ?? []} />; |
||||
} |
||||
} |
||||
|
||||
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}; |
||||
`,
|
||||
}); |
@ -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(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { |
||||
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(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { |
||||
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(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { |
||||
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(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { |
||||
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( |
||||
<NotificationPreviewByAlertManager |
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }} |
||||
potentialInstances={potentialInstances} |
||||
onlyOneAM={true} |
||||
/>, |
||||
{ 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( |
||||
<NotificationPreviewByAlertManager |
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }} |
||||
potentialInstances={potentialInstances} |
||||
onlyOneAM={true} |
||||
/>, |
||||
{ 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( |
||||
<NotificationPreviewByAlertManager |
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }} |
||||
potentialInstances={potentialInstances} |
||||
onlyOneAM={true} |
||||
/>, |
||||
{ 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/); |
||||
}); |
||||
}); |
@ -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 ( |
||||
<Stack direction="column" gap={2}> |
||||
<div className={styles.routePreviewHeaderRow}> |
||||
<div className={styles.previewHeader}> |
||||
<H4>Alert instance routing preview</H4> |
||||
</div> |
||||
<div className={styles.button}> |
||||
<Button icon="sync" variant="secondary" type="button" onClick={onPreview}> |
||||
Preview routing |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
{!renderHowToPreview && ( |
||||
<div className={styles.textMuted}> |
||||
Based on the labels added, alert instances are routed to the following notification policies. Expand each |
||||
notification policy below to view more details. |
||||
</div> |
||||
)} |
||||
{isLoading && <div className={styles.textMuted}>Loading...</div>} |
||||
{renderHowToPreview && ( |
||||
<div className={styles.previewHowToText}> |
||||
{`When your query and labels are configured, click "Preview routing" to see the results here.`} |
||||
</div> |
||||
)} |
||||
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && ( |
||||
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}> |
||||
{alertManagerSourceNamesAndImage.map((alertManagerSource) => ( |
||||
<NotificationPreviewByAlertManager |
||||
alertManagerSource={alertManagerSource} |
||||
potentialInstances={potentialInstances} |
||||
onlyOneAM={onlyOneAM} |
||||
key={alertManagerSource.name} |
||||
/> |
||||
))} |
||||
</Suspense> |
||||
)} |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
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)}; |
||||
`,
|
||||
}); |
@ -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 ( |
||||
<Alert title="Cannot load Alertmanager configuration" severity="error"> |
||||
{error.message} |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
if (loading) { |
||||
return <LoadingPlaceholder text="Loading routing preview..." />; |
||||
} |
||||
|
||||
const matchingPoliciesFound = matchingMap.size > 0; |
||||
|
||||
return matchingPoliciesFound ? ( |
||||
<div className={styles.alertManagerRow}> |
||||
{!onlyOneAM && ( |
||||
<Stack direction="row" alignItems="center"> |
||||
<div className={styles.firstAlertManagerLine}></div> |
||||
<div className={styles.alertManagerName}> |
||||
{' '} |
||||
Alert manager: |
||||
<img src={alertManagerSource.img} alt="" className={styles.img} /> |
||||
{alertManagerSource.name} |
||||
</div> |
||||
<div className={styles.secondAlertManagerLine}></div> |
||||
</Stack> |
||||
)} |
||||
<Stack gap={1} direction="column"> |
||||
{Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => { |
||||
const route = routesByIdMap.get(routeId); |
||||
const receiver = route?.receiver && receiversByName.get(route.receiver); |
||||
|
||||
if (!route) { |
||||
return null; |
||||
} |
||||
if (!receiver) { |
||||
throw new Error('Receiver not found'); |
||||
} |
||||
return ( |
||||
<NotificationRoute |
||||
instanceMatches={instanceMatches} |
||||
route={route} |
||||
receiver={receiver} |
||||
key={routeId} |
||||
routesByIdMap={routesByIdMap} |
||||
alertManagerSourceName={alertManagerSource.name} |
||||
/> |
||||
); |
||||
})} |
||||
</Stack> |
||||
</div> |
||||
) : 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)}; |
||||
`,
|
||||
}); |
@ -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<string, RouteWithPath>; |
||||
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 ( |
||||
<div className={styles.routeHeader}> |
||||
<CollapseToggle |
||||
isCollapsed={!expandRoute} |
||||
onToggle={(isCollapsed) => onExpandRouteClick(!isCollapsed)} |
||||
aria-label="Expand policy route" |
||||
/> |
||||
|
||||
<Stack flexGrow={1} gap={1}> |
||||
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}> |
||||
<Stack gap={1} direction="row" alignItems="center"> |
||||
Notification policy |
||||
<NotificationPolicyMatchers route={route} /> |
||||
</Stack> |
||||
</div> |
||||
<Spacer /> |
||||
<Stack gap={2} direction="row" alignItems="center"> |
||||
<MetaText icon="layers-alt" data-testid="matching-instances"> |
||||
{instancesCount ?? '-'} |
||||
<span>{pluralize('instance', instancesCount)}</span> |
||||
</MetaText> |
||||
<Stack gap={1} direction="row" alignItems="center"> |
||||
<div> |
||||
<span className={styles.textMuted}>@ Delivered to</span> {receiver.name} |
||||
</div> |
||||
|
||||
<div className={styles.verticalBar} /> |
||||
|
||||
<Button type="button" onClick={onClickDetails} variant="secondary" fill="outline" size="sm"> |
||||
See details |
||||
</Button> |
||||
</Stack> |
||||
</Stack> |
||||
</Stack> |
||||
{showDetails && ( |
||||
<NotificationRouteDetailsModal |
||||
onClose={() => setShowDetails(false)} |
||||
route={route} |
||||
receiver={receiver} |
||||
routesByIdMap={routesByIdMap} |
||||
alertManagerSourceName={alertManagerSourceName} |
||||
/> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
interface NotificationRouteProps { |
||||
route: RouteWithPath; |
||||
receiver: Receiver; |
||||
instanceMatches: AlertInstanceMatch[]; |
||||
routesByIdMap: Map<string, RouteWithPath>; |
||||
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 ( |
||||
<div data-testid="matching-policy-route"> |
||||
<NotificationRouteHeader |
||||
route={route} |
||||
receiver={receiver} |
||||
routesByIdMap={routesByIdMap} |
||||
instancesCount={instanceMatches.length} |
||||
alertManagerSourceName={alertManagerSourceName} |
||||
expandRoute={expandRoute} |
||||
onExpandRouteClick={setExpandRoute} |
||||
/> |
||||
{expandRoute && ( |
||||
<Stack gap={1} direction="column"> |
||||
<div className={styles.routeInstances} data-testid="route-matching-instance"> |
||||
{instanceMatches.map((instanceMatch) => { |
||||
const matchArray = Array.from(instanceMatch.labelsMatch); |
||||
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 ( |
||||
<div className={styles.tagListCard} key={uniqueId()}> |
||||
{matchArray.length > 0 ? ( |
||||
<> |
||||
{matchingLabels.length > 0 ? ( |
||||
<TagList |
||||
tags={matchingLabels.map((mr) => mr.label)} |
||||
className={styles.labelList} |
||||
getColorIndex={(_, index) => matchingLabels[index].colorIndex} |
||||
/> |
||||
) : ( |
||||
<div className={cx(styles.textMuted, styles.textItalic)}>No matching labels</div> |
||||
)} |
||||
<div className={styles.labelSeparator} /> |
||||
<TagList |
||||
tags={nonMatchingLabels.map((mr) => mr.label)} |
||||
className={styles.labelList} |
||||
getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex} |
||||
/> |
||||
</> |
||||
) : ( |
||||
<div className={styles.textMuted}>No labels</div> |
||||
)} |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
</Stack> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
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)}; |
||||
`,
|
||||
}); |
@ -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<string, RouteWithPath>; route: RouteWithPath }) { |
||||
const styles = useStyles2(getStyles); |
||||
const routePathIds = route.path?.slice(1) ?? []; |
||||
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; |
||||
|
||||
return ( |
||||
<div className={styles.policyPathWrapper}> |
||||
<div className={styles.defaultPolicy}>Default policy</div> |
||||
{routePathObjects.map((pathRoute, index) => { |
||||
return ( |
||||
<div key={pathRoute.id}> |
||||
<div className={styles.policyInPath(index, index === routePathObjects.length - 1)}> |
||||
{hasEmptyMatchers(pathRoute) ? ( |
||||
<div className={styles.textMuted}>No matchers</div> |
||||
) : ( |
||||
<Matchers matchers={pathRoute.object_matchers ?? []} /> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
interface NotificationRouteDetailsModalProps { |
||||
onClose: () => void; |
||||
route: RouteWithPath; |
||||
receiver: Receiver; |
||||
routesByIdMap: Map<string, RouteWithPath>; |
||||
alertManagerSourceName: string; |
||||
} |
||||
|
||||
export function NotificationRouteDetailsModal({ |
||||
onClose, |
||||
route, |
||||
receiver, |
||||
routesByIdMap, |
||||
alertManagerSourceName, |
||||
}: NotificationRouteDetailsModalProps) { |
||||
const styles = useStyles2(getStyles); |
||||
const isDefault = isDefaultPolicy(route); |
||||
|
||||
const permissions = getNotificationsPermissions(alertManagerSourceName); |
||||
return ( |
||||
<Modal |
||||
className={styles.detailsModal} |
||||
isOpen={true} |
||||
title="Routing details" |
||||
onDismiss={onClose} |
||||
onClickBackdrop={onClose} |
||||
> |
||||
<Stack gap={0} direction="column"> |
||||
<div className={cx(styles.textMuted, styles.marginBottom(2))}>Your alert instances are routed as follows.</div> |
||||
<div>Notification policy path</div> |
||||
{isDefault && <div className={styles.textMuted}>Default policy</div>} |
||||
<div className={styles.separator(1)} /> |
||||
{!isDefault && ( |
||||
<> |
||||
<PolicyPath route={route} routesByIdMap={routesByIdMap} /> |
||||
</> |
||||
)} |
||||
<div className={styles.separator(4)} /> |
||||
<div className={styles.contactPoint}> |
||||
<Stack gap={1} direction="row" alignItems="center"> |
||||
Contact point: |
||||
<span className={styles.textMuted}>{receiver.name}</span> |
||||
</Stack> |
||||
<Authorize actions={[permissions.update]}> |
||||
<Stack gap={1} direction="row" alignItems="center"> |
||||
<a |
||||
href={makeAMLink( |
||||
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`, |
||||
alertManagerSourceName |
||||
)} |
||||
className={styles.link} |
||||
target="_blank" |
||||
rel="noreferrer" |
||||
> |
||||
See details <Icon name="external-link-alt" /> |
||||
</a> |
||||
</Stack> |
||||
</Authorize> |
||||
</div> |
||||
<div className={styles.button}> |
||||
<Button variant="primary" type="button" onClick={onClose}> |
||||
Close |
||||
</Button> |
||||
</div> |
||||
</Stack> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
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; |
||||
} |
||||
} `,
|
||||
}); |
@ -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 <id , RouteWithPath>
|
||||
export function getRoutesByIdMap(rootRoute: RouteWithID): Map<string, RouteWithPath> { |
||||
const map = new Map<string, RouteWithPath>(); |
||||
|
||||
function addRoutesToMap(route: RouteWithID, path: string[] = []) { |
||||
map.set(route.id, { ...route, path: path }); |
||||
route.routes?.forEach((r) => addRoutesToMap(r, [...path, route.id])); |
||||
} |
||||
|
||||
addRoutesToMap(rootRoute, []); |
||||
return map; |
||||
} |
||||
|
||||
export function hasEmptyMatchers(route: RouteWithID) { |
||||
return route.object_matchers?.length === 0; |
||||
} |
@ -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; |
||||
}; |
@ -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 }; |
||||
} |
@ -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<AlertManagerCortexConfig>({ |
||||
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; |
||||
} |
@ -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<PreviewResponse>(result)))); |
||||
} |
@ -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)) |
||||
) |
||||
); |
||||
} |
||||
|
@ -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<string, AlertmanagerGroup[]> { |
||||
const normalizedRootRoute = normalizeRoute(rootRoute); |
||||
|
||||
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) { |
||||
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); |
||||
acc.set(route.id, routeGroups); |
||||
|
||||
route.routes?.forEach((r) => addRouteGroups(r, acc)); |
||||
} |
||||
|
||||
const routeGroupsMap = new Map<string, AlertmanagerGroup[]>(); |
||||
addRouteGroups(normalizedRootRoute, routeGroupsMap); |
||||
|
||||
return routeGroupsMap; |
||||
}, |
||||
|
||||
matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> { |
||||
const result = new Map<string, AlertInstanceMatch[]>(); |
||||
|
||||
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; |
@ -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<string, AlertmanagerGroup[]> { |
||||
const normalizedRootRoute = normalizeRoute(rootRoute); |
||||
|
||||
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) { |
||||
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); |
||||
acc.set(route.id, routeGroups); |
||||
|
||||
route.routes?.forEach((r) => addRouteGroups(r, acc)); |
||||
} |
||||
|
||||
const routeGroupsMap = new Map<string, AlertmanagerGroup[]>(); |
||||
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); |
||||
|
@ -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); |
Loading…
Reference in new issue