Alerting: Contact points v2 part 2 (#71135)
This commit is contained in:
+4
-7
@@ -1870,6 +1870,9 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx:5381": [
|
"public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[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": [
|
"public/app/features/alerting/unified/components/receivers/TemplateForm.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[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.", "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"]
|
|
||||||
],
|
],
|
||||||
"public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts:5381": [
|
"public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { dispatch } from 'app/store/store';
|
import { dispatch } from 'app/store/store';
|
||||||
|
import { ReceiversStateDTO } from 'app/types/alerting';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertmanagerAlert,
|
AlertmanagerAlert,
|
||||||
@@ -226,5 +227,30 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ['AlertmanagerConfiguration'],
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+90
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+171
-108
@@ -1,104 +1,129 @@
|
|||||||
import { css } from '@emotion/css';
|
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 { 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 { Text } from '@grafana/ui/src/unstable';
|
||||||
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
|
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 { INTEGRATION_ICONS } from '../../types/contact-points';
|
||||||
import { MetaText } from '../MetaText';
|
import { MetaText } from '../MetaText';
|
||||||
import { ProvisioningBadge } from '../Provisioning';
|
import { ProvisioningBadge } from '../Provisioning';
|
||||||
import { Spacer } from '../Spacer';
|
import { Spacer } from '../Spacer';
|
||||||
import { Strong } from '../Strong';
|
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 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);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column">
|
<div className={styles.contactPointWrapper} data-testid="contact-point">
|
||||||
<div className={styles.contactPointWrapper}>
|
<Stack direction="column" gap={0}>
|
||||||
<Stack direction="column" gap={0}>
|
<ContactPointHeader
|
||||||
<ContactPointHeader name={'grafana-default-email'} policies={['', '']} />
|
name={name}
|
||||||
<div className={styles.receiversWrapper}>
|
policies={[]}
|
||||||
<ContactPointReceiver type={'email'} description="gilles.demey@grafana.com" />
|
provisioned={provisioned}
|
||||||
</div>
|
disabled={disabled}
|
||||||
</Stack>
|
onDelete={onDelete}
|
||||||
</div>
|
/>
|
||||||
|
<div className={styles.receiversWrapper}>
|
||||||
|
{receivers?.map((receiver) => {
|
||||||
|
const diagnostics = receiver[RECEIVER_STATUS_KEY];
|
||||||
|
const sendingResolved = !Boolean(receiver.disableResolveMessage);
|
||||||
|
|
||||||
<div className={styles.contactPointWrapper}>
|
return (
|
||||||
<Stack direction="column" gap={0}>
|
<ContactPointReceiver
|
||||||
<ContactPointHeader name={'New school'} provenance={'api'} />
|
key={uniqueId()}
|
||||||
<div className={styles.receiversWrapper}>
|
type={receiver.type}
|
||||||
<Stack direction="column" gap={0}>
|
description={getReceiverDescription(receiver)}
|
||||||
<ContactPointReceiver type={'slack'} description="#test-alerts" sendingResolved={false} />
|
diagnostics={diagnostics}
|
||||||
<ContactPointReceiver type={'discord'} />
|
sendingResolved={sendingResolved}
|
||||||
</Stack>
|
/>
|
||||||
</div>
|
);
|
||||||
</Stack>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</Stack>
|
||||||
<div className={styles.contactPointWrapper}>
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ContactPointHeaderProps {
|
interface ContactPointHeaderProps {
|
||||||
name: string;
|
name: string;
|
||||||
provenance?: string;
|
disabled?: boolean;
|
||||||
|
provisioned?: boolean;
|
||||||
policies?: string[]; // some array of policies that refer to this contact point
|
policies?: string[]; // some array of policies that refer to this contact point
|
||||||
|
onDelete: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
||||||
const { name, provenance, policies = [] } = props;
|
const { name, disabled = false, provisioned = false, policies = [], onDelete } = props;
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const isProvisioned = Boolean(provenance);
|
|
||||||
|
const disableActions = disabled || provisioned;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.headerWrapper}>
|
<div className={styles.headerWrapper}>
|
||||||
@@ -112,12 +137,12 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
is used by <Strong>{policies.length}</Strong> notification policies
|
is used by <Strong>{policies.length}</Strong> notification policies
|
||||||
</MetaText>
|
</MetaText>
|
||||||
) : (
|
) : (
|
||||||
<MetaText>is not used</MetaText>
|
<MetaText>is not used in any policy</MetaText>
|
||||||
)}
|
)}
|
||||||
{isProvisioned && <ProvisioningBadge />}
|
{provisioned && <ProvisioningBadge />}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<ConditionalWrap
|
<ConditionalWrap
|
||||||
shouldWrap={isProvisioned}
|
shouldWrap={provisioned}
|
||||||
wrap={(children) => (
|
wrap={(children) => (
|
||||||
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
|
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
|
||||||
{children}
|
{children}
|
||||||
@@ -129,7 +154,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
icon="edit"
|
icon="edit"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isProvisioned}
|
disabled={disableActions}
|
||||||
aria-label="edit-action"
|
aria-label="edit-action"
|
||||||
data-testid="edit-action"
|
data-testid="edit-action"
|
||||||
>
|
>
|
||||||
@@ -141,7 +166,13 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Item label="Export" icon="download-alt" />
|
<Menu.Item label="Export" icon="download-alt" />
|
||||||
<Menu.Divider />
|
<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>
|
</Menu>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -152,6 +183,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="more-actions"
|
aria-label="more-actions"
|
||||||
data-testid="more-actions"
|
data-testid="more-actions"
|
||||||
|
disabled={disableActions}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -161,16 +193,19 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
|
|
||||||
interface ContactPointReceiverProps {
|
interface ContactPointReceiverProps {
|
||||||
type: GrafanaNotifierType | string;
|
type: GrafanaNotifierType | string;
|
||||||
description?: string;
|
description?: ReactNode;
|
||||||
error?: string;
|
|
||||||
sendingResolved?: boolean;
|
sendingResolved?: boolean;
|
||||||
|
diagnostics?: NotifierStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactPointReceiver = (props: ContactPointReceiverProps) => {
|
const ContactPointReceiver = (props: ContactPointReceiverProps) => {
|
||||||
const { type, description, error, sendingResolved = true } = props;
|
const { type, description, diagnostics, sendingResolved = true } = props;
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const iconName = INTEGRATION_ICONS[type];
|
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 (
|
return (
|
||||||
<div className={styles.integrationWrapper}>
|
<div className={styles.integrationWrapper}>
|
||||||
@@ -180,7 +215,7 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
|
|||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
{iconName && <Icon name={iconName} />}
|
{iconName && <Icon name={iconName} />}
|
||||||
<Text variant="body" color="primary">
|
<Text variant="body" color="primary">
|
||||||
{type}
|
{receiverName}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{description && (
|
{description && (
|
||||||
@@ -190,43 +225,71 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metadataRow}>
|
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
|
||||||
<Stack direction="row" gap={1}>
|
</Stack>
|
||||||
{error ? (
|
</div>
|
||||||
<>
|
);
|
||||||
{/* 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">
|
interface ContactPointReceiverMetadata {
|
||||||
<Stack direction="row" alignItems={'center'} gap={0.5}>
|
sendingResolved: boolean;
|
||||||
<Tooltip
|
diagnostics: NotifierStatus;
|
||||||
content={
|
}
|
||||||
'failed to send notification to email addresses: gilles.demey@grafana.com: dial tcp 192.168.1.21:1025: connect: connection refused'
|
|
||||||
}
|
const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => {
|
||||||
>
|
const { diagnostics, sendingResolved } = props;
|
||||||
<span>
|
const styles = useStyles2(getStyles);
|
||||||
<Icon name="exclamation-circle" /> Last delivery attempt failed
|
|
||||||
</span>
|
const failedToSend = Boolean(diagnostics.lastNotifyAttemptError);
|
||||||
</Tooltip>
|
const lastDeliveryAttempt = dateTime(diagnostics.lastNotifyAttempt);
|
||||||
</Stack>
|
const lastDeliveryAttemptDuration = diagnostics.lastNotifyAttemptDuration;
|
||||||
</Text>
|
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">
|
<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>
|
||||||
<MetaText icon="stopwatch">
|
<MetaText icon="stopwatch">
|
||||||
took <Strong>2s</Strong>
|
took <Strong>{lastDeliveryAttemptDuration}</Strong>
|
||||||
</MetaText>
|
</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 && (
|
{!sendingResolved && (
|
||||||
<MetaText icon="info-circle">
|
<MetaText icon="info-circle">
|
||||||
Delivering <Strong>only firing</Strong> notifications
|
Delivering <Strong>only firing</Strong> notifications
|
||||||
</MetaText>
|
</MetaText>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</>
|
||||||
</div>
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
+71
@@ -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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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;
|
||||||
+119
@@ -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,
|
||||||
|
}
|
||||||
|
`;
|
||||||
+18
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+79
-13
@@ -3,21 +3,87 @@
|
|||||||
* and (if available) it will also fetch the status from the Grafana Managed status endpoint
|
* 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
|
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||||
// each integration can have additional metadata assigned to it
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
export interface ContactPoint<T extends Notifier> {
|
|
||||||
notifiers: T[];
|
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 {
|
export function useDeleteContactPoint(selectedAlertmanager: string) {
|
||||||
type: NotifierType;
|
const [fetchAlertmanagerConfig] = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useLazyQuery();
|
||||||
}
|
const [updateAlertManager, updateAlertmanagerState] =
|
||||||
|
alertmanagerApi.endpoints.updateAlertmanagerConfiguration.useMutation();
|
||||||
|
|
||||||
// Grafana Managed contact points have receivers with additional diagnostics
|
const deleteTrigger = (contactPointName: string) => {
|
||||||
export interface NotifierWithDiagnostics extends Notifier {
|
return fetchAlertmanagerConfig(selectedAlertmanager).then(({ data }) => {
|
||||||
status: NotifierStatus;
|
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],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+2
-2
@@ -3,8 +3,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { LoadingPlaceholder } from '@grafana/ui';
|
import { LoadingPlaceholder } from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
|
GrafanaManagedContactPoint,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
Receiver,
|
|
||||||
TestReceiversAlert,
|
TestReceiversAlert,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
@@ -32,7 +32,7 @@ import { TestContactPointModal } from './TestContactPointModal';
|
|||||||
interface Props {
|
interface Props {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
existing?: Receiver;
|
existing?: GrafanaManagedContactPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultChannelValues: GrafanaChannelValues = Object.freeze({
|
const defaultChannelValues: GrafanaChannelValues = Object.freeze({
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
import { GrafanaManagedContactPoint } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||||
|
|
||||||
export interface AmRouteReceiver {
|
export interface AmRouteReceiver {
|
||||||
@@ -7,7 +7,7 @@ export interface AmRouteReceiver {
|
|||||||
grafanaAppReceiverType?: SupportedPlugin;
|
grafanaAppReceiverType?: SupportedPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReceiverWithTypes extends Receiver {
|
export interface ReceiverWithTypes extends GrafanaManagedContactPoint {
|
||||||
grafanaAppReceiverType?: SupportedPlugin;
|
grafanaAppReceiverType?: SupportedPlugin;
|
||||||
}
|
}
|
||||||
export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string> = {
|
export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string> = {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { backendSrv } from '../../../core/services/backend_srv';
|
|||||||
import {
|
import {
|
||||||
AlertmanagerConfig,
|
AlertmanagerConfig,
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
|
AlertmanagerReceiver,
|
||||||
EmailConfig,
|
EmailConfig,
|
||||||
MatcherOperator,
|
MatcherOperator,
|
||||||
Receiver,
|
|
||||||
Route,
|
Route,
|
||||||
} from '../../../plugins/datasource/alertmanager/types';
|
} from '../../../plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class EmailConfigBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AlertmanagerReceiverBuilder {
|
class AlertmanagerReceiverBuilder {
|
||||||
private receiver: Receiver = { name: '', email_configs: [] };
|
private receiver: AlertmanagerReceiver = { name: '', email_configs: [] };
|
||||||
|
|
||||||
withName(name: string): AlertmanagerReceiverBuilder {
|
withName(name: string): AlertmanagerReceiverBuilder {
|
||||||
this.receiver.name = name;
|
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() {
|
export function setupMswServer() {
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { isArray, isNil, omitBy } from 'lodash';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
|
AlertmanagerReceiver,
|
||||||
|
GrafanaManagedContactPoint,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
Receiver,
|
Receiver,
|
||||||
Route,
|
Route,
|
||||||
@@ -18,7 +20,7 @@ import {
|
|||||||
} from '../types/receiver-form';
|
} from '../types/receiver-form';
|
||||||
|
|
||||||
export function grafanaReceiverToFormValues(
|
export function grafanaReceiverToFormValues(
|
||||||
receiver: Receiver,
|
receiver: GrafanaManagedContactPoint,
|
||||||
notifiers: NotifierDTO[]
|
notifiers: NotifierDTO[]
|
||||||
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
||||||
const channelMap: GrafanaChannelMap = {};
|
const channelMap: GrafanaChannelMap = {};
|
||||||
@@ -92,7 +94,7 @@ export function formValuesToCloudReceiver(
|
|||||||
values: ReceiverFormValues<CloudChannelValues>,
|
values: ReceiverFormValues<CloudChannelValues>,
|
||||||
defaults: CloudChannelValues
|
defaults: CloudChannelValues
|
||||||
): Receiver {
|
): Receiver {
|
||||||
const recv: Receiver = {
|
const recv: AlertmanagerReceiver = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
};
|
};
|
||||||
values.items.forEach(({ __id, type, settings, sendResolved }) => {
|
values.items.forEach(({ __id, type, settings, sendResolved }) => {
|
||||||
@@ -101,11 +103,10 @@ export function formValuesToCloudReceiver(
|
|||||||
send_resolved: sendResolved ?? defaults.sendResolved,
|
send_resolved: sendResolved ?? defaults.sendResolved,
|
||||||
});
|
});
|
||||||
|
|
||||||
const configsKey = `${type}_configs`;
|
if (!(`${type}_configs` in recv)) {
|
||||||
if (!recv[configsKey]) {
|
recv[`${type}_configs`] = [channel];
|
||||||
recv[configsKey] = [channel];
|
|
||||||
} else {
|
} else {
|
||||||
(recv[configsKey] as unknown[]).push(channel);
|
(recv[`${type}_configs`] as unknown[]).push(channel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return recv;
|
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 { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
|
||||||
import { GrafanaManagedReceiverConfig, Receiver } from 'app/plugins/datasource/alertmanager/types';
|
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
|
type NotifierTypeCounts = Record<string, number>; // name : count
|
||||||
|
|
||||||
export function extractNotifierTypeCounts(receiver: Receiver, grafanaNotifiers: NotifierDTO[]): NotifierTypeCounts {
|
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 getGrafanaNotifierTypeCounts(receiver.grafana_managed_receiver_configs ?? [], grafanaNotifiers);
|
||||||
}
|
}
|
||||||
return getCortexAlertManagerNotifierTypeCounts(receiver);
|
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(
|
function getGrafanaNotifierTypeCounts(
|
||||||
configs: GrafanaManagedReceiverConfig[],
|
configs: GrafanaManagedReceiverConfig[],
|
||||||
grafanaNotifiers: NotifierDTO[]
|
grafanaNotifiers: NotifierDTO[]
|
||||||
|
|||||||
@@ -79,20 +79,22 @@ export type GrafanaManagedReceiverConfig = {
|
|||||||
provenance?: string;
|
provenance?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Receiver = {
|
export interface GrafanaManagedContactPoint {
|
||||||
|
name: string;
|
||||||
|
grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertmanagerReceiver {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
email_configs?: EmailConfig[];
|
email_configs?: EmailConfig[];
|
||||||
pagerduty_configs?: any[];
|
|
||||||
pushover_configs?: any[];
|
|
||||||
slack_configs?: any[];
|
|
||||||
opsgenie_configs?: any[];
|
|
||||||
webhook_configs?: WebhookConfig[];
|
webhook_configs?: WebhookConfig[];
|
||||||
victorops_configs?: any[];
|
|
||||||
wechat_configs?: any[];
|
// this is supposedly to support any *_configs
|
||||||
grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[];
|
[key: `${string}_configs`]: any[] | undefined;
|
||||||
[key: string]: any;
|
}
|
||||||
};
|
|
||||||
|
export type Receiver = GrafanaManagedContactPoint | AlertmanagerReceiver;
|
||||||
|
|
||||||
export type ObjectMatcher = [name: string, operator: MatcherOperator, value: string];
|
export type ObjectMatcher = [name: string, operator: MatcherOperator, value: string];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user