Alerting: Contact points v2 part 2 (#71135)

This commit is contained in:
Gilles De Mey 2023-07-27 13:28:00 +02:00 committed by GitHub
parent a912c970e3
commit f10527cfe3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 908 additions and 153 deletions

View File

@ -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"]

View File

@ -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<ReceiversStateDTO[], void>({
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,
};
}),
}));
},
}),
}),
});

View File

@ -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(
<AlertmanagerProvider accessType={'notification'}>
<ContactPoints />
</AlertmanagerProvider>,
{ 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(<ContactPoint name={'my-contact-point'} receivers={[]} onDelete={onDelete} />);
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(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />);
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(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />);
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);
});
});

View File

@ -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 <Alert title="Failed to fetch contact points">{(error as SerializedError).message}</Alert>;
}
if (isLoading) {
return <LoadingPlaceholder text={'Loading...'} />;
}
return (
<>
<Stack direction="column">
{contactPoints.map((contactPoint) => {
const contactPointKey = selectedAlertmanager + contactPoint.name;
const provisioned = isProvisioned(contactPoint);
const disabled = updateAlertmanagerState.isLoading;
return (
<ContactPoint
key={contactPointKey}
name={contactPoint.name}
disabled={disabled}
onDelete={showDeleteModal}
receivers={contactPoint.grafana_managed_receiver_configs}
provisioned={provisioned}
/>
);
})}
</Stack>
{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 (
<Stack direction="column">
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader name={'grafana-default-email'} policies={['', '']} />
<div className={styles.receiversWrapper}>
<ContactPointReceiver type={'email'} description="gilles.demey@grafana.com" />
</div>
</Stack>
</div>
<div className={styles.contactPointWrapper} data-testid="contact-point">
<Stack direction="column" gap={0}>
<ContactPointHeader
name={name}
policies={[]}
provisioned={provisioned}
disabled={disabled}
onDelete={onDelete}
/>
<div className={styles.receiversWrapper}>
{receivers?.map((receiver) => {
const diagnostics = receiver[RECEIVER_STATUS_KEY];
const sendingResolved = !Boolean(receiver.disableResolveMessage);
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader name={'New school'} provenance={'api'} />
<div className={styles.receiversWrapper}>
<Stack direction="column" gap={0}>
<ContactPointReceiver type={'slack'} description="#test-alerts" sendingResolved={false} />
<ContactPointReceiver type={'discord'} />
</Stack>
</div>
</Stack>
</div>
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader name={'Japan 🇯🇵'} />
<div className={styles.receiversWrapper}>
<ContactPointReceiver type={'line'} />
</div>
</Stack>
</div>
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader name={'Google Stuff'} />
<div className={styles.receiversWrapper}>
<ContactPointReceiver type={'googlechat'} />
</div>
</Stack>
</div>
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader name={'Chinese Contact Points'} />
<div className={styles.receiversWrapper}>
<Stack direction="column" gap={0}>
<ContactPointReceiver type={'dingding'} />
<ContactPointReceiver type={'wecom'} error="403 unauthorized" />
</Stack>
</div>
</Stack>
</div>
<div className={styles.contactPointWrapper}>
<Stack direction="column" gap={0}>
<ContactPointHeader
name={
"This is a very long title to check if we are dealing with it appropriately, it shouldn't cause any layout issues"
}
/>
<div className={styles.receiversWrapper}>
<Stack direction="column" gap={0}>
<ContactPointReceiver type={'dingding'} />
</Stack>
</div>
</Stack>
</div>
</Stack>
return (
<ContactPointReceiver
key={uniqueId()}
type={receiver.type}
description={getReceiverDescription(receiver)}
diagnostics={diagnostics}
sendingResolved={sendingResolved}
/>
);
})}
</div>
</Stack>
</div>
);
};
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 (
<div className={styles.headerWrapper}>
@ -112,12 +137,12 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
is used by <Strong>{policies.length}</Strong> notification policies
</MetaText>
) : (
<MetaText>is not used</MetaText>
<MetaText>is not used in any policy</MetaText>
)}
{isProvisioned && <ProvisioningBadge />}
{provisioned && <ProvisioningBadge />}
<Spacer />
<ConditionalWrap
shouldWrap={isProvisioned}
shouldWrap={provisioned}
wrap={(children) => (
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
{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) => {
<Menu>
<Menu.Item label="Export" icon="download-alt" />
<Menu.Divider />
<Menu.Item label="Delete" icon="trash-alt" destructive disabled={isProvisioned} />
<Menu.Item
label="Delete"
icon="trash-alt"
destructive
disabled={disableActions}
onClick={() => onDelete(name)}
/>
</Menu>
}
>
@ -152,6 +183,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
type="button"
aria-label="more-actions"
data-testid="more-actions"
disabled={disableActions}
/>
</Dropdown>
</Stack>
@ -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 (
<div className={styles.integrationWrapper}>
@ -180,7 +215,7 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
<Text variant="body" color="primary">
{type}
{receiverName}
</Text>
</Stack>
{description && (
@ -190,43 +225,71 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
)}
</Stack>
</div>
<div className={styles.metadataRow}>
<Stack direction="row" gap={1}>
{error ? (
<>
{/* TODO we might need an error variant for MetaText, dito for success */}
{/* TODO show error details on hover or elsewhere */}
<Text color="error" variant="bodySmall" weight="bold">
<Stack direction="row" alignItems={'center'} gap={0.5}>
<Tooltip
content={
'failed to send notification to email addresses: gilles.demey@grafana.com: dial tcp 192.168.1.21:1025: connect: connection refused'
}
>
<span>
<Icon name="exclamation-circle" /> Last delivery attempt failed
</span>
</Tooltip>
</Stack>
</Text>
</>
) : (
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
</Stack>
</div>
);
};
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 (
<div className={styles.metadataRow}>
<Stack direction="row" gap={1}>
{/* 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 */}
<Text color="error" variant="bodySmall" weight="bold">
<Stack direction="row" alignItems={'center'} gap={0.5}>
<Tooltip content={diagnostics.lastNotifyAttemptError!}>
<span>
<Icon name="exclamation-circle" /> Last delivery attempt failed
</span>
</Tooltip>
</Stack>
</Text>
</>
) : (
<>
{/* this is shown when we have a last delivery attempt */}
{hasDeliveryAttempt && (
<>
<MetaText icon="clock-nine">
Last delivery attempt <Strong>25 minutes ago</Strong>
Last delivery attempt{' '}
<Tooltip content={lastDeliveryAttempt.toLocaleString()}>
<span>
<Strong>{lastDeliveryAttempt.locale('en').fromNow()}</Strong>
</span>
</Tooltip>
</MetaText>
<MetaText icon="stopwatch">
took <Strong>2s</Strong>
took <Strong>{lastDeliveryAttemptDuration}</Strong>
</MetaText>
</>
)}
{/* when we have no last delivery attempt */}
{!hasDeliveryAttempt && <MetaText icon="clock-nine">No delivery attempts</MetaText>}
{/* this is only shown for contact points that only want "firing" updates */}
{!sendingResolved && (
<MetaText icon="info-circle">
Delivering <Strong>only firing</Strong> notifications
</MetaText>
)}
</Stack>
</div>
</>
)}
</Stack>
</div>
);

View File

@ -0,0 +1,89 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button, Modal, ModalProps } from '@grafana/ui';
type ModalHook<T = undefined> = [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<void>,
isLoading: boolean
): ModalHook<string> => {
const [showModal, setShowModal] = useState(false);
const [contactPoint, setContactPoint] = useState<string>();
const [error, setError] = useState<unknown | undefined>();
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 <ErrorModal isOpen={showModal} onDismiss={handleDismiss} error={error} />;
}
return (
<Modal
isOpen={showModal}
onDismiss={handleDismiss}
closeOnBackdropClick={!isLoading}
closeOnEscape={!isLoading}
title="Delete contact point"
>
<p>Deleting this contact point will permanently remove it.</p>
<p>Are you sure you want to delete this contact point?</p>
<Modal.ButtonRow>
<Button type="button" variant="destructive" onClick={handleSubmit} disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Yes, delete contact point'}
</Button>
<Button type="button" variant="secondary" onClick={handleDismiss} disabled={isLoading}>
Cancel
</Button>
</Modal.ButtonRow>
</Modal>
);
}, [error, handleDismiss, handleSubmit, isLoading, showModal]);
return [modalElement, handleShow, handleDismiss];
};
interface ErrorModalProps extends Pick<ModalProps, 'isOpen' | 'onDismiss'> {
error: unknown;
}
const ErrorModal = ({ isOpen, onDismiss, error }: ErrorModalProps) => (
<Modal
isOpen={isOpen}
onDismiss={onDismiss}
closeOnBackdropClick={true}
closeOnEscape={true}
title={'Something went wrong'}
>
<p>Failed to update your configuration:</p>
<p>
<code>{String(error)}</code>
</p>
</Modal>
);

View File

@ -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 }
}
]
}
]
}
}

View File

@ -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"
}
]

View File

@ -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<AlertManagerCortexConfig>(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<ReceiversStateDTO[]>(receiversMock))
)
);
export default server;

View File

@ -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,
}
`;

View File

@ -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();
});
});
});

View File

@ -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<T extends Notifier> {
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,
};
}

View File

@ -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],
})),
};
});
}

View File

@ -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({

View File

@ -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<SupportedPlugin, string> = {

View File

@ -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();

View File

@ -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<GrafanaChannelValues>, GrafanaChannelMap] {
const channelMap: GrafanaChannelMap = {};
@ -92,7 +94,7 @@ export function formValuesToCloudReceiver(
values: ReceiverFormValues<CloudChannelValues>,
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;

View File

@ -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<string, number>; // 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[]

View File

@ -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];