From 4e364ea043c2b397d008ab306e41013cf6f47bb9 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 22 Jul 2024 08:54:13 +0100 Subject: [PATCH] Alerting: Split out contact points components to separate files (#90605) --- .betterer.results | 15 +- .../components/common/TextVariants.tsx | 8 + .../contact-points/ContactPoint.tsx | 272 +++++++ .../contact-points/ContactPointHeader.tsx | 144 ++++ .../contact-points/ContactPoints.test.tsx | 25 +- .../contact-points/ContactPoints.tsx | 701 +++--------------- .../contact-points/useContactPointsSearch.tsx | 42 ++ .../contact-points/useExportContactPoint.tsx | 44 ++ .../notification-policies/Policy.tsx | 5 +- .../simplifiedRouting/AlertManagerRouting.tsx | 2 +- .../contactPoint/ContactPointDetails.tsx | 2 +- public/locales/en-US/grafana.json | 9 +- public/locales/pseudo-LOCALE/grafana.json | 9 +- 13 files changed, 659 insertions(+), 619 deletions(-) create mode 100644 public/app/features/alerting/unified/components/common/TextVariants.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/useContactPointsSearch.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx diff --git a/.betterer.results b/.betterer.results index ac00f9cbff6..ba808d11d27 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1706,21 +1706,10 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "13"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "14"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], "public/app/features/alerting/unified/components/contact-points/components/ContactPointsFilter.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] diff --git a/public/app/features/alerting/unified/components/common/TextVariants.tsx b/public/app/features/alerting/unified/components/common/TextVariants.tsx new file mode 100644 index 00000000000..3842c2b8287 --- /dev/null +++ b/public/app/features/alerting/unified/components/common/TextVariants.tsx @@ -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 }) => {content}; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx new file mode 100644 index 00000000000..5f94195df4b --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx @@ -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 ( +
+ + + {showFullMetadata ? ( +
+ {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 ( + + ); + })} +
+ ) : ( +
+ +
+ )} +
+
+ ); +}; + +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 ( +
+ + + {hasMetadata && } + +
+ ); +}; + +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 ( + + + {iconName && } + {pluginMetadata ? ( + + ) : ( + + {name} + + )} + + {description && ( + + {description} + + )} + + ); +} + +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 ( + + + {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 ( + + + {iconName && } + + {receiverName} + {receivers.length > 1 && receivers.length} + + + {!isLastItem && '⋅'} + + ); + })} + + + ); +}; + +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 ( +
+ + {/* this is shown when the last delivery failed – we don't show any additional metadata */} + {failedToSend ? ( + <> + + + + Last delivery attempt failed + + + + + ) : ( + <> + {/* this is shown when we have a last delivery attempt */} + {hasDeliveryAttempt && ( + <> + + Last delivery attempt + + + {lastDeliveryAttempt.locale('en').fromNow()} + + + + + + Last delivery took + + + + )} + {/* when we have no last delivery attempt */} + {!hasDeliveryAttempt && ( + + No delivery attempts + + )} + {/* this is only shown for contact points that only want "firing" updates */} + {!sendingResolved && ( + + + Delivering only firing notifications + + + )} + + )} + +
+ ); +}; + +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}`, + }), +}); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx new file mode 100644 index 00000000000..b93ab82f8d7 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -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( + + openExportDrawer(name)} + /> + + + ); + } + + if (deleteSupported) { + menuActions.push( + ( + + {children} + + )} + > + onDelete(name)} + /> + + ); + } + + const referencedByPoliciesText = t('alerting.contact-points.used-by', 'Used by {{ count }} notification policy', { + count: numberOfPolicies, + }); + + return ( +
+ + + + {name} + + + {isReferencedByAnyPolicy && ( + + {referencedByPoliciesText} + + )} + {provisioned && } + {!isReferencedByAnyPolicy && } + + + {canEdit ? 'Edit' : 'View'} + + {menuActions.length > 0 && ( + {menuActions}}> + + + )} + + {ExportDrawer} +
+ ); +}; + +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}`, + }), +}); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 29af24d60eb..1f3dc40a3ad 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -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(, { initialEntries: ['/?tab=contact_points'] }); + renderWithProvider(, { initialEntries: ['/?tab=contact_points'] }); expect(await screen.findByText(/add contact point/i)).toBeInTheDocument(); }); test('loads templates tab', async () => { - renderWithProvider(, { initialEntries: ['/?tab=templates'] }); + renderWithProvider(, { initialEntries: ['/?tab=templates'] }); expect(await screen.findByText(/add notification template/i)).toBeInTheDocument(); }); test('defaults to contact points tab with invalid query param', async () => { - renderWithProvider(, { initialEntries: ['/?tab=foo_bar'] }); + renderWithProvider(, { 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(); + renderWithProvider(); expect(await screen.findByText(/add contact point/i)).toBeInTheDocument(); }); }); it('should show / hide loading states, have all actions enabled', async () => { - renderWithProvider(); + renderWithProvider(); 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(); + renderWithProvider(); // wait for loading to be done await waitFor(async () => { @@ -245,9 +246,9 @@ describe('contact points', () => { }); it('should be able to search', async () => { - renderWithProvider(); + renderWithProvider(); - 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(, undefined, { alertmanagerSourceName: MIMIR_DATASOURCE_UID }); + renderWithProvider(, 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(, undefined, { alertmanagerSourceName: VANILLA_ALERTMANAGER_DATASOURCE_UID }); + renderWithProvider(, undefined, { + alertmanagerSourceName: VANILLA_ALERTMANAGER_DATASOURCE_UID, + }); await waitFor(async () => { expect(screen.getByText('Loading...')).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index ac651588dbe..df53213e004 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -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 {stringifyErrorLike(error)}; + } + + if (isLoading) { + return ; + } + + const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + return ( + <> + {/* TODO we can add some additional info here with a ToggleTip */} + + + + + {addContactPointSupported && ( + + Add contact point + + )} + {exportContactPointsSupported && ( + + )} + + + showDeleteModal(name)} + disabled={updateAlertmanagerState.isLoading} + /> + {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} + {!isGrafanaManagedAlertmanager && } + {DeleteModal} + {ExportDrawer} + + ); +}; + +const NotificationTemplatesTab = () => { + const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility( + AlertmanagerAction.CreateNotificationTemplate + ); + + return ( + <> + + + Create notification templates to customize your notifications. + + {createTemplateSupported && ( + + Add notification template + + )} + + + + ); +}; + 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 {(error as SerializedError).message}; - } - - const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; - return ( <> - { - <> - {isLoading && } - {/* Contact Points tab */} - {showingContactPoints && ( - <> - {error ? ( - {String(error)} - ) : ( - <> - {/* TODO we can add some additional info here with a ToggleTip */} - - - - - {addContactPointSupported && ( - - Add contact point - - )} - {exportContactPointsSupported && ( - - )} - - - showDeleteModal(name)} - disabled={updateAlertmanagerState.isLoading} - /> - {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} - {!isGrafanaManagedAlertmanager && } - - )} - - )} - {/* Notification Templates tab */} - {showNotificationTemplates && ( - <> - - - Create notification templates to customize your notifications. - - - {createTemplateSupported && ( - - Add notification template - - )} - - - - )} - + {showingContactPoints && } + {showNotificationTemplates && } - {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 ( -
- - - {showFullMetadata ? ( -
- {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 ( - - ); - })} -
- ) : ( -
- -
- )} -
-
- ); -}; - -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( - - openExportDrawer(name)} - /> - - - ); - } - - if (deleteSupported) { - menuActions.push( - ( - - {children} - - )} - > - onDelete(name)} - /> - - ); - } - - return ( -
- - - - {name} - - - {isReferencedByAnyPolicy && ( - - is used by{' '} - - {`${numberOfPolicies} ${pluralize('notification policy', numberOfPolicies)}`} - - - )} - {provisioned && } - {!isReferencedByAnyPolicy && } - - - {canEdit ? 'Edit' : 'View'} - - {menuActions.length > 0 && ( - {menuActions}}> - - - )} - - {ExportDrawer} -
- ); -}; - -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 ( -
- - - {hasMetadata && } - -
- ); -}; - -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 ( - - - {iconName && } - {pluginMetadata ? ( - - ) : ( - - {name} - - )} - - {description && ( - - {description} - - )} - - ); -} - -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 ( - - - {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 ( - - - {iconName && } - - {receiverName} - {receivers.length > 1 && <> ({receivers.length})} - - - {!isLastItem && '⋅'} - - ); - })} - - - ); -}; - -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 ( -
- - {/* this is shown when the last delivery failed – we don't show any additional metadata */} - {failedToSend ? ( - <> - - - Last delivery attempt failed - - - - ) : ( - <> - {/* this is shown when we have a last delivery attempt */} - {hasDeliveryAttempt && ( - <> - - Last delivery attempt{' '} - - - {lastDeliveryAttempt.locale('en').fromNow()} - - - - - took {lastDeliveryAttemptDuration} - - - )} - {/* when we have no last delivery attempt */} - {!hasDeliveryAttempt && No delivery attempts} - {/* this is only shown for contact points that only want "firing" updates */} - {!sendingResolved && ( - - Delivering only firing notifications - - )} - - )} - -
- ); -}; - -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(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 ; - } else { - // use this one for exporting a single contact point - return ; - } - }, [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; diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPointsSearch.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPointsSearch.tsx new file mode 100644 index 00000000000..397bfb3087e --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/useContactPointsSearch.tsx @@ -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]) ?? []; +}; diff --git a/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx new file mode 100644 index 00000000000..3dcb8753ccc --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx @@ -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(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 ; + } else { + // use this one for exporting a single contact point + return ; + } + }, [canReadSecrets, isExportDrawerOpen, handleClose, receiverName]); + + return [drawer, handleOpen]; +}; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index 194bcfd5679..b4239207f8f 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -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 }) => {content}; - export { Policy }; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index 55751a9db97..5dd38c4c1bd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointDetails.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointDetails.tsx index 6b2a949117b..5d164e547a5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointDetails.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointDetails.tsx @@ -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'; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d9637c637f2..d554908eb79 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -100,10 +100,17 @@ } }, "contact-points": { + "delivery-duration": "Last delivery took <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 notifications", "telegram": { "parse-mode-warning-body": "If you use a <1>parse_mode option other than <3>None, 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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 10a4ee051e7..cf684d502de 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -100,10 +100,17 @@ } }, "contact-points": { + "delivery-duration": "Ŀäşŧ đęľįvęřy ŧőőĸ <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ģ ʼnőŧįƒįčäŧįőʼnş", "telegram": { "parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę, ŧřūʼ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": {