From 5a1580c65966ca3f8e28483c1e197b4594ed5855 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 26 Sep 2023 10:44:18 +0200 Subject: [PATCH] Alerting: Contact points v2 part 3 (#72444) --- .betterer.results | 8 +- packages/grafana-data/src/types/icon.ts | 1 + .../src/components/Button/Button.tsx | 8 +- .../alerting/components/ConditionalWrap.tsx | 9 +- .../features/alerting/unified/Receivers.tsx | 27 +- .../alerting/unified/components/MetaText.tsx | 4 +- .../contact-points/ContactPoints.v2.test.tsx | 150 ++++-- .../contact-points/ContactPoints.v2.tsx | 465 +++++++++++++----- .../DuplicateMessageTemplate.tsx | 37 ++ .../contact-points/EditContactPoint.tsx | 47 ++ .../contact-points/EditMessageTemplate.tsx | 47 ++ .../contact-points/GlobalConfig.tsx | 32 ++ .../contact-points/MessageTemplates.tsx | 22 + .../contact-points/NewContactPoint.tsx | 33 ++ .../contact-points/NewMessageTemplate.tsx | 32 ++ .../alertmanager.mimir.config.mock.json | 34 ++ .../__mocks__/grafanaManagedServer.ts | 24 + .../__mocks__/mimirFlavoredServer.ts | 23 + .../useContactPoints.test.tsx.snap | 4 + .../contact-points/useContactPoints.test.tsx | 4 +- .../components/contact-points/utils.ts | 29 +- .../receivers/ReceiversAndTemplatesView.tsx | 63 ++- .../components/receivers/ReceiversTable.tsx | 2 +- .../components/receivers/TemplatesTable.tsx | 16 +- .../receivers/form/CloudReceiverForm.tsx | 5 +- .../receivers/form/GrafanaReceiverForm.tsx | 4 +- .../receivers/useAlertmanagerConfigHealth.ts | 12 +- .../alerting/unified/types/contact-points.ts | 2 + 28 files changed, 916 insertions(+), 228 deletions(-) create mode 100644 public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/GlobalConfig.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/MessageTemplates.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.mimir.config.mock.json create mode 100644 public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts create mode 100644 public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts diff --git a/.betterer.results b/.betterer.results index 168342a6030..16a3e9e1ad6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2108,13 +2108,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/alerting/unified/components/export/FileExportPreview.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index e6a5d301622..9d957baf273 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -6,6 +6,7 @@ export const availableIconsIndex = { okta: true, discord: true, hipchat: true, + amazon: true, 'google-hangouts-alt': true, pagerduty: true, line: true, diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index b9fe6e0d7bf..8e4f00b1c87 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -126,7 +126,13 @@ export const LinkButton = React.forwardRef( // When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632 const button = ( - + {icon && } {children && {children}} diff --git a/public/app/features/alerting/components/ConditionalWrap.tsx b/public/app/features/alerting/components/ConditionalWrap.tsx index 7d6c495be2f..3a5308b7da1 100644 --- a/public/app/features/alerting/components/ConditionalWrap.tsx +++ b/public/app/features/alerting/components/ConditionalWrap.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef, Ref } from 'react'; interface ConditionalWrapProps { shouldWrap: boolean; @@ -6,7 +6,8 @@ interface ConditionalWrapProps { wrap: (children: JSX.Element) => JSX.Element; } -export const ConditionalWrap = ({ shouldWrap, children, wrap }: ConditionalWrapProps): JSX.Element => - shouldWrap ? React.cloneElement(wrap(children)) : children; +function ConditionalWrap({ children, shouldWrap, wrap }: ConditionalWrapProps, _ref: Ref) { + return shouldWrap ? React.cloneElement(wrap(children)) : children; +} -export default ConditionalWrap; +export default forwardRef(ConditionalWrap); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 9baa0238952..4b00dc040ff 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -1,19 +1,42 @@ import React from 'react'; import { Disable, Enable } from 'react-enable'; +import { Route, Switch } from 'react-router-dom'; import { withErrorBoundary } from '@grafana/ui'; const ContactPointsV1 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v1')); const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v2')); +const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint')); +const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint')); +const EditMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/EditMessageTemplate')); +const NewMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/NewMessageTemplate')); +const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/GlobalConfig')); +const DuplicateMessageTemplate = SafeDynamicImport( + () => import('./components/contact-points/DuplicateMessageTemplate') +); import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { AlertingFeature } from './features'; -// TODO add pagenav back in – what are we missing if we don't specify it? + +// TODO add pagenav back in – that way we have correct breadcrumbs and page title const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => ( - + {/* TODO do we want a "routes" component for each Alerting entity? */} + + + + + + + + + diff --git a/public/app/features/alerting/unified/components/MetaText.tsx b/public/app/features/alerting/unified/components/MetaText.tsx index 3edc8ba13db..beb183c8230 100644 --- a/public/app/features/alerting/unified/components/MetaText.tsx +++ b/public/app/features/alerting/unified/components/MetaText.tsx @@ -21,9 +21,9 @@ const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => { // allow passing ARIA and data- attributes {...rest} > - + - {icon && } + {icon && } {children} diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx index 62eaecef77b..08261d21f91 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx @@ -1,17 +1,20 @@ import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash'; -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { selectors } from '@grafana/e2e-selectors'; +import { AccessControlAction } from 'app/types'; -import { disableRBAC } from '../../mocks'; +import { grantUserPermissions, mockDataSource } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { setupDataSources } from '../../testSetup/datasources'; +import { DataSourceType } from '../../utils/datasource'; import ContactPoints, { ContactPoint } from './ContactPoints.v2'; - -import './__mocks__/server'; +import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer'; +import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimirFlavoredServer'; /** * There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them. @@ -28,26 +31,70 @@ import './__mocks__/server'; * if those have any logic or data structure transformations in them. */ describe('ContactPoints', () => { - beforeAll(() => { - disableRBAC(); - }); + describe('Grafana managed alertmanager', () => { + setupGrafanaManagedServer(); - it('should show / hide loading states', async () => { - render( - - - , - { wrapper: TestProvider } - ); - - await waitFor(async () => { - await expect(screen.getByText('Loading...')).toBeInTheDocument(); - await waitForElementToBeRemoved(screen.getByText('Loading...')); - await expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument(); + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); }); - expect(screen.getByText('grafana-default-email')).toBeInTheDocument(); - expect(screen.getAllByTestId('contact-point')).toHaveLength(4); + it('should show / hide loading states', async () => { + render( + + + , + { wrapper: TestProvider } + ); + + await waitFor(async () => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + await waitForElementToBeRemoved(screen.getByText('Loading...')); + expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument(); + }); + + expect(screen.getByText('grafana-default-email')).toBeInTheDocument(); + expect(screen.getAllByTestId('contact-point')).toHaveLength(4); + }); + }); + + describe('Mimir-flavored alertmanager', () => { + setupMimirFlavoredServer(); + + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsExternalWrite, + ]); + setupDataSources( + mockDataSource({ + type: DataSourceType.Alertmanager, + name: MIMIR_DATASOURCE_UID, + uid: MIMIR_DATASOURCE_UID, + }) + ); + }); + + it('should show / hide loading states', async () => { + render( + + + , + { wrapper: TestProvider } + ); + + await waitFor(async () => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + await waitForElementToBeRemoved(screen.getByText('Loading...')); + expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument(); + }); + + expect(screen.getByText('mixed')).toBeInTheDocument(); + expect(screen.getByText('some webhook')).toBeInTheDocument(); + expect(screen.getAllByTestId('contact-point')).toHaveLength(2); + }); }); }); @@ -55,9 +102,11 @@ describe('ContactPoint', () => { it('should call delete when clicked and not disabled', async () => { const onDelete = jest.fn(); - render(); + render(, { + wrapper, + }); - const moreActions = screen.getByTestId('more-actions'); + const moreActions = screen.getByRole('button', { name: 'more-actions' }); await userEvent.click(moreActions); const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); @@ -66,25 +115,56 @@ describe('ContactPoint', () => { expect(onDelete).toHaveBeenCalledWith('my-contact-point'); }); - it('should disabled buttons', async () => { - render(); + it('should disable edit button', async () => { + render(, { + wrapper, + }); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + expect(moreActions).not.toBeDisabled(); - const moreActions = screen.getByTestId('more-actions'); const editAction = screen.getByTestId('edit-action'); - - expect(moreActions).toHaveProperty('disabled', true); - expect(editAction).toHaveProperty('disabled', true); + expect(editAction).toHaveAttribute('aria-disabled', 'true'); }); - it('should disabled buttons when provisioned', async () => { - render(); + it('should disable buttons when provisioned', async () => { + render(, { + wrapper, + }); expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); - const moreActions = screen.getByTestId('more-actions'); - const editAction = screen.getByTestId('edit-action'); + const editAction = screen.queryByTestId('edit-action'); + expect(editAction).not.toBeInTheDocument(); - expect(moreActions).toHaveProperty('disabled', true); - expect(editAction).toHaveProperty('disabled', true); + const viewAction = screen.getByRole('link', { name: /view/i }); + expect(viewAction).toBeInTheDocument(); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + expect(moreActions).not.toBeDisabled(); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + expect(deleteButton).toBeDisabled(); + }); + + it('should disable delete when contact point is linked to at least one notification policy', async () => { + render(, { + wrapper, + }); + + expect(screen.getByRole('link', { name: 'is used by 1 notification policy' })).toBeInTheDocument(); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + expect(deleteButton).toBeDisabled(); }); }); + +const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx index 60f1fe1ef28..f0bbc011ce9 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx @@ -1,72 +1,205 @@ import { css } from '@emotion/css'; import { SerializedError } from '@reduxjs/toolkit'; -import { uniqueId, upperFirst } from 'lodash'; -import React, { ReactNode } from 'react'; +import { groupBy, size, uniqueId, upperFirst } from 'lodash'; +import pluralize from 'pluralize'; +import React, { ReactNode, useState } from 'react'; +import { Link } from 'react-router-dom'; import { dateTime, GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Alert, Button, Dropdown, Icon, LoadingPlaceholder, Menu, Tooltip, useStyles2, Text } from '@grafana/ui'; +import { + Alert, + Button, + Dropdown, + Icon, + LoadingPlaceholder, + Menu, + Tooltip, + useStyles2, + Text, + LinkButton, + TabsBar, + TabContent, + Tab, + Pagination, +} from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; +import { isOrgAdmin } from 'app/features/plugins/admin/permissions'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; +import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; +import { usePagination } from '../../hooks/usePagination'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { INTEGRATION_ICONS } from '../../types/contact-points'; +import { getNotificationsPermissions } from '../../utils/access-control'; +import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; +import { createUrl } from '../../utils/url'; import { MetaText } from '../MetaText'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { Strong } from '../Strong'; +import { GlobalConfigAlert } from '../receivers/ReceiversAndTemplatesView'; +import { UnusedContactPointBadge } from '../receivers/ReceiversTable'; +import { MessageTemplates } from './MessageTemplates'; import { useDeleteContactPointModal } from './Modals'; import { RECEIVER_STATUS_KEY, useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints'; -import { getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils'; +import { ContactPointWithStatus, getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils'; + +enum ActiveTab { + ContactPoints, + MessageTemplates, +} + +const DEFAULT_PAGE_SIZE = 25; const ContactPoints = () => { const { selectedAlertmanager } = useAlertmanager(); - const { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!); + // TODO hook up to query params + const [activeTab, setActiveTab] = useState(ActiveTab.ContactPoints); + let { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!); const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!); const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading); + const showingContactPoints = activeTab === ActiveTab.ContactPoints; + const showingMessageTemplates = activeTab === ActiveTab.MessageTemplates; + if (error) { // TODO fix this type casting, when error comes from "getContactPointsStatus" it probably won't be a SerializedError return {(error as SerializedError).message}; } - if (isLoading) { - return ; - } + const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + const isVanillaAlertmanager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager!); + const permissions = getNotificationsPermissions(selectedAlertmanager!); + + const allowedToAddContactPoint = contextSrv.hasPermission(permissions.create); return ( <> - {contactPoints.map((contactPoint) => { - const contactPointKey = selectedAlertmanager + contactPoint.name; - const provisioned = isProvisioned(contactPoint); - const disabled = updateAlertmanagerState.isLoading; - - return ( - - ); - })} + + setActiveTab(ActiveTab.ContactPoints)} + /> + setActiveTab(ActiveTab.MessageTemplates)} + /> + + {showingContactPoints && ( + + Add contact point + + )} + {showingMessageTemplates && ( + + Add message template + + )} + + + + <> + {isLoading && } + {/* Contact Points tab */} + {showingContactPoints && ( + <> + {error ? ( + {String(error)} + ) : ( + <> + {/* TODO we can add some additional info here with a ToggleTip */} + + Define where notifications are sent, a contact point can contain multiple integrations. + + showDeleteModal(name)} + disabled={updateAlertmanagerState.isLoading} + /> + {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} + {!isGrafanaManagedAlertmanager && } + + )} + + )} + {/* Message Templates tab */} + {showingMessageTemplates && ( + <> + + Create message templates to customize your notifications. + + + + )} + + + {DeleteModal} ); }; +interface ContactPointsListProps { + contactPoints: ContactPointWithStatus[]; + disabled?: boolean; + onDelete: (name: string) => void; + pageSize?: number; +} + +const ContactPointsList = ({ + contactPoints, + disabled = false, + pageSize = DEFAULT_PAGE_SIZE, + onDelete, +}: ContactPointsListProps) => { + const { page, pageItems, numberOfPages, onPageChange } = usePagination(contactPoints, 1, pageSize); + + return ( + <> + {pageItems.map((contactPoint, index) => { + const provisioned = isProvisioned(contactPoint); + const policies = contactPoint.numberOfPolicies; + + return ( + + ); + })} + + + ); +}; + interface ContactPointProps { name: string; disabled?: boolean; provisioned?: boolean; receivers: ReceiverConfigWithStatus[]; + policies?: number; onDelete: (name: string) => void; } @@ -75,36 +208,46 @@ export const ContactPoint = ({ disabled = false, provisioned = false, receivers, + policies = 0, onDelete, }: ContactPointProps) => { const styles = useStyles2(getStyles); + // TODO probably not the best way to figure out if we want to show either only the summary or full metadata for the receivers? + const showFullMetadata = receivers.some((receiver) => Boolean(receiver[RECEIVER_STATUS_KEY])); + return (
-
- {receivers?.map((receiver) => { - const diagnostics = receiver[RECEIVER_STATUS_KEY]; - const sendingResolved = !Boolean(receiver.disableResolveMessage); + {showFullMetadata ? ( +
+ {receivers?.map((receiver) => { + const diagnostics = receiver[RECEIVER_STATUS_KEY]; + const sendingResolved = !Boolean(receiver.disableResolveMessage); - return ( - - ); - })} -
+ return ( + + ); + })} +
+ ) : ( +
+ +
+ )}
); @@ -114,64 +257,97 @@ interface ContactPointHeaderProps { name: string; disabled?: boolean; provisioned?: boolean; - policies?: string[]; // some array of policies that refer to this contact point + policies?: number; onDelete: (name: string) => void; } const ContactPointHeader = (props: ContactPointHeaderProps) => { - const { name, disabled = false, provisioned = false, policies = [], onDelete } = props; + const { name, disabled = false, provisioned = false, policies = 0, onDelete } = props; const styles = useStyles2(getStyles); + const { selectedAlertmanager } = useAlertmanager(); + const permissions = getNotificationsPermissions(selectedAlertmanager ?? ''); - const disableActions = disabled || provisioned; + const isReferencedByPolicies = policies > 0; + const isGranaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + + // we make a distinction here becase for "canExport" we show the menu item, if not we hide it + const canExport = isGranaManagedAlertmanager; + const allowedToExport = contextSrv.hasAccess(permissions.provisioning.read, isOrgAdmin()); return (
- {name} + + {name} + - {policies.length > 0 ? ( + {isReferencedByPolicies ? ( - {/* TODO make this a link to the notification policies page with the filter applied */} - is used by {policies.length} notification policies + + is used by {policies} {pluralize('notification policy', policies)} + ) : ( - is not used in any policy + )} {provisioned && } - ( - - {children} - - )} + - - + {provisioned ? 'View' : 'Edit'} + + {/* TODO probably want to split this off since there's lots of RBAC involved here */} - - - onDelete(name)} - /> + {canExport && ( + <> + + + + )} + 0} + wrap={(children) => ( + + {children} + + )} + > + 0} + onClick={() => onDelete(name)} + /> + } > @@ -182,7 +358,6 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { type="button" aria-label="more-actions" data-testid="more-actions" - disabled={disableActions} /> @@ -203,27 +378,26 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => { const iconName = INTEGRATION_ICONS[type]; const hasMetadata = diagnostics !== undefined; + // TODO get the actual name of the type from /ngalert if grafanaManaged AM const receiverName = receiverTypeNames[type] ?? upperFirst(type); return (
- -
- - - {iconName && } - - {receiverName} - - - {description && ( - - {description} - - )} + + + + {iconName && } + + {receiverName} + -
+ {description && ( + + {description} + + )} +
{hasMetadata && }
@@ -235,8 +409,47 @@ interface ContactPointReceiverMetadata { diagnostics: NotifierStatus; } -const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => { - const { diagnostics, sendingResolved } = props; +type ContactPointReceiverSummaryProps = { + receivers: GrafanaManagedReceiverConfig[]; +}; + +/** + * This summary is used when we're dealing with non-Grafana managed alertmanager since they + * don't have any metadata worth showing other than a summary of what types are configured for the contact point + */ +const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => { + const styles = useStyles2(getStyles); + const countByType = groupBy(receivers, (receiver) => receiver.type); + + return ( +
+ + + {Object.entries(countByType).map(([type, receivers], index) => { + const iconName = INTEGRATION_ICONS[type]; + const receiverName = receiverTypeNames[type] ?? upperFirst(type); + const isLastItem = size(countByType) - 1 === index; + + return ( + + + {iconName && } + + {receiverName} + {receivers.length > 1 && <> ({receivers.length})} + + + {!isLastItem && '⋅'} + + ); + })} + + +
+ ); +}; + +const ContactPointReceiverMetadataRow = ({ diagnostics, sendingResolved }: ContactPointReceiverMetadata) => { const styles = useStyles2(getStyles); const failedToSend = Boolean(diagnostics.lastNotifyAttemptError); @@ -250,16 +463,11 @@ const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => {/* this is shown when the last delivery failed – we don't show any additional metadata */} {failedToSend ? ( <> - {/* TODO we might need an error variant for MetaText, dito for success */} - - - - - Last delivery attempt failed - - - - + + + Last delivery attempt failed + + ) : ( <> @@ -295,36 +503,31 @@ const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => }; const getStyles = (theme: GrafanaTheme2) => ({ - contactPointWrapper: css` - border-radius: ${theme.shape.radius.default}; - border: solid 1px ${theme.colors.border.weak}; - border-bottom: none; - `, - integrationWrapper: css` - position: relative; - background: ${theme.colors.background.primary}; + contactPointWrapper: css({ + borderRadius: `${theme.shape.radius.default}`, + border: `solid 1px ${theme.colors.border.weak}`, + borderBottom: 'none', + }), + integrationWrapper: css({ + position: 'relative', - border-bottom: solid 1px ${theme.colors.border.weak}; - `, - headerWrapper: css` - padding: ${theme.spacing(1)} ${theme.spacing(1.5)}; + background: `${theme.colors.background.primary}`, + padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, - background: ${theme.colors.background.secondary}; + borderBottom: `solid 1px ${theme.colors.border.weak}`, + }), + headerWrapper: css({ + background: `${theme.colors.background.secondary}`, + padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, - border-bottom: solid 1px ${theme.colors.border.weak}; - border-top-left-radius: ${theme.shape.radius.default}; - border-top-right-radius: ${theme.shape.radius.default}; - `, - receiverDescriptionRow: css` - padding: ${theme.spacing(1)} ${theme.spacing(1.5)}; - `, - metadataRow: css` - padding: 0 ${theme.spacing(1.5)} ${theme.spacing(1.5)} ${theme.spacing(1.5)}; - - border-bottom-left-radius: ${theme.shape.radius.default}; - border-bottom-right-radius: ${theme.shape.radius.default}; - `, - receiversWrapper: css``, + borderBottom: `solid 1px ${theme.colors.border.weak}`, + borderTopLeftRadius: `${theme.shape.radius.default}`, + borderTopRightRadius: `${theme.shape.radius.default}`, + }), + metadataRow: css({ + borderBottomLeftRadius: `${theme.shape.radius.default}`, + borderBottomRightRadius: `${theme.shape.radius.default}`, + }), }); export default ContactPoints; diff --git a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx new file mode 100644 index 00000000000..b59d348aac6 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { RouteChildrenProps } from 'react-router-dom'; + +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { DuplicateTemplateView } from '../receivers/DuplicateTemplateView'; + +type Props = RouteChildrenProps<{ name: string }>; + +const NewMessageTemplate = ({ match }: Props) => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + const name = match?.params.name; + if (!name) { + return ; + } + + if (isLoading && !data) { + return 'loading...'; + } + + // TODO decent error handling + if (error) { + return String(error); + } + + if (!data) { + return null; + } + + return ; +}; + +export default NewMessageTemplate; diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx new file mode 100644 index 00000000000..a9cfa2f865b --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { RouteChildrenProps } from 'react-router-dom'; + +import { Alert } from '@grafana/ui'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { EditReceiverView } from '../receivers/EditReceiverView'; + +type Props = RouteChildrenProps<{ name: string }>; + +const EditContactPoint = ({ match }: Props) => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + const contactPointName = match?.params.name; + if (!contactPointName) { + return ; + } + + if (isLoading && !data) { + return 'loading...'; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!data) { + return null; + } + + return ( + + ); +}; + +export default EditContactPoint; diff --git a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx new file mode 100644 index 00000000000..b8227ff1bca --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { RouteChildrenProps } from 'react-router-dom'; + +import { Alert } from '@grafana/ui'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { EditTemplateView } from '../receivers/EditTemplateView'; + +type Props = RouteChildrenProps<{ name: string }>; + +const EditMessageTemplate = ({ match }: Props) => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + const name = match?.params.name; + if (!name) { + return ; + } + + if (isLoading && !data) { + return 'loading...'; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!data) { + return null; + } + + return ( + + ); +}; + +export default EditMessageTemplate; diff --git a/public/app/features/alerting/unified/components/contact-points/GlobalConfig.tsx b/public/app/features/alerting/unified/components/contact-points/GlobalConfig.tsx new file mode 100644 index 00000000000..c5a453c6ef6 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/GlobalConfig.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { Alert } from '@grafana/ui'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { GlobalConfigForm } from '../receivers/GlobalConfigForm'; + +const NewMessageTemplate = () => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + if (isLoading && !data) { + return 'loading...'; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!data) { + return null; + } + + return ; +}; + +export default NewMessageTemplate; diff --git a/public/app/features/alerting/unified/components/contact-points/MessageTemplates.tsx b/public/app/features/alerting/unified/components/contact-points/MessageTemplates.tsx new file mode 100644 index 00000000000..415188bc1d6 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/MessageTemplates.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Alert } from '@grafana/ui'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { TemplatesTable } from '../receivers/TemplatesTable'; + +export const MessageTemplates = () => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, error } = useAlertmanagerConfig(selectedAlertmanager); + + if (error) { + return {String(error)}; + } + + if (data) { + return ; + } + + return null; +}; diff --git a/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx new file mode 100644 index 00000000000..e1ca42cbd90 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { RouteChildrenProps } from 'react-router-dom'; + +import { Alert } from '@grafana/ui'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { NewReceiverView } from '../receivers/NewReceiverView'; + +const NewContactPoint = (_props: RouteChildrenProps) => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + if (isLoading && !data) { + return 'loading...'; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!data) { + return null; + } + + return ; +}; + +export default NewContactPoint; diff --git a/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx new file mode 100644 index 00000000000..5a0e658d832 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { Alert } from '@grafana/ui'; + +import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { NewTemplateView } from '../receivers/NewTemplateView'; + +const NewMessageTemplate = () => { + const { selectedAlertmanager } = useAlertmanager(); + const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); + + if (isLoading && !data) { + return 'loading...'; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!data) { + return null; + } + + return ; +}; + +export default NewMessageTemplate; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.mimir.config.mock.json b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.mimir.config.mock.json new file mode 100644 index 00000000000..9bcdc5ce78e --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.mimir.config.mock.json @@ -0,0 +1,34 @@ +{ + "template_files": {}, + "alertmanager_config": { + "global": {}, + "mute_time_intervals": [], + "receivers": [ + { + "email_configs": [ + { "require_tls": false, "send_resolved": true, "to": "foo@bar.com" }, + { "require_tls": false, "send_resolved": true, "to": "foo@bar.com" } + ], + "name": "mixed", + "webhook_configs": [{ "send_resolved": true, "url": "https://foo.bar/" }] + }, + { "name": "some webhook", "webhook_configs": [{ "send_resolved": true, "url": "https://foo.bar/" }] } + ], + "route": { + "continue": false, + "group_by": ["alertname", "grafana_folder"], + "group_interval": "5m", + "group_wait": "30s", + "matchers": [], + "mute_time_intervals": [], + "receiver": "email", + "repeat_interval": "5h", + "routes": [ + { + "receiver": "mixed" + } + ] + }, + "templates": [] + } +} diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts new file mode 100644 index 00000000000..ba281758e43 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts @@ -0,0 +1,24 @@ +import { rest } from 'msw'; + +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { ReceiversStateDTO } from 'app/types'; + +import { setupMswServer } from '../../../mockApi'; + +import alertmanagerMock from './alertmanager.config.mock.json'; +import receiversMock from './receivers.mock.json'; + +export default () => { + const server = setupMswServer(); + + server.use( + // this endpoint is a grafana built-in alertmanager + rest.get('/api/alertmanager/grafana/config/api/v1/alerts', (_req, res, ctx) => + res(ctx.json(alertmanagerMock)) + ), + // this endpoint is only available for the built-in alertmanager + rest.get('/api/alertmanager/grafana/config/api/v1/receivers', (_req, res, ctx) => + res(ctx.json(receiversMock)) + ) + ); +}; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts new file mode 100644 index 00000000000..b5d2ec38274 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts @@ -0,0 +1,23 @@ +import { rest } from 'msw'; + +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; + +import { setupMswServer } from '../../../mockApi'; + +import mimirAlertmanagerMock from './alertmanager.mimir.config.mock.json'; + +// this one emulates a mimir server setup +export const MIMIR_DATASOURCE_UID = 'mimir'; + +export default () => { + const server = setupMswServer(); + + server.use( + rest.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, (_req, res, ctx) => + res(ctx.json(mimirAlertmanagerMock)) + ), + rest.get(`/api/datasources/proxy/uid/${MIMIR_DATASOURCE_UID}/api/v1/status/buildinfo`, (_req, res, ctx) => + res(ctx.status(404)) + ) + ); +}; diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index 81bfcb128f2..e82347ce7e6 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -25,6 +25,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, ], "name": "grafana-default-email", + "numberOfPolicies": 0, }, { "grafana_managed_receiver_configs": [ @@ -48,6 +49,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, ], "name": "provisioned-contact-point", + "numberOfPolicies": 0, }, { "grafana_managed_receiver_configs": [ @@ -70,6 +72,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, ], "name": "lotsa-emails", + "numberOfPolicies": 0, }, { "grafana_managed_receiver_configs": [ @@ -111,6 +114,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, ], "name": "Slack with multiple channels", + "numberOfPolicies": 0, }, ], "error": undefined, diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index ca7ff791f9e..c96d03ae9b9 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -1,10 +1,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { TestProvider } from 'test/helpers/TestProvider'; -import './__mocks__/server'; +import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer'; import { useContactPointsWithStatus } from './useContactPoints'; describe('useContactPoints', () => { + setupGrafanaManagedServer(); + it('should return contact points with status', async () => { const { result } = renderHook(() => useContactPointsWithStatus('grafana'), { wrapper: TestProvider, diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index d9e42a44f1f..bcec11e595a 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -1,13 +1,15 @@ -import { split } from 'lodash'; +import { countBy, split, trim } from 'lodash'; import { ReactNode } from 'react'; import { AlertManagerCortexConfig, GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, + Route, } from 'app/plugins/datasource/alertmanager/types'; import { NotifierStatus, ReceiversStateDTO } from 'app/types'; +import { computeInheritedTree } from '../../utils/notification-policies'; import { extractReceivers } from '../../utils/receivers'; import { RECEIVER_STATUS_KEY } from './useContactPoints'; @@ -34,6 +36,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig): const topicName = receiver.settings['kafkaTopic']; return topicName; } + case 'webhook': { + const url = receiver.settings['url']; + return url; + } default: return undefined; } @@ -43,9 +49,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig): // output: foo+1@bar.com, foo+2@bar.com, +2 more function summarizeEmailAddresses(addresses: string): string { const MAX_ADDRESSES_SHOWN = 3; - const SUPPORTED_SEPARATORS = /,|;|\\n/; + const SUPPORTED_SEPARATORS = /,|;|\n+/g; + + const emails = addresses.trim().split(SUPPORTED_SEPARATORS).map(trim); - const emails = addresses.trim().split(SUPPORTED_SEPARATORS); const notShown = emails.length - MAX_ADDRESSES_SHOWN; const truncatedAddresses = split(addresses, SUPPORTED_SEPARATORS, MAX_ADDRESSES_SHOWN); @@ -64,6 +71,7 @@ export interface ReceiverConfigWithStatus extends GrafanaManagedReceiverConfig { } export interface ContactPointWithStatus extends GrafanaManagedContactPoint { + numberOfPolicies: number; grafana_managed_receiver_configs: ReceiverConfigWithStatus[]; } @@ -78,12 +86,18 @@ export function enhanceContactPointsWithStatus( ): ContactPointWithStatus[] { const contactPoints = result.alertmanager_config.receivers ?? []; + // compute the entire inherited tree before finding what notification policies are using a particular contact point + const fullyInheritedTree = computeInheritedTree(result?.alertmanager_config?.route ?? {}); + const usedContactPoints = getUsedContactPoints(fullyInheritedTree); + const usedContactPointsByName = countBy(usedContactPoints); + return contactPoints.map((contactPoint) => { const receivers = extractReceivers(contactPoint); const statusForReceiver = status.find((status) => status.name === contactPoint.name); return { ...contactPoint, + numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0, grafana_managed_receiver_configs: receivers.map((receiver, index) => ({ ...receiver, [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], @@ -91,3 +105,12 @@ export function enhanceContactPointsWithStatus( }; }); } + +export function getUsedContactPoints(route: Route): string[] { + const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? []; + if (route.receiver) { + return [route.receiver, ...childrenContactPoints]; + } + + return childrenContactPoints; +} diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx index dab905a481c..a4de8059101 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx @@ -4,11 +4,12 @@ import { Stack } from '@grafana/experimental'; import { Alert, LinkButton } from '@grafana/ui'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; -import { AlertmanagerAction } from '../../hooks/useAbilities'; +import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; import { Authorize } from '../Authorize'; +import { ReceiversSection } from './ReceiversSection'; import { ReceiversTable } from './ReceiversTable'; import { TemplatesTable } from './TemplatesTable'; @@ -18,26 +19,56 @@ interface Props { } export const ReceiversAndTemplatesView = ({ config, alertManagerName }: Props) => { - const isCloud = alertManagerName !== GRAFANA_RULES_SOURCE_NAME; + const isGrafanaManagedAlertmanager = alertManagerName === GRAFANA_RULES_SOURCE_NAME; const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName); return ( - {!isVanillaAM && } - {isCloud && ( - - -

- For each external Alertmanager you can define global settings, like server addresses, usernames and - password, for all the supported contact points. -

- - {isVanillaAM ? 'View global config' : 'Edit global config'} - -
-
- )} + {/* Vanilla flavored Alertmanager does not support editing message templates via the UI */} + {!isVanillaAM && } + {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} + {!isGrafanaManagedAlertmanager && }
); }; + +export const TemplatesView = ({ config, alertManagerName }: Props) => { + const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility( + AlertmanagerAction.CreateNotificationTemplate + ); + + return ( + + + + ); +}; + +interface GlobalConfigAlertProps { + alertManagerName: string; +} + +export const GlobalConfigAlert = ({ alertManagerName }: GlobalConfigAlertProps) => { + const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName); + + return ( + + +

+ For each external Alertmanager you can define global settings, like server addresses, usernames and password, + for all the supported contact points. +

+ + {isVanillaAM ? 'View global config' : 'Edit global config'} + +
+
+ ); +}; diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 36c8f914910..af6f8634a0b 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -511,7 +511,7 @@ function useGetColumns( ]; } -function UnusedContactPointBadge() { +export function UnusedContactPointBadge() { return ( { const dispatch = useDispatch(); const [expandedTemplates, setExpandedTemplates] = useState>({}); const tableStyles = useStyles2(getAlertTableStyles); - const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility( - AlertmanagerAction.CreateNotificationTemplate - ); const templateRows = useMemo(() => { const templates = Object.entries(config.template_files); @@ -49,13 +45,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => { }; return ( - + <> @@ -177,6 +167,6 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => { onDismiss={() => setTemplateToDelete(undefined)} /> )} - + ); }; diff --git a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx index b5150c8fd7d..65fa7312c93 100644 --- a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx @@ -4,6 +4,7 @@ import { Alert } from '@grafana/ui'; import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; +import { alertmanagerApi } from '../../../api/alertmanagerApi'; import { updateAlertManagerConfigAction } from '../../../state/actions'; import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form'; import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types'; @@ -57,7 +58,9 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }: successMessage: existing ? 'Contact point updated.' : 'Contact point created.', redirectPath: '/alerting/notifications', }) - ); + ).then(() => { + dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration'])); + }); }; const takenReceiverNames = useMemo( diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index 3ab821449d7..ee769573b6a 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -86,7 +86,9 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config } successMessage: existing ? 'Contact point updated.' : 'Contact point created', redirectPath: '/alerting/notifications', }) - ); + ).then(() => { + dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration'])); + }); }; const onTestChannel = (values: GrafanaChannelValues) => { diff --git a/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts b/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts index 4327ccae21f..65c84e61f5c 100644 --- a/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts +++ b/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts @@ -1,6 +1,7 @@ import { countBy } from 'lodash'; -import { AlertmanagerConfig, Route } from '../../../../../plugins/datasource/alertmanager/types'; +import { AlertmanagerConfig } from '../../../../../plugins/datasource/alertmanager/types'; +import { getUsedContactPoints } from '../contact-points/utils'; export interface ContactPointConfigHealth { matchingRoutes: number; @@ -32,12 +33,3 @@ export function useAlertmanagerConfigHealth(config: AlertmanagerConfig): Alertma return configHealth; } - -function getUsedContactPoints(route: Route): string[] { - const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? []; - if (route.receiver) { - return [route.receiver, ...childrenContactPoints]; - } - - return childrenContactPoints; -} diff --git a/public/app/features/alerting/unified/types/contact-points.ts b/public/app/features/alerting/unified/types/contact-points.ts index b0fe57edf7f..d2923116905 100644 --- a/public/app/features/alerting/unified/types/contact-points.ts +++ b/public/app/features/alerting/unified/types/contact-points.ts @@ -10,4 +10,6 @@ export const INTEGRATION_ICONS: Record = { slack: 'slack', teams: 'microsoft', telegram: 'telegram-alt', + webhook: 'link', + sns: 'amazon', };