mirror of https://github.com/grafana/grafana
Alerting: Split out contact points components to separate files (#90605)
parent
042c239a56
commit
4e364ea043
@ -0,0 +1,8 @@ |
||||
// These are convenience components to deal with i18n shenanigans
|
||||
// (see https://github.com/grafana/grafana/blob/main/contribute/internationalization.md#jsx)
|
||||
// These help when we need to interpolate variables inside translated strings,
|
||||
// where we need to style them differently
|
||||
|
||||
import { Text } from '@grafana/ui'; |
||||
|
||||
export const PrimaryText = ({ content }: { content: string }) => <Text color="primary">{content}</Text>; |
@ -0,0 +1,272 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { groupBy, size, upperFirst } from 'lodash'; |
||||
import { Fragment, ReactNode } from 'react'; |
||||
|
||||
import { dateTime, GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants'; |
||||
import { ContactPointHeader } from 'app/features/alerting/unified/components/contact-points/ContactPointHeader'; |
||||
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; |
||||
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; |
||||
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; |
||||
|
||||
import { INTEGRATION_ICONS } from '../../types/contact-points'; |
||||
import { MetaText } from '../MetaText'; |
||||
import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/ReceiverMetadataBadge'; |
||||
import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata'; |
||||
|
||||
import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from './useContactPoints'; |
||||
import { getReceiverDescription, ReceiverConfigWithMetadata, RouteReference } from './utils'; |
||||
|
||||
interface ContactPointProps { |
||||
name: string; |
||||
disabled?: boolean; |
||||
provisioned?: boolean; |
||||
receivers: ReceiverConfigWithMetadata[]; |
||||
policies?: RouteReference[]; |
||||
onDelete: (name: string) => void; |
||||
} |
||||
|
||||
export const ContactPoint = ({ |
||||
name, |
||||
disabled = false, |
||||
provisioned = false, |
||||
receivers, |
||||
policies = [], |
||||
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 className={styles.integrationWrapper}> |
||||
<ContactPointReceiverSummary receivers={receivers} /> |
||||
</div> |
||||
)} |
||||
</Stack> |
||||
</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 hasMetadata = diagnostics !== undefined; |
||||
|
||||
return ( |
||||
<div className={styles.integrationWrapper}> |
||||
<Stack direction="column" gap={0.5}> |
||||
<ContactPointReceiverTitleRow |
||||
name={name} |
||||
type={type} |
||||
description={description} |
||||
pluginMetadata={pluginMetadata} |
||||
/> |
||||
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />} |
||||
</Stack> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export interface ContactPointReceiverTitleRowProps { |
||||
name: string; |
||||
type: GrafanaNotifierType | string; |
||||
description?: ReactNode; |
||||
pluginMetadata?: ReceiverPluginMetadata; |
||||
} |
||||
|
||||
export function ContactPointReceiverTitleRow(props: ContactPointReceiverTitleRowProps) { |
||||
const { name, type, description, pluginMetadata } = props; |
||||
|
||||
const iconName = INTEGRATION_ICONS[type]; |
||||
|
||||
return ( |
||||
<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> |
||||
); |
||||
} |
||||
|
||||
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 |
||||
*/ |
||||
export const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => { |
||||
const countByType = groupBy(receivers, (receiver) => receiver.type); |
||||
|
||||
return ( |
||||
<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 ( |
||||
<Fragment key={type}> |
||||
<Stack direction="row" alignItems="center" gap={0.5}> |
||||
{iconName && <Icon name={iconName} />} |
||||
<Text variant="body"> |
||||
{receiverName} |
||||
{receivers.length > 1 && receivers.length} |
||||
</Text> |
||||
</Stack> |
||||
{!isLastItem && '⋅'} |
||||
</Fragment> |
||||
); |
||||
})} |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
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> |
||||
<Trans i18nKey="alerting.contact-points.last-delivery-failed">Last delivery attempt failed</Trans> |
||||
</span> |
||||
</Tooltip> |
||||
</MetaText> |
||||
</> |
||||
) : ( |
||||
<> |
||||
{/* this is shown when we have a last delivery attempt */} |
||||
{hasDeliveryAttempt && ( |
||||
<> |
||||
<MetaText icon="clock-nine"> |
||||
<Trans i18nKey="alerting.contact-points.last-delivery-attempt">Last delivery attempt</Trans> |
||||
<Tooltip content={lastDeliveryAttempt.toLocaleString()}> |
||||
<span> |
||||
<Text color="primary">{lastDeliveryAttempt.locale('en').fromNow()}</Text> |
||||
</span> |
||||
</Tooltip> |
||||
</MetaText> |
||||
<MetaText icon="stopwatch"> |
||||
<Trans i18nKey="alerting.contact-points.delivery-duration"> |
||||
Last delivery took <PrimaryText content={lastDeliveryAttemptDuration} /> |
||||
</Trans> |
||||
</MetaText> |
||||
</> |
||||
)} |
||||
{/* when we have no last delivery attempt */} |
||||
{!hasDeliveryAttempt && ( |
||||
<MetaText icon="clock-nine"> |
||||
<Trans i18nKey="alerting.contact-points.no-delivery-attempts">No delivery attempts</Trans> |
||||
</MetaText> |
||||
)} |
||||
{/* this is only shown for contact points that only want "firing" updates */} |
||||
{!sendingResolved && ( |
||||
<MetaText icon="info-circle"> |
||||
<Trans i18nKey="alerting.contact-points.only-firing"> |
||||
Delivering <Text color="primary">only firing</Text> notifications |
||||
</Trans> |
||||
</MetaText> |
||||
)} |
||||
</> |
||||
)} |
||||
</Stack> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
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}`, |
||||
}), |
||||
metadataRow: css({ |
||||
borderBottomLeftRadius: `${theme.shape.radius.default}`, |
||||
borderBottomRightRadius: `${theme.shape.radius.default}`, |
||||
}), |
||||
}); |
@ -0,0 +1,144 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { Fragment } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Dropdown, LinkButton, Menu, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap'; |
||||
import { useExportContactPoint } from 'app/features/alerting/unified/components/contact-points/useExportContactPoint'; |
||||
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; |
||||
import { createUrl } from '../../utils/url'; |
||||
import MoreButton from '../MoreButton'; |
||||
import { ProvisioningBadge } from '../Provisioning'; |
||||
import { Spacer } from '../Spacer'; |
||||
|
||||
import { UnusedContactPointBadge } from './components/UnusedBadge'; |
||||
import { RouteReference } from './utils'; |
||||
|
||||
interface ContactPointHeaderProps { |
||||
name: string; |
||||
disabled?: boolean; |
||||
provisioned?: boolean; |
||||
policies?: RouteReference[]; |
||||
onDelete: (name: string) => void; |
||||
} |
||||
|
||||
export const ContactPointHeader = (props: ContactPointHeaderProps) => { |
||||
const { name, disabled = false, provisioned = false, policies = [], 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 numberOfPolicies = policies.length; |
||||
const isReferencedByAnyPolicy = numberOfPolicies > 0; |
||||
const isReferencedByRegularPolicies = policies.some((ref) => ref.route.type !== 'auto-generated'); |
||||
|
||||
const canEdit = editSupported && editAllowed && !provisioned; |
||||
const canDelete = deleteSupported && deleteAllowed && !provisioned && !isReferencedByRegularPolicies; |
||||
|
||||
const menuActions: JSX.Element[] = []; |
||||
|
||||
if (exportSupported) { |
||||
menuActions.push( |
||||
<Fragment key="export-contact-point"> |
||||
<Menu.Item |
||||
icon="download-alt" |
||||
label="Export" |
||||
ariaLabel="export" |
||||
disabled={!exportAllowed} |
||||
data-testid="export" |
||||
onClick={() => openExportDrawer(name)} |
||||
/> |
||||
<Menu.Divider /> |
||||
</Fragment> |
||||
); |
||||
} |
||||
|
||||
if (deleteSupported) { |
||||
menuActions.push( |
||||
<ConditionalWrap |
||||
key="delete-contact-point" |
||||
shouldWrap={!canDelete} |
||||
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" |
||||
ariaLabel="delete" |
||||
icon="trash-alt" |
||||
destructive |
||||
disabled={disabled || !canDelete} |
||||
onClick={() => onDelete(name)} |
||||
/> |
||||
</ConditionalWrap> |
||||
); |
||||
} |
||||
|
||||
const referencedByPoliciesText = t('alerting.contact-points.used-by', 'Used by {{ count }} notification policy', { |
||||
count: numberOfPolicies, |
||||
}); |
||||
|
||||
return ( |
||||
<div className={styles.headerWrapper}> |
||||
<Stack direction="row" alignItems="center" gap={1}> |
||||
<Stack alignItems="center" gap={1}> |
||||
<Text element="h2" variant="body" weight="medium"> |
||||
{name} |
||||
</Text> |
||||
</Stack> |
||||
{isReferencedByAnyPolicy && ( |
||||
<TextLink |
||||
href={createUrl('/alerting/routes', { contactPoint: name })} |
||||
variant="bodySmall" |
||||
color="primary" |
||||
inline={false} |
||||
> |
||||
{referencedByPoliciesText} |
||||
</TextLink> |
||||
)} |
||||
{provisioned && <ProvisioningBadge />} |
||||
{!isReferencedByAnyPolicy && <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> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
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}`, |
||||
}), |
||||
}); |
@ -0,0 +1,42 @@ |
||||
import uFuzzy from '@leeoniya/ufuzzy'; |
||||
import { uniq } from 'lodash'; |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { RECEIVER_META_KEY } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; |
||||
import { ContactPointWithMetadata } from 'app/features/alerting/unified/components/contact-points/utils'; |
||||
|
||||
const fuzzyFinder = new uFuzzy({ |
||||
intraMode: 1, |
||||
intraIns: 1, |
||||
intraSub: 1, |
||||
intraDel: 1, |
||||
intraTrn: 1, |
||||
}); |
||||
|
||||
// let's search in two different haystacks, the name of the contact point and the type of the receiver(s)
|
||||
export const useContactPointsSearch = ( |
||||
contactPoints: ContactPointWithMetadata[], |
||||
search?: string | null |
||||
): ContactPointWithMetadata[] => { |
||||
const nameHaystack = useMemo(() => { |
||||
return contactPoints.map((contactPoint) => contactPoint.name); |
||||
}, [contactPoints]); |
||||
|
||||
const typeHaystack = useMemo(() => { |
||||
return contactPoints.map((contactPoint) => |
||||
// we're using the resolved metadata key here instead of the "type" property – ex. we alias "teams" to "microsoft teams"
|
||||
contactPoint.grafana_managed_receiver_configs.map((receiver) => receiver[RECEIVER_META_KEY].name).join(' ') |
||||
); |
||||
}, [contactPoints]); |
||||
|
||||
if (!search) { |
||||
return contactPoints; |
||||
} |
||||
|
||||
const nameHits = fuzzyFinder.filter(nameHaystack, search) ?? []; |
||||
const typeHits = fuzzyFinder.filter(typeHaystack, search) ?? []; |
||||
|
||||
const hits = [...nameHits, ...typeHits]; |
||||
|
||||
return uniq(hits).map((id) => contactPoints[id]) ?? []; |
||||
}; |
@ -0,0 +1,44 @@ |
||||
import { useCallback, useMemo, useState } from 'react'; |
||||
import { useToggle } from 'react-use'; |
||||
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; |
||||
import { GrafanaReceiverExporter } from '../export/GrafanaReceiverExporter'; |
||||
import { GrafanaReceiversExporter } from '../export/GrafanaReceiversExporter'; |
||||
|
||||
export const ALL_CONTACT_POINTS = Symbol('all contact points'); |
||||
|
||||
type ExportProps = [JSX.Element | null, (receiver: string | typeof ALL_CONTACT_POINTS) => void]; |
||||
|
||||
export 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]; |
||||
}; |
Loading…
Reference in new issue