Files
grafana/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
2023-10-25 15:57:53 +02:00

603 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { css } from '@emotion/css';
import { SerializedError } from '@reduxjs/toolkit';
import { groupBy, size, upperFirst } from 'lodash';
import pluralize from 'pluralize';
import React, { Fragment, ReactNode, useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useToggle } from 'react-use';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import {
Alert,
Dropdown,
Icon,
LoadingPlaceholder,
Menu,
Tooltip,
useStyles2,
Text,
LinkButton,
TabsBar,
TabContent,
Tab,
Pagination,
Button,
} from '@grafana/ui';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { createUrl } from '../../utils/url';
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
import { MetaText } from '../MetaText';
import MoreButton from '../MoreButton';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { Strong } from '../Strong';
import { GrafanaReceiverExporter } from '../export/GrafanaReceiverExporter';
import { GrafanaReceiversExporter } from '../export/GrafanaReceiversExporter';
import { GlobalConfigAlert } from '../receivers/ReceiversAndTemplatesView';
import { UnusedContactPointBadge } from '../receivers/ReceiversTable';
import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/ReceiverMetadataBadge';
import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
import { MessageTemplates } from './MessageTemplates';
import { useDeleteContactPointModal } from './Modals';
import {
RECEIVER_META_KEY,
RECEIVER_PLUGIN_META_KEY,
RECEIVER_STATUS_KEY,
useContactPointsWithStatus,
useDeleteContactPoint,
} from './useContactPoints';
import { ContactPointWithMetadata, getReceiverDescription, isProvisioned, ReceiverConfigWithMetadata } from './utils';
enum ActiveTab {
ContactPoints,
MessageTemplates,
}
const DEFAULT_PAGE_SIZE = 10;
const ContactPoints = () => {
const { selectedAlertmanager } = useAlertmanager();
// TODO hook up to query params
const [activeTab, setActiveTab] = useState<ActiveTab>(ActiveTab.ContactPoints);
let { isLoading, error, contactPoints } = useContactPointsWithStatus();
const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!);
const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateContactPoint
);
const [exportContactPointsSupported, exportContactPointsAllowed] = useAlertmanagerAbility(
AlertmanagerAction.ExportContactPoint
);
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
const [ExportDrawer, showExportDrawer] = useExportContactPoint();
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showingMessageTemplates = activeTab === ActiveTab.MessageTemplates;
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>;
}
const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
return (
<>
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager!} />
<Stack direction="column">
<TabsBar>
<Tab
label="Contact Points"
active={showingContactPoints}
counter={contactPoints.length}
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
/>
<Tab
label="Message Templates"
active={showingMessageTemplates}
onChangeTab={() => setActiveTab(ActiveTab.MessageTemplates)}
/>
</TabsBar>
<TabContent>
<Stack direction="column">
<>
{isLoading && <LoadingPlaceholder text={'Loading...'} />}
{/* Contact Points tab */}
{showingContactPoints && (
<>
{error ? (
<Alert title="Failed to fetch contact points">{String(error)}</Alert>
) : (
<>
{/* TODO we can add some additional info here with a ToggleTip */}
<Stack direction="row" alignItems="center">
<Text variant="body" color="secondary">
Define where notifications are sent, a contact point can contain multiple integrations.
</Text>
<Spacer />
<Stack direction="row" gap={1}>
{addContactPointSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/receivers/new"
disabled={!addContactPointAllowed}
>
Add contact point
</LinkButton>
)}
{exportContactPointsSupported && (
<Button
icon="download-alt"
variant="secondary"
disabled={!exportContactPointsAllowed}
onClick={() => showExportDrawer(ALL_CONTACT_POINTS)}
>
Export all
</Button>
)}
</Stack>
</Stack>
<ContactPointsList
contactPoints={contactPoints}
pageSize={DEFAULT_PAGE_SIZE}
onDelete={(name) => showDeleteModal(name)}
disabled={updateAlertmanagerState.isLoading}
/>
{/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */}
{!isGrafanaManagedAlertmanager && <GlobalConfigAlert alertManagerName={selectedAlertmanager!} />}
</>
)}
</>
)}
{/* Message Templates tab */}
{showingMessageTemplates && (
<>
<Stack direction="row" alignItems="center">
<Text variant="body" color="secondary">
Create message templates to customize your notifications.
</Text>
<Spacer />
<LinkButton icon="plus" variant="primary" href="/alerting/notifications/templates/new">
Add message template
</LinkButton>
</Stack>
<MessageTemplates />
</>
)}
</>
</Stack>
</TabContent>
</Stack>
{DeleteModal}
{ExportDrawer}
</>
);
};
interface ContactPointsListProps {
contactPoints: ContactPointWithMetadata[];
disabled?: boolean;
onDelete: (name: string) => void;
pageSize?: number;
}
const ContactPointsList = ({
contactPoints,
disabled = false,
pageSize = DEFAULT_PAGE_SIZE,
onDelete,
}: ContactPointsListProps) => {
const { page, pageItems, numberOfPages, onPageChange } = usePagination(contactPoints, 1, pageSize);
return (
<>
{pageItems.map((contactPoint, index) => {
const provisioned = isProvisioned(contactPoint);
const policies = contactPoint.numberOfPolicies;
return (
<ContactPoint
key={`${contactPoint.name}-${index}`}
name={contactPoint.name}
disabled={disabled}
onDelete={onDelete}
receivers={contactPoint.grafana_managed_receiver_configs}
provisioned={provisioned}
policies={policies}
/>
);
})}
<Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} hideWhenSinglePage />
</>
);
};
interface ContactPointProps {
name: string;
disabled?: boolean;
provisioned?: boolean;
receivers: ReceiverConfigWithMetadata[];
policies?: number;
onDelete: (name: string) => void;
}
export const ContactPoint = ({
name,
disabled = false,
provisioned = false,
receivers,
policies = 0,
onDelete,
}: ContactPointProps) => {
const styles = useStyles2(getStyles);
// TODO probably not the best way to figure out if we want to show either only the summary or full metadata for the receivers?
const showFullMetadata = receivers.some((receiver) => Boolean(receiver[RECEIVER_STATUS_KEY]));
return (
<div className={styles.contactPointWrapper} data-testid="contact-point">
<Stack direction="column" gap={0}>
<ContactPointHeader
name={name}
policies={policies}
provisioned={provisioned}
disabled={disabled}
onDelete={onDelete}
/>
{showFullMetadata ? (
<div>
{receivers.map((receiver, index) => {
const diagnostics = receiver[RECEIVER_STATUS_KEY];
const metadata = receiver[RECEIVER_META_KEY];
const sendingResolved = !Boolean(receiver.disableResolveMessage);
const pluginMetadata = receiver[RECEIVER_PLUGIN_META_KEY];
const key = metadata.name + index;
return (
<ContactPointReceiver
key={key}
name={metadata.name}
type={receiver.type}
description={getReceiverDescription(receiver)}
diagnostics={diagnostics}
pluginMetadata={pluginMetadata}
sendingResolved={sendingResolved}
/>
);
})}
</div>
) : (
<div>
<ContactPointReceiverSummary receivers={receivers} />
</div>
)}
</Stack>
</div>
);
};
interface ContactPointHeaderProps {
name: string;
disabled?: boolean;
provisioned?: boolean;
policies?: number;
onDelete: (name: string) => void;
}
const ContactPointHeader = (props: ContactPointHeaderProps) => {
const { name, disabled = false, provisioned = false, policies = 0, onDelete } = props;
const styles = useStyles2(getStyles);
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint);
const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint);
const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint);
const [ExportDrawer, openExportDrawer] = useExportContactPoint();
const isReferencedByPolicies = policies > 0;
const canEdit = editSupported && editAllowed && !provisioned;
const canDelete = deleteSupported && deleteAllowed && !provisioned && policies === 0;
const menuActions: JSX.Element[] = [];
if (exportSupported) {
menuActions.push(
<Fragment key="export-contact-point">
<Menu.Item
icon="download-alt"
label="Export"
disabled={!exportAllowed}
data-testid="export"
onClick={() => openExportDrawer(name)}
/>
<Menu.Divider />
</Fragment>
);
}
if (deleteSupported) {
menuActions.push(
<ConditionalWrap
key="delete-contact-point"
shouldWrap={isReferencedByPolicies}
wrap={(children) => (
<Tooltip content="Contact point is currently in use by one or more notification policies" placement="top">
<span>{children}</span>
</Tooltip>
)}
>
<Menu.Item
label="Delete"
icon="trash-alt"
destructive
disabled={disabled || !canDelete}
onClick={() => onDelete(name)}
/>
</ConditionalWrap>
);
}
return (
<div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={1}>
<Text variant="body" weight="medium">
{name}
</Text>
</Stack>
{isReferencedByPolicies && (
<MetaText>
<Link to={createUrl('/alerting/routes', { contactPoint: name })}>
is used by <Strong>{policies}</Strong> {pluralize('notification policy', policies)}
</Link>
</MetaText>
)}
{provisioned && <ProvisioningBadge />}
{!isReferencedByPolicies && <UnusedContactPointBadge />}
<Spacer />
<LinkButton
tooltipPlacement="top"
tooltip={provisioned ? 'Provisioned contact points cannot be edited in the UI' : undefined}
variant="secondary"
size="sm"
icon={canEdit ? 'pen' : 'eye'}
type="button"
disabled={disabled}
aria-label={`${canEdit ? 'edit' : 'view'}-action`}
data-testid={`${canEdit ? 'edit' : 'view'}-action`}
href={`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`}
>
{canEdit ? 'Edit' : 'View'}
</LinkButton>
{menuActions.length > 0 && (
<Dropdown overlay={<Menu>{menuActions}</Menu>}>
<MoreButton />
</Dropdown>
)}
</Stack>
{ExportDrawer}
</div>
);
};
interface ContactPointReceiverProps {
name: string;
type: GrafanaNotifierType | string;
description?: ReactNode;
sendingResolved?: boolean;
diagnostics?: NotifierStatus;
pluginMetadata?: ReceiverPluginMetadata;
}
const ContactPointReceiver = (props: ContactPointReceiverProps) => {
const { name, type, description, diagnostics, pluginMetadata, sendingResolved = true } = props;
const styles = useStyles2(getStyles);
const iconName = INTEGRATION_ICONS[type];
const hasMetadata = diagnostics !== undefined;
return (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0.5}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
{pluginMetadata ? (
<ReceiverMetadataBadge metadata={pluginMetadata} />
) : (
<Text variant="body" color="primary">
{name}
</Text>
)}
</Stack>
{description && (
<Text variant="bodySmall" color="secondary">
{description}
</Text>
)}
</Stack>
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
</Stack>
</div>
);
};
interface ContactPointReceiverMetadata {
sendingResolved: boolean;
diagnostics: NotifierStatus;
}
type ContactPointReceiverSummaryProps = {
receivers: GrafanaManagedReceiverConfig[];
};
/**
* This summary is used when we're dealing with non-Grafana managed alertmanager since they
* don't have any metadata worth showing other than a summary of what types are configured for the contact point
*/
const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
const styles = useStyles2(getStyles);
const countByType = groupBy(receivers, (receiver) => receiver.type);
return (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}>
{Object.entries(countByType).map(([type, receivers], index) => {
const iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index;
return (
<React.Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
<Text variant="body" color="primary">
{receiverName}
{receivers.length > 1 && <> ({receivers.length})</>}
</Text>
</Stack>
{!isLastItem && '⋅'}
</React.Fragment>
);
})}
</Stack>
</Stack>
</div>
);
};
const ContactPointReceiverMetadataRow = ({ diagnostics, sendingResolved }: ContactPointReceiverMetadata) => {
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 ? (
<>
<MetaText color="error" icon="exclamation-circle">
<Tooltip content={diagnostics.lastNotifyAttemptError!}>
<span>Last delivery attempt failed</span>
</Tooltip>
</MetaText>
</>
) : (
<>
{/* this is shown when we have a last delivery attempt */}
{hasDeliveryAttempt && (
<>
<MetaText icon="clock-nine">
Last delivery attempt{' '}
<Tooltip content={lastDeliveryAttempt.toLocaleString()}>
<span>
<Strong>{lastDeliveryAttempt.locale('en').fromNow()}</Strong>
</span>
</Tooltip>
</MetaText>
<MetaText icon="stopwatch">
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>
);
};
const ALL_CONTACT_POINTS = Symbol('all contact points');
type ExportProps = [JSX.Element | null, (receiver: string | typeof ALL_CONTACT_POINTS) => void];
const useExportContactPoint = (): ExportProps => {
const [receiverName, setReceiverName] = useState<string | typeof ALL_CONTACT_POINTS | null>(null);
const [isExportDrawerOpen, toggleShowExportDrawer] = useToggle(false);
const [decryptSecretsSupported, decryptSecretsAllowed] = useAlertmanagerAbility(AlertmanagerAction.DecryptSecrets);
const canReadSecrets = decryptSecretsSupported && decryptSecretsAllowed;
const handleClose = useCallback(() => {
setReceiverName(null);
toggleShowExportDrawer(false);
}, [toggleShowExportDrawer]);
const handleOpen = (receiverName: string | typeof ALL_CONTACT_POINTS) => {
setReceiverName(receiverName);
toggleShowExportDrawer(true);
};
const drawer = useMemo(() => {
if (!receiverName || !isExportDrawerOpen) {
return null;
}
if (receiverName === ALL_CONTACT_POINTS) {
// use this drawer when we want to export all contact points
return <GrafanaReceiversExporter decrypt={canReadSecrets} onClose={handleClose} />;
} else {
// use this one for exporting a single contact point
return <GrafanaReceiverExporter receiverName={receiverName} decrypt={canReadSecrets} onClose={handleClose} />;
}
}, [canReadSecrets, isExportDrawerOpen, handleClose, receiverName]);
return [drawer, handleOpen];
};
const getStyles = (theme: GrafanaTheme2) => ({
contactPointWrapper: css({
borderRadius: `${theme.shape.radius.default}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderBottom: 'none',
}),
integrationWrapper: css({
position: 'relative',
background: `${theme.colors.background.primary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
}),
headerWrapper: css({
background: `${theme.colors.background.secondary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
borderTopLeftRadius: `${theme.shape.radius.default}`,
borderTopRightRadius: `${theme.shape.radius.default}`,
}),
metadataRow: css({
borderBottomLeftRadius: `${theme.shape.radius.default}`,
borderBottomRightRadius: `${theme.shape.radius.default}`,
}),
});
export default ContactPoints;