mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
603 lines
21 KiB
TypeScript
603 lines
21 KiB
TypeScript
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;
|