mirror of https://github.com/grafana/grafana.git
				
				
				
			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