mirror of
https://github.com/grafana/grafana.git
synced 2025-02-09 23:16:16 -06:00
Alerting: Split out contact points components to separate files (#90605)
This commit is contained in:
parent
042c239a56
commit
4e364ea043
@ -1706,21 +1706,10 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/contact-points/components/ContactPointsFilter.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
|
@ -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}`,
|
||||
}),
|
||||
});
|
@ -14,7 +14,8 @@ import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
|
||||
import ContactPoints, { ContactPoint } from './ContactPoints';
|
||||
import { ContactPoint } from './ContactPoint';
|
||||
import ContactPointsPageContents from './ContactPoints';
|
||||
import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimirFlavoredServer';
|
||||
import setupVanillaAlertmanagerFlavoredServer, {
|
||||
VANILLA_ALERTMANAGER_DATASOURCE_UID,
|
||||
@ -62,32 +63,32 @@ describe('contact points', () => {
|
||||
|
||||
describe('tabs behaviour', () => {
|
||||
test('loads contact points tab', async () => {
|
||||
renderWithProvider(<ContactPoints />, { initialEntries: ['/?tab=contact_points'] });
|
||||
renderWithProvider(<ContactPointsPageContents />, { initialEntries: ['/?tab=contact_points'] });
|
||||
|
||||
expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loads templates tab', async () => {
|
||||
renderWithProvider(<ContactPoints />, { initialEntries: ['/?tab=templates'] });
|
||||
renderWithProvider(<ContactPointsPageContents />, { initialEntries: ['/?tab=templates'] });
|
||||
|
||||
expect(await screen.findByText(/add notification template/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to contact points tab with invalid query param', async () => {
|
||||
renderWithProvider(<ContactPoints />, { initialEntries: ['/?tab=foo_bar'] });
|
||||
renderWithProvider(<ContactPointsPageContents />, { initialEntries: ['/?tab=foo_bar'] });
|
||||
|
||||
expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to contact points tab with no query param', async () => {
|
||||
renderWithProvider(<ContactPoints />);
|
||||
renderWithProvider(<ContactPointsPageContents />);
|
||||
|
||||
expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show / hide loading states, have all actions enabled', async () => {
|
||||
renderWithProvider(<ContactPoints />);
|
||||
renderWithProvider(<ContactPointsPageContents />);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
@ -126,7 +127,7 @@ describe('contact points', () => {
|
||||
it('should disable certain actions if the user has no write permissions', async () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead]);
|
||||
|
||||
renderWithProvider(<ContactPoints />);
|
||||
renderWithProvider(<ContactPointsPageContents />);
|
||||
|
||||
// wait for loading to be done
|
||||
await waitFor(async () => {
|
||||
@ -245,9 +246,9 @@ describe('contact points', () => {
|
||||
});
|
||||
|
||||
it('should be able to search', async () => {
|
||||
renderWithProvider(<ContactPoints />);
|
||||
renderWithProvider(<ContactPointsPageContents />);
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: 'search contact points' });
|
||||
const searchInput = await screen.findByRole('textbox', { name: 'search contact points' });
|
||||
await userEvent.type(searchInput, 'slack');
|
||||
expect(searchInput).toHaveValue('slack');
|
||||
|
||||
@ -283,7 +284,7 @@ describe('contact points', () => {
|
||||
});
|
||||
|
||||
it('should show / hide loading states, have the right actions enabled', async () => {
|
||||
renderWithProvider(<ContactPoints />, undefined, { alertmanagerSourceName: MIMIR_DATASOURCE_UID });
|
||||
renderWithProvider(<ContactPointsPageContents />, undefined, { alertmanagerSourceName: MIMIR_DATASOURCE_UID });
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
@ -339,7 +340,9 @@ describe('contact points', () => {
|
||||
});
|
||||
|
||||
it("should not allow any editing because it's not supported", async () => {
|
||||
renderWithProvider(<ContactPoints />, undefined, { alertmanagerSourceName: VANILLA_ALERTMANAGER_DATASOURCE_UID });
|
||||
renderWithProvider(<ContactPointsPageContents />, undefined, {
|
||||
alertmanagerSourceName: VANILLA_ALERTMANAGER_DATASOURCE_UID,
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
@ -1,72 +1,35 @@
|
||||
import { css } from '@emotion/css';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { SerializedError } from '@reduxjs/toolkit';
|
||||
import { groupBy, size, uniq, upperFirst } from 'lodash';
|
||||
import pluralize from 'pluralize';
|
||||
import { Fragment, ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { dateTime, GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dropdown,
|
||||
Icon,
|
||||
LinkButton,
|
||||
LoadingPlaceholder,
|
||||
Menu,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tab,
|
||||
TabContent,
|
||||
TabsBar,
|
||||
Text,
|
||||
TextLink,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap';
|
||||
import { useURLSearchParams } from 'app/features/alerting/unified/hooks/useURLSearchParams';
|
||||
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
|
||||
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
|
||||
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||
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 { GrafanaReceiverExporter } from '../export/GrafanaReceiverExporter';
|
||||
import { GrafanaReceiversExporter } from '../export/GrafanaReceiversExporter';
|
||||
import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/ReceiverMetadataBadge';
|
||||
import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
|
||||
|
||||
import { ContactPoint } from './ContactPoint';
|
||||
import { NotificationTemplates } from './NotificationTemplates';
|
||||
import { ContactPointsFilter } from './components/ContactPointsFilter';
|
||||
import { GlobalConfigAlert } from './components/GlobalConfigAlert';
|
||||
import { useDeleteContactPointModal } from './components/Modals';
|
||||
import { UnusedContactPointBadge } from './components/UnusedBadge';
|
||||
import {
|
||||
RECEIVER_META_KEY,
|
||||
RECEIVER_PLUGIN_META_KEY,
|
||||
RECEIVER_STATUS_KEY,
|
||||
useContactPointsWithStatus,
|
||||
useDeleteContactPoint,
|
||||
} from './useContactPoints';
|
||||
import {
|
||||
ContactPointWithMetadata,
|
||||
getReceiverDescription,
|
||||
isProvisioned,
|
||||
ReceiverConfigWithMetadata,
|
||||
RouteReference,
|
||||
} from './utils';
|
||||
import { useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints';
|
||||
import { useContactPointsSearch } from './useContactPointsSearch';
|
||||
import { ALL_CONTACT_POINTS, useExportContactPoint } from './useExportContactPoint';
|
||||
import { ContactPointWithMetadata, isProvisioned } from './utils';
|
||||
|
||||
export enum ActiveTab {
|
||||
ContactPoints = 'contact_points',
|
||||
@ -75,6 +38,107 @@ export enum ActiveTab {
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
const ContactPointsTab = () => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const [queryParams] = useURLSearchParams();
|
||||
|
||||
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 search = queryParams.get('search');
|
||||
|
||||
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">{stringifyErrorLike(error)}</Alert>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder text="Loading..." />;
|
||||
}
|
||||
|
||||
const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||||
return (
|
||||
<>
|
||||
{/* TODO we can add some additional info here with a ToggleTip */}
|
||||
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
||||
<ContactPointsFilter />
|
||||
|
||||
<Stack direction="row" gap={1}>
|
||||
{addContactPointSupported && (
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
aria-label="add contact point"
|
||||
variant="primary"
|
||||
href="/alerting/notifications/receivers/new"
|
||||
disabled={!addContactPointAllowed}
|
||||
>
|
||||
Add contact point
|
||||
</LinkButton>
|
||||
)}
|
||||
{exportContactPointsSupported && (
|
||||
<Button
|
||||
icon="download-alt"
|
||||
variant="secondary"
|
||||
aria-label="export all"
|
||||
disabled={!exportContactPointsAllowed}
|
||||
onClick={() => showExportDrawer(ALL_CONTACT_POINTS)}
|
||||
>
|
||||
Export all
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ContactPointsList
|
||||
contactPoints={contactPoints}
|
||||
search={search}
|
||||
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!} />}
|
||||
{DeleteModal}
|
||||
{ExportDrawer}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationTemplatesTab = () => {
|
||||
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.CreateNotificationTemplate
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Text variant="body" color="secondary">
|
||||
Create notification templates to customize your notifications.
|
||||
</Text>
|
||||
{createTemplateSupported && (
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
href="/alerting/notifications/templates/new"
|
||||
disabled={!createTemplateAllowed}
|
||||
>
|
||||
Add notification template
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
<NotificationTemplates />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useTabQueryParam = () => {
|
||||
const [queryParams, setQueryParams] = useURLSearchParams();
|
||||
const param = useMemo(() => {
|
||||
@ -92,42 +156,18 @@ const useTabQueryParam = () => {
|
||||
return [param, setParam] as const;
|
||||
};
|
||||
|
||||
const ContactPoints = () => {
|
||||
const ContactPointsPageContents = () => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const [queryParams] = useURLSearchParams();
|
||||
const [activeTab, setActiveTab] = useTabQueryParam();
|
||||
|
||||
let { isLoading, error, contactPoints } = useContactPointsWithStatus();
|
||||
const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!);
|
||||
const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.CreateContactPoint
|
||||
);
|
||||
const [exportContactPointsSupported, exportContactPointsAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.ExportContactPoint
|
||||
);
|
||||
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.CreateNotificationTemplate
|
||||
);
|
||||
|
||||
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
|
||||
const [ExportDrawer, showExportDrawer] = useExportContactPoint();
|
||||
|
||||
const search = queryParams.get('search');
|
||||
let { contactPoints } = useContactPointsWithStatus();
|
||||
|
||||
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
||||
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
||||
|
||||
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
|
||||
@ -144,85 +184,11 @@ const ContactPoints = () => {
|
||||
</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="end">
|
||||
<ContactPointsFilter />
|
||||
<Spacer />
|
||||
<Stack direction="row" gap={1}>
|
||||
{addContactPointSupported && (
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
aria-label="add contact point"
|
||||
variant="primary"
|
||||
href="/alerting/notifications/receivers/new"
|
||||
disabled={!addContactPointAllowed}
|
||||
>
|
||||
Add contact point
|
||||
</LinkButton>
|
||||
)}
|
||||
{exportContactPointsSupported && (
|
||||
<Button
|
||||
icon="download-alt"
|
||||
variant="secondary"
|
||||
aria-label="export all"
|
||||
disabled={!exportContactPointsAllowed}
|
||||
onClick={() => showExportDrawer(ALL_CONTACT_POINTS)}
|
||||
>
|
||||
Export all
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ContactPointsList
|
||||
contactPoints={contactPoints}
|
||||
search={search}
|
||||
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!} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Notification Templates tab */}
|
||||
{showNotificationTemplates && (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Text variant="body" color="secondary">
|
||||
Create notification templates to customize your notifications.
|
||||
</Text>
|
||||
<Spacer />
|
||||
{createTemplateSupported && (
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
href="/alerting/notifications/templates/new"
|
||||
disabled={!createTemplateAllowed}
|
||||
>
|
||||
Add notification template
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
<NotificationTemplates />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{showingContactPoints && <ContactPointsTab />}
|
||||
{showNotificationTemplates && <NotificationTemplatesTab />}
|
||||
</Stack>
|
||||
</TabContent>
|
||||
</Stack>
|
||||
{DeleteModal}
|
||||
{ExportDrawer}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -269,443 +235,4 @@ const ContactPointsList = ({
|
||||
);
|
||||
};
|
||||
|
||||
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)
|
||||
function 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]) ?? [];
|
||||
}
|
||||
|
||||
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 ContactPointHeaderProps {
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
provisioned?: boolean;
|
||||
policies?: RouteReference[];
|
||||
onDelete: (name: string) => void;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
is used by{' '}
|
||||
<TextLink
|
||||
href={createUrl('/alerting/routes', { contactPoint: name })}
|
||||
variant="bodySmall"
|
||||
color="primary"
|
||||
inline={false}
|
||||
>
|
||||
{`${numberOfPolicies} ${pluralize('notification policy', numberOfPolicies)}`}
|
||||
</TextLink>
|
||||
</Text>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<React.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 && '⋅'}
|
||||
</React.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>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>
|
||||
<Text color="primary">{lastDeliveryAttempt.locale('en').fromNow()}</Text>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</MetaText>
|
||||
<MetaText icon="stopwatch">
|
||||
took <Text color="primary">{lastDeliveryAttemptDuration}</Text>
|
||||
</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 <Text color="primary">only firing</Text> 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;
|
||||
export default ContactPointsPageContents;
|
||||
|
@ -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];
|
||||
};
|
@ -23,6 +23,7 @@ import {
|
||||
} from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap';
|
||||
import { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants';
|
||||
import {
|
||||
AlertmanagerGroup,
|
||||
MatcherOperator,
|
||||
@ -1021,8 +1022,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
}),
|
||||
});
|
||||
|
||||
// This is a convencience component to deal with I18n shenanigans
|
||||
// see https://github.com/grafana/grafana/blob/main/contribute/internationalization.md#jsx
|
||||
const PrimaryText = ({ content }: { content: string }) => <Text color="primary">{content}</Text>;
|
||||
|
||||
export { Policy };
|
||||
|
@ -7,7 +7,7 @@ import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from
|
||||
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
|
||||
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints';
|
||||
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoint';
|
||||
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
|
||||
import { ContactPointWithMetadata } from '../../../contact-points/utils';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
import { ContactPointReceiverTitleRow } from '../../../../contact-points/ContactPoints';
|
||||
import { ContactPointReceiverTitleRow } from '../../../../contact-points/ContactPoint';
|
||||
import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from '../../../../contact-points/useContactPoints';
|
||||
import { ReceiverConfigWithMetadata, getReceiverDescription } from '../../../../contact-points/utils';
|
||||
|
||||
|
@ -100,10 +100,17 @@
|
||||
}
|
||||
},
|
||||
"contact-points": {
|
||||
"delivery-duration": "Last delivery took <1></1>",
|
||||
"last-delivery-attempt": "Last delivery attempt",
|
||||
"last-delivery-failed": "Last delivery attempt failed",
|
||||
"no-delivery-attempts": "No delivery attempts",
|
||||
"only-firing": "Delivering <1>only firing</1> notifications",
|
||||
"telegram": {
|
||||
"parse-mode-warning-body": "If you use a <1>parse_mode</1> option other than <3>None</3>, truncation may result in an invalid message, causing the notification to fail. For longer messages, we recommend using an alternative contact method.",
|
||||
"parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters."
|
||||
}
|
||||
},
|
||||
"used-by_one": "Used by {{ count }} notification policy",
|
||||
"used-by_other": "Used by {{ count }} notification policies"
|
||||
},
|
||||
"policies": {
|
||||
"metadata": {
|
||||
|
@ -100,10 +100,17 @@
|
||||
}
|
||||
},
|
||||
"contact-points": {
|
||||
"delivery-duration": "Ŀäşŧ đęľįvęřy ŧőőĸ <1></1>",
|
||||
"last-delivery-attempt": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ",
|
||||
"last-delivery-failed": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ ƒäįľęđ",
|
||||
"no-delivery-attempts": "Ńő đęľįvęřy äŧŧęmpŧş",
|
||||
"only-firing": "Đęľįvęřįʼnģ <1>őʼnľy ƒįřįʼnģ</1> ʼnőŧįƒįčäŧįőʼnş",
|
||||
"telegram": {
|
||||
"parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę</1> őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę</3>, ŧřūʼnčäŧįőʼn mäy řęşūľŧ įʼn äʼn įʼnväľįđ męşşäģę, čäūşįʼnģ ŧĥę ʼnőŧįƒįčäŧįőʼn ŧő ƒäįľ. Főř ľőʼnģęř męşşäģęş, ŵę řęčőmmęʼnđ ūşįʼnģ äʼn äľŧęřʼnäŧįvę čőʼnŧäčŧ męŧĥőđ.",
|
||||
"parse-mode-warning-title": "Ŧęľęģřäm męşşäģęş äřę ľįmįŧęđ ŧő 4096 ŮŦF-8 čĥäřäčŧęřş."
|
||||
}
|
||||
},
|
||||
"used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy",
|
||||
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş"
|
||||
},
|
||||
"policies": {
|
||||
"metadata": {
|
||||
|
Loading…
Reference in New Issue
Block a user