grafana/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
Gilles De Mey 702fd0f9e8
Docs: Update wording / text and copy (#61868)
Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com>
2023-01-30 15:26:21 +00:00

436 lines
15 KiB
TypeScript

import pluralize from 'pluralize';
import React, { FC, useMemo, useState } from 'react';
import { dateTime, dateTimeFormat } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, ConfirmModal, Modal, useStyles2, Badge, Icon } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch, AccessControlAction, ContactPointsState, NotifiersState, ReceiversState } from 'app/types';
import { useGetContactPointsState } from '../../api/receiversApi';
import { Authorize } from '../../components/Authorize';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { deleteReceiverAction } from '../../state/actions';
import { getAlertTableStyles } from '../../styles/table';
import { SupportedPlugin } from '../../types/pluginBridges';
import { getNotificationsPermissions } from '../../utils/access-control';
import { isReceiverUsed } from '../../utils/alertmanager';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { extractNotifierTypeCounts } from '../../utils/receivers';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { ProvisioningBadge } from '../Provisioning';
import { ActionIcon } from '../rules/ActionIcon';
import { ReceiversSection } from './ReceiversSection';
import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge';
import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp';
import { ReceiverWithTypes } from './grafanaAppReceivers/types';
interface UpdateActionProps extends ActionProps {
onClickDeleteReceiver: (receiverName: string) => void;
}
function UpdateActions({ permissions, alertManagerName, receiverName, onClickDeleteReceiver }: UpdateActionProps) {
return (
<>
<Authorize actions={[permissions.update]}>
<ActionIcon
aria-label="Edit"
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiverName)}/edit`,
alertManagerName
)}
tooltip="Edit contact point"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiverName)}
tooltip="Delete contact point"
icon="trash-alt"
/>
</Authorize>
</>
);
}
interface ActionProps {
permissions: {
read: AccessControlAction;
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
};
alertManagerName: string;
receiverName: string;
}
function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps) {
return (
<Authorize actions={[permissions.update]}>
<ActionIcon
data-testid="view"
to={makeAMLink(`/alerting/notifications/receivers/${encodeURIComponent(receiverName)}/edit`, alertManagerName)}
tooltip="View contact point"
icon="file-alt"
/>
</Authorize>
);
}
interface ReceiverErrorProps {
errorCount: number;
errorDetail?: string;
showErrorCount: boolean;
}
function ReceiverError({ errorCount, errorDetail, showErrorCount }: ReceiverErrorProps) {
const text = showErrorCount ? `${errorCount} ${pluralize('error', errorCount)}` : 'Error';
return <Badge color="orange" icon="exclamation-triangle" text={text} tooltip={errorDetail ?? 'Error'} />;
}
interface NotifierHealthProps {
errorsByNotifier: number;
errorDetail?: string;
lastNotify: string;
}
function NotifierHealth({ errorsByNotifier, errorDetail, lastNotify }: NotifierHealthProps) {
const noErrorsColor = isLastNotifyNullDate(lastNotify) ? 'orange' : 'green';
const noErrorsText = isLastNotifyNullDate(lastNotify) ? 'No attempts' : 'OK';
return errorsByNotifier > 0 ? (
<ReceiverError errorCount={errorsByNotifier} errorDetail={errorDetail} showErrorCount={false} />
) : (
<Badge color={noErrorsColor} text={noErrorsText} tooltip="" />
);
}
interface ReceiverHealthProps {
errorsByReceiver: number;
someWithNoAttempt: boolean;
}
function ReceiverHealth({ errorsByReceiver, someWithNoAttempt }: ReceiverHealthProps) {
const noErrorsColor = someWithNoAttempt ? 'orange' : 'green';
const noErrorsText = someWithNoAttempt ? 'No attempts' : 'OK';
return errorsByReceiver > 0 ? (
<ReceiverError errorCount={errorsByReceiver} showErrorCount={true} />
) : (
<Badge color={noErrorsColor} text={noErrorsText} tooltip="" />
);
}
const useContactPointsState = (alertManagerName: string) => {
const contactPointsState = useGetContactPointsState(alertManagerName);
const receivers: ReceiversState = contactPointsState?.receivers ?? {};
const errorStateAvailable = Object.keys(receivers).length > 0;
return { contactPointsState, errorStateAvailable };
};
interface ReceiverItem {
name: string;
types: string[];
provisioned?: boolean;
grafanaAppReceiverType?: SupportedPlugin;
}
interface NotifierStatus {
lastError?: null | string;
lastNotify: string;
lastNotifyDuration: string;
type: string;
sendResolved?: boolean;
}
type RowTableColumnProps = DynamicTableColumnProps<ReceiverItem>;
type RowItemTableProps = DynamicTableItemProps<ReceiverItem>;
type NotifierTableColumnProps = DynamicTableColumnProps<NotifierStatus>;
type NotifierItemTableProps = DynamicTableItemProps<NotifierStatus>;
interface NotifiersTableProps {
notifiersState: NotifiersState;
}
const isLastNotifyNullDate = (lastNotify: string) => lastNotify === '0001-01-01T00:00:00.000Z';
function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) {
if (isLastNotifyNullDate(lastNotifyDate)) {
return <>{'-'}</>;
} else {
return (
<Stack alignItems="center">
<div>{`${dateTime(lastNotifyDate).locale('en').fromNow(true)} ago`}</div>
<Icon name="clock-nine" />
<div>{`${dateTimeFormat(lastNotifyDate, { format: 'YYYY-MM-DD HH:mm:ss' })}`}</div>
</Stack>
);
}
}
const possibleNullDurations = ['', '0', '0ms', '0s', '0m', '0h', '0d', '0w', '0y'];
const durationIsNull = (duration: string) => possibleNullDurations.includes(duration);
function NotifiersTable({ notifiersState }: NotifiersTableProps) {
function getNotifierColumns(): NotifierTableColumnProps[] {
return [
{
id: 'health',
label: 'Health',
renderCell: ({ data: { lastError, lastNotify } }) => {
return (
<NotifierHealth
errorsByNotifier={lastError ? 1 : 0}
errorDetail={lastError ?? undefined}
lastNotify={lastNotify}
/>
);
},
size: 0.5,
},
{
id: 'name',
label: 'Name',
renderCell: ({ data: { type }, id }) => <>{`${type}[${id}]`}</>,
size: 1,
},
{
id: 'lastNotify',
label: 'Last delivery attempt',
renderCell: ({ data: { lastNotify } }) => <LastNotify lastNotifyDate={lastNotify} />,
size: 3,
},
{
id: 'lastNotifyDuration',
label: 'Last duration',
renderCell: ({ data: { lastNotify, lastNotifyDuration } }) => (
<>{isLastNotifyNullDate(lastNotify) && durationIsNull(lastNotifyDuration) ? '-' : lastNotifyDuration}</>
),
size: 1,
},
{
id: 'sendResolved',
label: 'Send resolved',
renderCell: ({ data: { sendResolved } }) => <>{String(Boolean(sendResolved))}</>,
size: 1,
},
];
}
const notifierRows: NotifierItemTableProps[] = Object.entries(notifiersState).flatMap((typeState) =>
typeState[1].map((notifierStatus, index) => {
return {
id: index,
data: {
type: typeState[0],
lastError: notifierStatus.lastNotifyAttemptError,
lastNotify: notifierStatus.lastNotifyAttempt,
lastNotifyDuration: notifierStatus.lastNotifyAttemptDuration,
sendResolved: notifierStatus.sendResolved,
},
};
})
);
return <DynamicTable items={notifierRows} cols={getNotifierColumns()} />;
}
interface Props {
config: AlertManagerCortexConfig;
alertManagerName: string;
}
export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
const dispatch = useDispatch();
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
const permissions = getNotificationsPermissions(alertManagerName);
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName);
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
const [receiverToDelete, setReceiverToDelete] = useState<string>();
const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false);
const onClickDeleteReceiver = (receiverName: string): void => {
if (isReceiverUsed(receiverName, config)) {
setShowCannotDeleteReceiverModal(true);
} else {
setReceiverToDelete(receiverName);
}
};
const deleteReceiver = () => {
if (receiverToDelete) {
dispatch(deleteReceiverAction(receiverToDelete, alertManagerName));
}
setReceiverToDelete(undefined);
};
const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []);
const rows: RowItemTableProps[] = useMemo(() => {
return (
receivers?.map((receiver: ReceiverWithTypes) => ({
id: receiver.name,
data: {
name: receiver.name,
types: Object.entries(extractNotifierTypeCounts(receiver, grafanaNotifiers.result ?? [])).map(
([type, count]) => {
if (count > 1) {
return `${type} (${count})`;
}
return type;
}
),
grafanaAppReceiverType: receiver.grafanaAppReceiverType,
provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance),
},
})) ?? []
);
}, [grafanaNotifiers.result, receivers]);
const columns = useGetColumns(
alertManagerName,
errorStateAvailable,
contactPointsState,
onClickDeleteReceiver,
permissions,
isVanillaAM
);
return (
<ReceiversSection
title="Contact points"
description="Define where notifications are sent, for example, email or Slack."
showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)}
addButtonLabel={'Add contact point'}
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
>
<DynamicTable
items={rows}
cols={columns}
isExpandable={errorStateAvailable}
renderExpandedContent={
errorStateAvailable
? ({ data: { name } }) => (
<NotifiersTable notifiersState={contactPointsState?.receivers[name]?.notifiers ?? {}} />
)
: undefined
}
/>
{!!showCannotDeleteReceiverModal && (
<Modal
isOpen={true}
title="Cannot delete contact point"
onDismiss={() => setShowCannotDeleteReceiverModal(false)}
>
<p>
Contact point cannot be deleted because it is used in more policies. Please update or delete these policies
first.
</p>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => setShowCannotDeleteReceiverModal(false)} fill="outline">
Close
</Button>
</Modal.ButtonRow>
</Modal>
)}
{!!receiverToDelete && (
<ConfirmModal
isOpen={true}
title="Delete contact point"
body={`Are you sure you want to delete contact point "${receiverToDelete}"?`}
confirmText="Yes, delete"
onConfirm={deleteReceiver}
onDismiss={() => setReceiverToDelete(undefined)}
/>
)}
</ReceiversSection>
);
};
const errorsByReceiver = (contactPointsState: ContactPointsState, receiverName: string) =>
contactPointsState?.receivers[receiverName]?.errorCount ?? 0;
const someNotifiersWithNoAttempt = (contactPointsState: ContactPointsState, receiverName: string) => {
const notifiers = Object.values(contactPointsState?.receivers[receiverName]?.notifiers ?? {});
const hasSomeWitNoAttempt =
notifiers.length === 0 || notifiers.flat().some((status) => isLastNotifyNullDate(status.lastNotifyAttempt));
return hasSomeWitNoAttempt;
};
function useGetColumns(
alertManagerName: string,
errorStateAvailable: boolean,
contactPointsState: ContactPointsState | undefined,
onClickDeleteReceiver: (receiverName: string) => void,
permissions: {
read: AccessControlAction;
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
},
isVanillaAM: boolean
): RowTableColumnProps[] {
const tableStyles = useStyles2(getAlertTableStyles);
const baseColumns: RowTableColumnProps[] = [
{
id: 'name',
label: 'Contact point name',
renderCell: ({ data: { name, provisioned } }) => (
<Stack alignItems="center">
<div>{name}</div>
{provisioned && <ProvisioningBadge />}
</Stack>
),
size: 1,
},
{
id: 'type',
label: 'Type',
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
),
size: 1,
},
];
const healthColumn: RowTableColumnProps = {
id: 'health',
label: 'Health',
renderCell: ({ data: { name } }) => {
return (
contactPointsState && (
<ReceiverHealth
errorsByReceiver={errorsByReceiver(contactPointsState, name)}
someWithNoAttempt={someNotifiersWithNoAttempt(contactPointsState, name)}
/>
)
);
},
size: 1,
};
return [
...baseColumns,
...(errorStateAvailable ? [healthColumn] : []),
{
id: 'actions',
label: 'Actions',
renderCell: ({ data: { provisioned, name } }) => (
<Authorize actions={[permissions.update, permissions.delete]}>
<div className={tableStyles.actionsCell}>
{!isVanillaAM && !provisioned && (
<UpdateActions
permissions={permissions}
alertManagerName={alertManagerName}
receiverName={name}
onClickDeleteReceiver={onClickDeleteReceiver}
/>
)}
{(isVanillaAM || provisioned) && (
<ViewAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
</div>
</Authorize>
),
size: '100px',
},
];
}