diff --git a/.betterer.results b/.betterer.results index d186fb188f7..353577392c5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1870,6 +1870,9 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/alerting/unified/components/receivers/TemplateForm.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -3559,13 +3562,7 @@ exports[`better eslint`] = { "public/app/plugins/datasource/alertmanager/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 67040babaf8..238f28d0ff3 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -1,6 +1,7 @@ import { isEmpty } from 'lodash'; import { dispatch } from 'app/store/store'; +import { ReceiversStateDTO } from 'app/types/alerting'; import { AlertmanagerAlert, @@ -226,5 +227,30 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }), invalidatesTags: ['AlertmanagerConfiguration'], }), + + // Grafana Managed Alertmanager only + getContactPointsStatus: build.query({ + query: () => ({ + url: `/api/alertmanager/${getDatasourceAPIUid(GRAFANA_RULES_SOURCE_NAME)}/config/api/v1/receivers`, + }), + // this transformer basically fixes the weird "0001-01-01T00:00:00.000Z" and "0001-01-01T00:00:00.00Z" timestamps + // and sets both last attempt and duration to an empty string to indicate there hasn't been an attempt yet + transformResponse: (response: ReceiversStateDTO[]) => { + const isLastNotifyNullDate = (lastNotify: string) => lastNotify.startsWith('0001-01-01'); + + return response.map((receiversState) => ({ + ...receiversState, + integrations: receiversState.integrations.map((integration) => { + const noAttempt = isLastNotifyNullDate(integration.lastNotifyAttempt); + + return { + ...integration, + lastNotifyAttempt: noAttempt ? '' : integration.lastNotifyAttempt, + lastNotifyAttemptDuration: noAttempt ? '' : integration.lastNotifyAttemptDuration, + }; + }), + })); + }, + }), }), }); 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 new file mode 100644 index 00000000000..62eaecef77b --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx @@ -0,0 +1,90 @@ +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 { TestProvider } from 'test/helpers/TestProvider'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { disableRBAC } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; + +import ContactPoints, { ContactPoint } from './ContactPoints.v2'; + +import './__mocks__/server'; + +/** + * There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them. + * + * Use MSW to mock API responses, you can copy the JSON results from the network panel and use them in a __mocks__ folder. + * + * 1. Make sure we have "presentation" components we can test without mocking data, + * test these if they have some logic in them (hiding / showing things) and sad paths. + * + * 2. For testing the "container" components, check if data fetching is working as intended (you can use loading state) + * and check if we're not in an error state (although you can test for that too for sad path). + * + * 3. Write tests for the hooks we call in the "container" components + * if those have any logic or data structure transformations in them. + */ +describe('ContactPoints', () => { + beforeAll(() => { + disableRBAC(); + }); + + 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(); + }); + + expect(screen.getByText('grafana-default-email')).toBeInTheDocument(); + expect(screen.getAllByTestId('contact-point')).toHaveLength(4); + }); +}); + +describe('ContactPoint', () => { + it('should call delete when clicked and not disabled', async () => { + const onDelete = jest.fn(); + + render(); + + const moreActions = screen.getByTestId('more-actions'); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + await userEvent.click(deleteButton); + + expect(onDelete).toHaveBeenCalledWith('my-contact-point'); + }); + + it('should disabled buttons', async () => { + render(); + + const moreActions = screen.getByTestId('more-actions'); + const editAction = screen.getByTestId('edit-action'); + + expect(moreActions).toHaveProperty('disabled', true); + expect(editAction).toHaveProperty('disabled', true); + }); + + it('should disabled buttons when provisioned', async () => { + render(); + + expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); + + const moreActions = screen.getByTestId('more-actions'); + const editAction = screen.getByTestId('edit-action'); + + expect(moreActions).toHaveProperty('disabled', true); + expect(editAction).toHaveProperty('disabled', true); + }); +}); 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 4c10828a51a..feee11ab5dd 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,104 +1,129 @@ import { css } from '@emotion/css'; -import React from 'react'; +import { SerializedError } from '@reduxjs/toolkit'; +import { uniqueId, upperFirst } from 'lodash'; +import React, { ReactNode } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { dateTime, GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Button, Dropdown, Icon, Menu, Tooltip, useStyles2 } from '@grafana/ui'; +import { Alert, Button, Dropdown, Icon, LoadingPlaceholder, Menu, Tooltip, useStyles2 } from '@grafana/ui'; import { Text } from '@grafana/ui/src/unstable'; import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; -import { GrafanaNotifierType } from 'app/types/alerting'; +import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; +import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { INTEGRATION_ICONS } from '../../types/contact-points'; import { MetaText } from '../MetaText'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { Strong } from '../Strong'; +import { useDeleteContactPointModal } from './Modals'; +import { RECEIVER_STATUS_KEY, useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints'; +import { getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils'; + const ContactPoints = () => { + const { selectedAlertmanager } = useAlertmanager(); + const { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!); + const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!); + + const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading); + + 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 ; + } + + return ( + <> + + {contactPoints.map((contactPoint) => { + const contactPointKey = selectedAlertmanager + contactPoint.name; + const provisioned = isProvisioned(contactPoint); + const disabled = updateAlertmanagerState.isLoading; + + return ( + + ); + })} + + {DeleteModal} + + ); +}; + +interface ContactPointProps { + name: string; + disabled?: boolean; + provisioned?: boolean; + receivers: ReceiverConfigWithStatus[]; + onDelete: (name: string) => void; +} + +export const ContactPoint = ({ + name, + disabled = false, + provisioned = false, + receivers, + onDelete, +}: ContactPointProps) => { const styles = useStyles2(getStyles); return ( - -
- - -
- -
-
-
+
+ + +
+ {receivers?.map((receiver) => { + const diagnostics = receiver[RECEIVER_STATUS_KEY]; + const sendingResolved = !Boolean(receiver.disableResolveMessage); -
- - -
- - - - -
-
-
- -
- - -
- -
-
-
- -
- - -
- -
-
-
- -
- - -
- - - - -
-
-
- -
- - -
- - - -
-
-
- + return ( + + ); + })} +
+
+
); }; interface ContactPointHeaderProps { name: string; - provenance?: string; + disabled?: boolean; + provisioned?: boolean; policies?: string[]; // some array of policies that refer to this contact point + onDelete: (name: string) => void; } const ContactPointHeader = (props: ContactPointHeaderProps) => { - const { name, provenance, policies = [] } = props; - + const { name, disabled = false, provisioned = false, policies = [], onDelete } = props; const styles = useStyles2(getStyles); - const isProvisioned = Boolean(provenance); + + const disableActions = disabled || provisioned; return (
@@ -112,12 +137,12 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { is used by {policies.length} notification policies ) : ( - is not used + is not used in any policy )} - {isProvisioned && } + {provisioned && } ( {children} @@ -129,7 +154,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { size="sm" icon="edit" type="button" - disabled={isProvisioned} + disabled={disableActions} aria-label="edit-action" data-testid="edit-action" > @@ -141,7 +166,13 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { - + onDelete(name)} + /> } > @@ -152,6 +183,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { type="button" aria-label="more-actions" data-testid="more-actions" + disabled={disableActions} /> @@ -161,16 +193,19 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { interface ContactPointReceiverProps { type: GrafanaNotifierType | string; - description?: string; - error?: string; + description?: ReactNode; sendingResolved?: boolean; + diagnostics?: NotifierStatus; } const ContactPointReceiver = (props: ContactPointReceiverProps) => { - const { type, description, error, sendingResolved = true } = props; + const { type, description, diagnostics, sendingResolved = true } = props; const styles = useStyles2(getStyles); 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 (
@@ -180,7 +215,7 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => { {iconName && } - {type} + {receiverName} {description && ( @@ -190,43 +225,71 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => { )}
-
- - {error ? ( - <> - {/* TODO we might need an error variant for MetaText, dito for success */} - {/* TODO show error details on hover or elsewhere */} - - - - - Last delivery attempt failed - - - - - - ) : ( + {hasMetadata && } + +
+ ); +}; + +interface ContactPointReceiverMetadata { + sendingResolved: boolean; + diagnostics: NotifierStatus; +} + +const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => { + const { diagnostics, sendingResolved } = props; + const styles = useStyles2(getStyles); + + const failedToSend = Boolean(diagnostics.lastNotifyAttemptError); + const lastDeliveryAttempt = dateTime(diagnostics.lastNotifyAttempt); + const lastDeliveryAttemptDuration = diagnostics.lastNotifyAttemptDuration; + const hasDeliveryAttempt = lastDeliveryAttempt.isValid(); + + return ( +
+ + {/* 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 + + + + + + ) : ( + <> + {/* this is shown when we have a last delivery attempt */} + {hasDeliveryAttempt && ( <> - Last delivery attempt 25 minutes ago + Last delivery attempt{' '} + + + {lastDeliveryAttempt.locale('en').fromNow()} + + - took 2s + took {lastDeliveryAttemptDuration} )} + {/* when we have no last delivery attempt */} + {!hasDeliveryAttempt && No delivery attempts} + {/* this is only shown for contact points that only want "firing" updates */} {!sendingResolved && ( Delivering only firing notifications )} - -
+ + )}
); diff --git a/public/app/features/alerting/unified/components/contact-points/Modals.tsx b/public/app/features/alerting/unified/components/contact-points/Modals.tsx new file mode 100644 index 00000000000..6c4fe8ec7df --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/Modals.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useMemo, useState } from 'react'; + +import { Button, Modal, ModalProps } from '@grafana/ui'; + +type ModalHook = [JSX.Element, (item: T) => void, () => void]; + +/** + * This hook controls the delete modal for contact points, showing loading and error states when appropriate + */ +export const useDeleteContactPointModal = ( + handleDelete: (name: string) => Promise, + isLoading: boolean +): ModalHook => { + const [showModal, setShowModal] = useState(false); + const [contactPoint, setContactPoint] = useState(); + const [error, setError] = useState(); + + const handleDismiss = useCallback(() => { + if (isLoading) { + return; + } + + setContactPoint(undefined); + setShowModal(false); + setError(undefined); + }, [isLoading]); + + const handleShow = useCallback((name: string) => { + setContactPoint(name); + setShowModal(true); + setError(undefined); + }, []); + + const handleSubmit = useCallback(() => { + if (contactPoint) { + handleDelete(contactPoint) + .then(() => setShowModal(false)) + .catch(setError); + } + }, [handleDelete, contactPoint]); + + const modalElement = useMemo(() => { + if (error) { + return ; + } + + return ( + +

Deleting this contact point will permanently remove it.

+

Are you sure you want to delete this contact point?

+ + + + + +
+ ); + }, [error, handleDismiss, handleSubmit, isLoading, showModal]); + + return [modalElement, handleShow, handleDismiss]; +}; + +interface ErrorModalProps extends Pick { + error: unknown; +} +const ErrorModal = ({ isOpen, onDismiss, error }: ErrorModalProps) => ( + +

Failed to update your configuration:

+

+ {String(error)} +

+
+); diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json new file mode 100644 index 00000000000..b5d9f3f2c82 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json @@ -0,0 +1,71 @@ +{ + "template_files": {}, + "alertmanager_config": { + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "xeKQrBrnk", + "name": "grafana-default-email", + "type": "email", + "disableResolveMessage": false, + "settings": { "addresses": "gilles.demey@grafana.com", "singleEmail": false }, + "secureFields": {} + } + ] + }, + { + "name": "provisioned-contact-point", + "grafana_managed_receiver_configs": [ + { + "uid": "s8SdCVjnk", + "name": "provisioned-contact-point", + "type": "email", + "disableResolveMessage": false, + "settings": { "addresses": "gilles.demey@grafana.com", "singleEmail": false }, + "secureFields": {}, + "provenance": "api" + } + ] + }, + { + "name": "lotsa-emails", + "grafana_managed_receiver_configs": [ + { + "uid": "af306c96-35a2-4d6e-908a-4993e245dbb2", + "name": "lotsa-emails", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com", + "singleEmail": false + }, + "secureFields": {} + } + ] + }, + { + "name": "Slack with multiple channels", + "grafana_managed_receiver_configs": [ + { + "uid": "c02ad56a-31da-46b9-becb-4348ec0890fd", + "name": "Slack with multiple channels", + "type": "slack", + "disableResolveMessage": false, + "settings": { "recipient": "test-alerts" }, + "secureFields": { "token": true } + }, + { + "uid": "b286a3be-f690-49e2-8605-b075cbace2df", + "name": "Slack with multiple channels", + "type": "slack", + "disableResolveMessage": false, + "settings": { "recipient": "test-alerts2" }, + "secureFields": { "token": true } + } + ] + } + ] + } +} diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/receivers.mock.json b/public/app/features/alerting/unified/components/contact-points/__mocks__/receivers.mock.json new file mode 100644 index 00000000000..d6ceb50db23 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/receivers.mock.json @@ -0,0 +1,57 @@ +[ + { + "active": true, + "integrations": [ + { + "lastNotifyAttempt": "2023-07-02T21:35:34.841+02:00", + "lastNotifyAttemptDuration": "1ms", + "lastNotifyAttemptError": "failed to send notification to email addresses: gilles.demey@grafana.com: dial tcp 192.168.1.21:1025: connect: connection refused", + "name": "email", + "sendResolved": true + } + ], + "name": "grafana-default-email" + }, + { + "active": false, + "integrations": [ + { + "lastNotifyAttempt": "0001-01-01T00:00:00.000Z", + "lastNotifyAttemptDuration": "0s", + "name": "email", + "sendResolved": true + } + ], + "name": "provisioned-contact-point" + }, + { + "active": false, + "integrations": [ + { + "lastNotifyAttempt": "0001-01-01T00:00:00.000Z", + "lastNotifyAttemptDuration": "0s", + "name": "email", + "sendResolved": true + } + ], + "name": "lotsa-emails" + }, + { + "active": false, + "integrations": [ + { + "lastNotifyAttempt": "0001-01-01T00:00:00.000Z", + "lastNotifyAttemptDuration": "0s", + "name": "slack", + "sendResolved": true + }, + { + "lastNotifyAttempt": "0001-01-01T00:00:00.000Z", + "lastNotifyAttemptDuration": "0s", + "name": "slack", + "sendResolved": true + } + ], + "name": "Slack with multiple channels" + } +] diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/server.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/server.ts new file mode 100644 index 00000000000..951a7bc1eff --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/server.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'; + +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)) + ) +); + +export default server; 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 new file mode 100644 index 00000000000..81bfcb128f2 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useContactPoints should return contact points with status 1`] = ` +{ + "contactPoints": [ + { + "grafana_managed_receiver_configs": [ + { + "disableResolveMessage": false, + "name": "grafana-default-email", + "secureFields": {}, + "settings": { + "addresses": "gilles.demey@grafana.com", + "singleEmail": false, + }, + "type": "email", + "uid": "xeKQrBrnk", + Symbol(receiver_status): { + "lastNotifyAttempt": "2023-07-02T21:35:34.841+02:00", + "lastNotifyAttemptDuration": "1ms", + "lastNotifyAttemptError": "failed to send notification to email addresses: gilles.demey@grafana.com: dial tcp 192.168.1.21:1025: connect: connection refused", + "name": "email", + "sendResolved": true, + }, + }, + ], + "name": "grafana-default-email", + }, + { + "grafana_managed_receiver_configs": [ + { + "disableResolveMessage": false, + "name": "provisioned-contact-point", + "provenance": "api", + "secureFields": {}, + "settings": { + "addresses": "gilles.demey@grafana.com", + "singleEmail": false, + }, + "type": "email", + "uid": "s8SdCVjnk", + Symbol(receiver_status): { + "lastNotifyAttempt": "", + "lastNotifyAttemptDuration": "", + "name": "email", + "sendResolved": true, + }, + }, + ], + "name": "provisioned-contact-point", + }, + { + "grafana_managed_receiver_configs": [ + { + "disableResolveMessage": false, + "name": "lotsa-emails", + "secureFields": {}, + "settings": { + "addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com", + "singleEmail": false, + }, + "type": "email", + "uid": "af306c96-35a2-4d6e-908a-4993e245dbb2", + Symbol(receiver_status): { + "lastNotifyAttempt": "", + "lastNotifyAttemptDuration": "", + "name": "email", + "sendResolved": true, + }, + }, + ], + "name": "lotsa-emails", + }, + { + "grafana_managed_receiver_configs": [ + { + "disableResolveMessage": false, + "name": "Slack with multiple channels", + "secureFields": { + "token": true, + }, + "settings": { + "recipient": "test-alerts", + }, + "type": "slack", + "uid": "c02ad56a-31da-46b9-becb-4348ec0890fd", + Symbol(receiver_status): { + "lastNotifyAttempt": "", + "lastNotifyAttemptDuration": "", + "name": "slack", + "sendResolved": true, + }, + }, + { + "disableResolveMessage": false, + "name": "Slack with multiple channels", + "secureFields": { + "token": true, + }, + "settings": { + "recipient": "test-alerts2", + }, + "type": "slack", + "uid": "b286a3be-f690-49e2-8605-b075cbace2df", + Symbol(receiver_status): { + "lastNotifyAttempt": "", + "lastNotifyAttemptDuration": "", + "name": "slack", + "sendResolved": true, + }, + }, + ], + "name": "Slack with multiple channels", + }, + ], + "error": undefined, + "isLoading": false, +} +`; 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 new file mode 100644 index 00000000000..ca7ff791f9e --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -0,0 +1,18 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import './__mocks__/server'; +import { useContactPointsWithStatus } from './useContactPoints'; + +describe('useContactPoints', () => { + it('should return contact points with status', async () => { + const { result } = renderHook(() => useContactPointsWithStatus('grafana'), { + wrapper: TestProvider, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current).toMatchSnapshot(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index 32244114cd9..095c586afec 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -3,21 +3,87 @@ * and (if available) it will also fetch the status from the Grafana Managed status endpoint */ -import { NotifierType, NotifierStatus } from 'app/types'; +import { produce } from 'immer'; +import { remove } from 'lodash'; -// A Contact Point has 1 or more integrations -// each integration can have additional metadata assigned to it -export interface ContactPoint { - notifiers: T[]; +import { alertmanagerApi } from '../../api/alertmanagerApi'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; + +import { enhanceContactPointsWithStatus } from './utils'; + +export const RECEIVER_STATUS_KEY = Symbol('receiver_status'); +const RECEIVER_STATUS_POLLING_INTERVAL = 10 * 1000; // 10 seconds + +/** + * This hook will combine data from two endpoints; + * 1. the alertmanager config endpoint where the definition of the receivers are + * 2. (if available) the alertmanager receiver status endpoint, currently Grafana Managed only + */ +export function useContactPointsWithStatus(selectedAlertmanager: string) { + const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + + // fetch receiver status if we're dealing with a Grafana Managed Alertmanager + const fetchContactPointsStatus = alertmanagerApi.endpoints.getContactPointsStatus.useQuery(undefined, { + // TODO these don't seem to work since we've not called setupListeners() + refetchOnFocus: true, + refetchOnReconnect: true, + // re-fetch status every so often for up-to-date information + pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, + // skip fetching receiver statuses if not Grafana AM + skip: !isGrafanaManagedAlertmanager, + }); + + // fetch the latest config from the Alertmanager + const fetchAlertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery( + selectedAlertmanager, + { + refetchOnFocus: true, + refetchOnReconnect: true, + selectFromResult: (result) => ({ + ...result, + contactPoints: result.data ? enhanceContactPointsWithStatus(result.data, fetchContactPointsStatus.data) : [], + }), + } + ); + + // TODO kinda yucky to combine hooks like this, better alternative? + const error = fetchAlertmanagerConfiguration.error ?? fetchContactPointsStatus.error; + const isLoading = fetchAlertmanagerConfiguration.isLoading || fetchContactPointsStatus.isLoading; + + const contactPoints = fetchAlertmanagerConfiguration.contactPoints; + + return { + error, + isLoading, + contactPoints, + }; } -interface Notifier { - type: NotifierType; -} +export function useDeleteContactPoint(selectedAlertmanager: string) { + const [fetchAlertmanagerConfig] = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useLazyQuery(); + const [updateAlertManager, updateAlertmanagerState] = + alertmanagerApi.endpoints.updateAlertmanagerConfiguration.useMutation(); -// Grafana Managed contact points have receivers with additional diagnostics -export interface NotifierWithDiagnostics extends Notifier { - status: NotifierStatus; -} + const deleteTrigger = (contactPointName: string) => { + return fetchAlertmanagerConfig(selectedAlertmanager).then(({ data }) => { + if (!data) { + return; + } -export function useContactPoints(AlertManagerSourceName: string) {} + const newConfig = produce(data, (draft) => { + remove(draft?.alertmanager_config?.receivers ?? [], (receiver) => receiver.name === contactPointName); + return draft; + }); + + return updateAlertManager({ + selectedAlertmanager, + config: newConfig, + }).unwrap(); + }); + }; + + return { + deleteTrigger, + updateAlertmanagerState, + }; +} diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts new file mode 100644 index 00000000000..d9e42a44f1f --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -0,0 +1,93 @@ +import { split } from 'lodash'; +import { ReactNode } from 'react'; + +import { + AlertManagerCortexConfig, + GrafanaManagedContactPoint, + GrafanaManagedReceiverConfig, +} from 'app/plugins/datasource/alertmanager/types'; +import { NotifierStatus, ReceiversStateDTO } from 'app/types'; + +import { extractReceivers } from '../../utils/receivers'; + +import { RECEIVER_STATUS_KEY } from './useContactPoints'; + +export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { + // for some reason the provenance is on the receiver and not the entire contact point + const provenance = contactPoint.grafana_managed_receiver_configs?.find((receiver) => receiver.provenance)?.provenance; + + return Boolean(provenance); +} + +// TODO we should really add some type information to these receiver settings... +export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig): ReactNode | undefined { + switch (receiver.type) { + case 'email': { + const hasEmailAddresses = 'addresses' in receiver.settings; // when dealing with alertmanager email_configs we don't normalize the settings + return hasEmailAddresses ? summarizeEmailAddresses(receiver.settings['addresses']) : undefined; + } + case 'slack': { + const channelName = receiver.settings['recipient']; + return channelName ? `#${channelName}` : undefined; + } + case 'kafka': { + const topicName = receiver.settings['kafkaTopic']; + return topicName; + } + default: + return undefined; + } +} + +// input: foo+1@bar.com, foo+2@bar.com, foo+3@bar.com, foo+4@bar.com +// 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 emails = addresses.trim().split(SUPPORTED_SEPARATORS); + const notShown = emails.length - MAX_ADDRESSES_SHOWN; + + const truncatedAddresses = split(addresses, SUPPORTED_SEPARATORS, MAX_ADDRESSES_SHOWN); + if (notShown > 0) { + truncatedAddresses.push(`+${notShown} more`); + } + + return truncatedAddresses.join(', '); +} + +// Grafana Managed contact points have receivers with additional diagnostics +export interface ReceiverConfigWithStatus extends GrafanaManagedReceiverConfig { + // we're using a symbol here so we'll never have a conflict on keys for a receiver + // we also specify that the diagnostics might be "undefined" for vanilla Alertmanager + [RECEIVER_STATUS_KEY]?: NotifierStatus | undefined; +} + +export interface ContactPointWithStatus extends GrafanaManagedContactPoint { + grafana_managed_receiver_configs: ReceiverConfigWithStatus[]; +} + +/** + * This function adds the status information for each of the integrations (contact point types) in a contact point + * 1. we iterate over all contact points + * 2. for each contact point we "enhance" it with the status or "undefined" for vanilla Alertmanager + */ +export function enhanceContactPointsWithStatus( + result: AlertManagerCortexConfig, + status: ReceiversStateDTO[] = [] +): ContactPointWithStatus[] { + const contactPoints = result.alertmanager_config.receivers ?? []; + + return contactPoints.map((contactPoint) => { + const receivers = extractReceivers(contactPoint); + const statusForReceiver = status.find((status) => status.name === contactPoint.name); + + return { + ...contactPoint, + grafana_managed_receiver_configs: receivers.map((receiver, index) => ({ + ...receiver, + [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], + })), + }; + }); +} 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 bc45af1f128..684a090ebfd 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { LoadingPlaceholder } from '@grafana/ui'; import { AlertManagerCortexConfig, + GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, - Receiver, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; @@ -32,7 +32,7 @@ import { TestContactPointModal } from './TestContactPointModal'; interface Props { alertManagerSourceName: string; config: AlertManagerCortexConfig; - existing?: Receiver; + existing?: GrafanaManagedContactPoint; } const defaultChannelValues: GrafanaChannelValues = Object.freeze({ diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts index 9c1c245c501..099a73b8fc8 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts @@ -1,4 +1,4 @@ -import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; +import { GrafanaManagedContactPoint } from '../../../../../../plugins/datasource/alertmanager/types'; import { SupportedPlugin } from '../../../types/pluginBridges'; export interface AmRouteReceiver { @@ -7,7 +7,7 @@ export interface AmRouteReceiver { grafanaAppReceiverType?: SupportedPlugin; } -export interface ReceiverWithTypes extends Receiver { +export interface ReceiverWithTypes extends GrafanaManagedContactPoint { grafanaAppReceiverType?: SupportedPlugin; } export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record = { diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 7ce3e754d48..99e840a893d 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -8,9 +8,9 @@ import { backendSrv } from '../../../core/services/backend_srv'; import { AlertmanagerConfig, AlertManagerCortexConfig, + AlertmanagerReceiver, EmailConfig, MatcherOperator, - Receiver, Route, } from '../../../plugins/datasource/alertmanager/types'; @@ -84,7 +84,7 @@ class EmailConfigBuilder { } class AlertmanagerReceiverBuilder { - private receiver: Receiver = { name: '', email_configs: [] }; + private receiver: AlertmanagerReceiver = { name: '', email_configs: [] }; withName(name: string): AlertmanagerReceiverBuilder { this.receiver.name = name; @@ -124,7 +124,7 @@ export function mockApi(server: SetupServer) { }; } -// Creates a MSW server and sets up beforeAll and afterAll handlers for it +// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it export function setupMswServer() { const server = setupServer(); diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index b3de19969fa..a80459e354b 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -2,6 +2,8 @@ import { isArray, isNil, omitBy } from 'lodash'; import { AlertManagerCortexConfig, + AlertmanagerReceiver, + GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, Receiver, Route, @@ -18,7 +20,7 @@ import { } from '../types/receiver-form'; export function grafanaReceiverToFormValues( - receiver: Receiver, + receiver: GrafanaManagedContactPoint, notifiers: NotifierDTO[] ): [ReceiverFormValues, GrafanaChannelMap] { const channelMap: GrafanaChannelMap = {}; @@ -92,7 +94,7 @@ export function formValuesToCloudReceiver( values: ReceiverFormValues, defaults: CloudChannelValues ): Receiver { - const recv: Receiver = { + const recv: AlertmanagerReceiver = { name: values.name, }; values.items.forEach(({ __id, type, settings, sendResolved }) => { @@ -101,11 +103,10 @@ export function formValuesToCloudReceiver( send_resolved: sendResolved ?? defaults.sendResolved, }); - const configsKey = `${type}_configs`; - if (!recv[configsKey]) { - recv[configsKey] = [channel]; + if (!(`${type}_configs` in recv)) { + recv[`${type}_configs`] = [channel]; } else { - (recv[configsKey] as unknown[]).push(channel); + (recv[`${type}_configs`] as unknown[]).push(channel); } }); return recv; diff --git a/public/app/features/alerting/unified/utils/receivers.ts b/public/app/features/alerting/unified/utils/receivers.ts index c599e45514b..11bed1bf7fc 100644 --- a/public/app/features/alerting/unified/utils/receivers.ts +++ b/public/app/features/alerting/unified/utils/receivers.ts @@ -1,4 +1,4 @@ -import { capitalize } from 'lodash'; +import { capitalize, isEmpty, times } from 'lodash'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; import { GrafanaManagedReceiverConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; @@ -9,7 +9,7 @@ import { NotifierDTO } from 'app/types'; type NotifierTypeCounts = Record; // name : count export function extractNotifierTypeCounts(receiver: Receiver, grafanaNotifiers: NotifierDTO[]): NotifierTypeCounts { - if (receiver['grafana_managed_receiver_configs']) { + if ('grafana_managed_receiver_configs' in receiver) { return getGrafanaNotifierTypeCounts(receiver.grafana_managed_receiver_configs ?? [], grafanaNotifiers); } return getCortexAlertManagerNotifierTypeCounts(receiver); @@ -29,6 +29,45 @@ function getCortexAlertManagerNotifierTypeCounts(receiver: Receiver): NotifierTy }, {}); } +/** + * This function will extract the integrations that have been defined for either grafana managed contact point + * or vanilla Alertmanager receiver. + * + * It will attempt to normalize the data structure to how they have been defined for Grafana managed contact points. + * That way we can work with the same data structure in the UI. + * + * We don't normalize the configuration settings and those are blank for vanilla Alertmanager receivers. + * + * Example input: + * { name: 'my receiver', email_configs: [{ from: "foo@bar.com" }] } + * + * Example output: + * { name: 'my receiver', grafana_managed_receiver_configs: [{ type: 'email', settings: {} }] } + */ +export function extractReceivers(receiver: Receiver): GrafanaManagedReceiverConfig[] { + if ('grafana_managed_receiver_configs' in receiver) { + return receiver.grafana_managed_receiver_configs ?? []; + } + + const integrations = Object.entries(receiver) + .filter(([key]) => key !== 'grafana_managed_receiver_configs' && key.endsWith('_configs')) + .filter(([_, value]) => Array.isArray(value) && !isEmpty(value)) + .reduce((acc: GrafanaManagedReceiverConfig[], [key, value]) => { + const type = key.replace('_configs', ''); + + const configs = times(value.length, () => ({ + name: receiver.name, + type: type, + settings: [], // we don't normalize the configuration values + disableResolveMessage: false, + })); + + return acc.concat(configs); + }, []); + + return integrations; +} + function getGrafanaNotifierTypeCounts( configs: GrafanaManagedReceiverConfig[], grafanaNotifiers: NotifierDTO[] diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index b8ff117bc1d..293b4850d3d 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -79,20 +79,22 @@ export type GrafanaManagedReceiverConfig = { provenance?: string; }; -export type Receiver = { +export interface GrafanaManagedContactPoint { + name: string; + grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; +} + +export interface AlertmanagerReceiver { name: string; email_configs?: EmailConfig[]; - pagerduty_configs?: any[]; - pushover_configs?: any[]; - slack_configs?: any[]; - opsgenie_configs?: any[]; webhook_configs?: WebhookConfig[]; - victorops_configs?: any[]; - wechat_configs?: any[]; - grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; - [key: string]: any; -}; + + // this is supposedly to support any *_configs + [key: `${string}_configs`]: any[] | undefined; +} + +export type Receiver = GrafanaManagedContactPoint | AlertmanagerReceiver; export type ObjectMatcher = [name: string, operator: MatcherOperator, value: string];