mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Contact points v2 part 2 (#71135)
This commit is contained in:
parent
a912c970e3
commit
f10527cfe3
@ -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"]
|
||||
|
@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
@ -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;
|
@ -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,
|
||||
}
|
||||
`;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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],
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
@ -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({
|
||||
|
@ -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> = {
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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[]
|
||||
|
@ -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];
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user