From 7a414a04a0d5099965dd4ce3708fcbb61fd338c6 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 14 Nov 2024 09:55:15 +0000 Subject: [PATCH] Alerting: Fix permissions for timeintervals UI and improve display of contact points in notification policies (#96129) Co-authored-by: Gilles De Mey --- pkg/services/navtree/navtreeimpl/navtree.go | 2 + public/app/features/alerting/routes.tsx | 9 ++ .../alerting/unified/NotificationPolicies.tsx | 83 +++++++++---------- .../components/alert-groups/AlertGroup.tsx | 18 +--- .../contact-points/ContactPoint.tsx | 15 ++-- .../contact-points/ContactPoints.tsx | 5 ++ .../components/contact-points/permissions.ts | 2 +- .../components/contact-points/utils.ts | 6 +- .../components/mute-timings/permissions.ts | 11 +++ .../notification-policies/Policy.test.tsx | 28 ++----- .../notification-policies/Policy.tsx | 74 +++++------------ .../NotificationRouteDetailsModal.tsx | 7 +- .../alerting/unified/hooks/useAbilities.ts | 54 ++++++++---- .../components/AlertRuleListItem.tsx | 4 +- .../features/alerting/unified/utils/misc.ts | 12 +++ public/app/types/accessControl.ts | 4 + public/locales/en-US/grafana.json | 2 +- public/locales/pseudo-LOCALE/grafana.json | 2 +- 18 files changed, 172 insertions(+), 166 deletions(-) create mode 100644 public/app/features/alerting/unified/components/mute-timings/permissions.ts diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 2d64d9c6ade..448893bffd0 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -452,6 +452,8 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na if hasAccess(ac.EvalAny( ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite), )) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "am-routes", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"}) } diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index ef88f26d6c6..bb7bbe9c995 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -4,6 +4,10 @@ import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/type import { AccessControlAction } from 'app/types'; import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions'; +import { + PERMISSIONS_TIME_INTERVALS_MODIFY, + PERMISSIONS_TIME_INTERVALS_READ, +} from './unified/components/mute-timings/permissions'; import { PERMISSIONS_TEMPLATES } from './unified/components/templates/permissions'; import { evaluateAccess } from './unified/utils/access-control'; @@ -33,6 +37,8 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { roles: evaluateAccess([ AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead, + ...PERMISSIONS_TIME_INTERVALS_READ, + ...PERMISSIONS_TIME_INTERVALS_MODIFY, ]), component: importAlertingComponent( () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/NotificationPolicies') @@ -43,6 +49,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { roles: evaluateAccess([ AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsExternalWrite, + ...PERMISSIONS_TIME_INTERVALS_MODIFY, ]), component: importAlertingComponent( () => @@ -56,6 +63,8 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { roles: evaluateAccess([ AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsExternalWrite, + ...PERMISSIONS_TIME_INTERVALS_READ, + ...PERMISSIONS_TIME_INTERVALS_MODIFY, ]), component: importAlertingComponent( () => diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index beb1b908abe..2d9dbcd2ac6 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -242,54 +242,51 @@ const AmRoutes = () => { {isFetching && } - {haveError && ( - - {resultError.message || 'Unknown error.'} - - )} - {haveData && ( + {policyTreeTabActive && ( <> - {policyTreeTabActive && ( - <> - - {rootRoute && ( - - )} - {rootRoute && ( - - )} - - {addModal} - {editModal} - {deleteModal} - {alertInstancesModal} - + {haveError && ( + + {resultError.message || 'Unknown error.'} + )} - {muteTimingsTabActive && ( - + {haveData && ( + + {rootRoute && ( + + )} + {rootRoute && ( + + )} + )} + {addModal} + {editModal} + {deleteModal} + {alertInstancesModal} )} + {muteTimingsTabActive && ( + + )} ); diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx index 7c5169f930b..17fc1ca5a01 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx @@ -3,9 +3,9 @@ import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack, TextLink } from '@grafana/ui'; -import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; -import { createContactPointLink } from '../../utils/misc'; +import { createContactPointSearchLink } from '../../utils/misc'; import { AlertLabels } from '../AlertLabels'; import { CollapseToggle } from '../CollapseToggle'; import { MetaText } from '../MetaText'; @@ -44,7 +44,7 @@ export const AlertGroup = ({ alertManagerSourceName, group }: Props) => { Delivered to{' '} ({ flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', - padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`, + padding: theme.spacing(1), backgroundColor: theme.colors.background.secondary, width: '100%', }), @@ -86,14 +86,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'row', alignItems: 'center', }), - summary: css({}), - [AlertState.Active]: css({ - color: theme.colors.error.main, - }), - [AlertState.Suppressed]: css({ - color: theme.colors.primary.main, - }), - [AlertState.Unprocessed]: css({ - color: theme.colors.secondary.main, - }), }); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx index 2c249b30171..c3a9c10aae2 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx @@ -47,13 +47,7 @@ export const ContactPoint = ({ contactPoint }: ContactPointProps) => { }) } /> - {receivers.length === 0 && ( -
- - No integrations configured - -
- )} + {showFullMetadata ? (
{receivers.map((receiver, index) => { @@ -176,6 +170,11 @@ export const ContactPointReceiverSummary = ({ receivers, limit }: ContactPointRe return ( + {integrationsShown.length === 0 && ( + + No integrations configured + + )} {integrationsShown.map(([type, receivers], index) => { const iconName = INTEGRATION_ICONS[type]; const receiverName = receiverTypeNames[type] ?? upperFirst(type); @@ -198,7 +197,7 @@ export const ContactPointReceiverSummary = ({ receivers, limit }: ContactPointRe {iconName && } {receiverName} - {receivers.length > 1 && receivers.length} + {receivers.length > 1 && ` (${receivers.length})`} {!isLastItem && '⋅'} 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 6366c421d9a..c33609b367f 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -243,6 +243,11 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE const searchResults = useContactPointsSearch(contactPoints, search); const { page, pageItems, numberOfPages, onPageChange } = usePagination(searchResults, 1, pageSize); + if (pageItems.length === 0) { + const emptyMessage = t('alerting.contact-points.no-contact-points-found', 'No contact points found'); + return ; + } + return ( <> {pageItems.map((contactPoint, index) => { diff --git a/public/app/features/alerting/unified/components/contact-points/permissions.ts b/public/app/features/alerting/unified/components/contact-points/permissions.ts index 30af528e6dc..48ba46a10c5 100644 --- a/public/app/features/alerting/unified/components/contact-points/permissions.ts +++ b/public/app/features/alerting/unified/components/contact-points/permissions.ts @@ -5,7 +5,7 @@ import { AccessControlAction } from 'app/types'; * * Any permission in this list will be checked for client side access to view Contact Points functionality. */ -const PERMISSIONS_CONTACT_POINTS_READ = [AccessControlAction.AlertingReceiversRead]; +export const PERMISSIONS_CONTACT_POINTS_READ = [AccessControlAction.AlertingReceiversRead]; /** * List of granular permissions that allow modifying contact points diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 5f6deb9c0b0..c02bfabfb1a 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -129,7 +129,7 @@ export function enhanceContactPointsWithMetadata({ const receivers = extractReceivers(contactPoint); const statusForReceiver = status.find((status) => status.name === contactPoint.name); - const id = 'id' in contactPoint && contactPoint.id ? contactPoint.id : contactPoint.name; + const id = getContactPointIdentifier(contactPoint); return { ...contactPoint, @@ -157,6 +157,10 @@ export function enhanceContactPointsWithMetadata({ return enhanced.sort((a, b) => a.name.localeCompare(b.name)); } +function getContactPointIdentifier(contactPoint: Receiver): string { + return 'id' in contactPoint && contactPoint.id ? contactPoint.id : contactPoint.name; +} + export function isAutoGeneratedPolicy(route: Route) { const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; if (!simplifiedRoutingToggleEnabled) { diff --git a/public/app/features/alerting/unified/components/mute-timings/permissions.ts b/public/app/features/alerting/unified/components/mute-timings/permissions.ts new file mode 100644 index 00000000000..dc895c100c8 --- /dev/null +++ b/public/app/features/alerting/unified/components/mute-timings/permissions.ts @@ -0,0 +1,11 @@ +import { AccessControlAction } from 'app/types'; + +/** + * List of granular permissions that allow viewing contact points + */ +export const PERMISSIONS_TIME_INTERVALS_READ = [AccessControlAction.AlertingTimeIntervalsRead]; + +/** + * List of granular permissions that allow modifying time intervals + */ +export const PERMISSIONS_TIME_INTERVALS_MODIFY = [AccessControlAction.AlertingTimeIntervalsWrite]; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index 2985c7e20a6..19afef0f8e0 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -15,7 +15,7 @@ import { import { ReceiversState } from 'app/types/alerting'; import { useAlertmanagerAbilities } from '../../hooks/useAbilities'; -import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../../mocks'; +import { mockReceiversState } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -57,7 +57,8 @@ describe('Policy', () => { renderPolicy( { renderPolicy( { renderPolicy( { renderPolicy( { renderPolicy( { routes: [{ id: '1', object_matchers: [['foo', eq, 'bar']] }], }; - const matchingGroups: AlertmanagerGroup[] = [ - mockAlertGroup({ - labels: {}, - alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } }), mockAlertmanagerAlert({ labels: { foo: 'bar' } })], - }), - mockAlertGroup({ - labels: {}, - alerts: [mockAlertmanagerAlert({ labels: { bar: 'baz' } })], - }), - ]; - renderPolicy( { renderPolicy( void; @@ -79,6 +77,7 @@ interface PolicyComponentProps { formatter?: MatcherFormatter ) => void; isAutoGenerated?: boolean; + isDefaultPolicy?: boolean; } const Policy = (props: PolicyComponentProps) => { @@ -87,10 +86,8 @@ const Policy = (props: PolicyComponentProps) => { contactPointsState, readOnly = false, provisioned = false, - alertGroups = [], alertManagerSourceName, currentRoute, - routeTree, inheritedProperties, routesMatchingFilters = { filtersApplied: false, @@ -102,12 +99,11 @@ const Policy = (props: PolicyComponentProps) => { onDeletePolicy, onShowAlertInstances, isAutoGenerated = false, + isDefaultPolicy = false, } = props; const styles = useStyles2(getStyles); - const isDefaultPolicy = currentRoute === routeTree; - const contactPoint = currentRoute.receiver; const continueMatching = currentRoute.continue ?? false; @@ -353,7 +349,6 @@ const Policy = (props: PolicyComponentProps) => { return ( { onDeletePolicy={onDeletePolicy} onShowAlertInstances={onShowAlertInstances} alertManagerSourceName={alertManagerSourceName} - alertGroups={alertGroups} routesMatchingFilters={routesMatchingFilters} matchingInstancesPreview={matchingInstancesPreview} isAutoGenerated={isThisChildAutoGenerated} @@ -844,7 +838,6 @@ interface ContactPointDetailsProps { receivers: Receiver[]; } -// @TODO make this work for cloud AMs too const ContactPointsHoverDetails: FC = ({ alertManagerSourceName, contactPoint, @@ -852,67 +845,40 @@ const ContactPointsHoverDetails: FC = ({ }) => { const details = receivers.find((receiver) => receiver.name === contactPoint); if (!details) { + // If we can't find details, then it's possible (likely) that the user doesn't have access to this + // contact point, so we don't try and link to it return ( - + {contactPoint} - + ); } const integrations = details.grafana_managed_receiver_configs; - if (!integrations) { - return ( - - {contactPoint} - - ); - } - const groupedIntegrations = groupBy(details.grafana_managed_receiver_configs, (config) => config.type); + const contactPointLink = + 'id' in details && details.id + ? createContactPointLink(details.id, alertManagerSourceName) + : createContactPointSearchLink(details.name, alertManagerSourceName); return ( -
- Contact Point -
{contactPoint} } key={uniqueId()} content={ - - {/* use "label" to indicate how many of that type we have in the contact point */} - {Object.entries(groupedIntegrations).map(([type, integrations]) => ( - + + + } > - + {contactPoint}
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx index 42e0bc67804..8d3dfdae77a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx @@ -9,7 +9,7 @@ import { AlertmanagerAction } from '../../../hooks/useAbilities'; import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; import { getAmMatcherFormatter } from '../../../utils/alertmanager'; import { MatcherFormatter } from '../../../utils/matchers'; -import { makeAMLink } from '../../../utils/misc'; +import { createContactPointSearchLink } from '../../../utils/misc'; import { Authorize } from '../../Authorize'; import { Matchers } from '../../notification-policies/Matchers'; @@ -98,10 +98,7 @@ export function NotificationRouteDetailsModal({ { hasConfigurationAPI, notificationsPermissions.create, // TODO: Move this into the permissions config and generalise that code to allow for an array of permissions - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingReceiversCreate : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingReceiversCreate] : []) ), [AlertmanagerAction.ViewContactPoint]: toAbility( AlwaysSupported, notificationsPermissions.read, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingReceiversRead : null + ...(isGrafanaFlavoredAlertmanager ? PERMISSIONS_CONTACT_POINTS_READ : []) ), [AlertmanagerAction.UpdateContactPoint]: toAbility( hasConfigurationAPI, notificationsPermissions.update, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingReceiversWrite : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingReceiversWrite] : []) ), [AlertmanagerAction.DeleteContactPoint]: toAbility( hasConfigurationAPI, notificationsPermissions.delete, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingReceiversWrite : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingReceiversWrite] : []) ), // At the time of writing, only Grafana flavored alertmanager supports exporting, // and if a user can view the contact point, then they can also export it @@ -254,17 +259,17 @@ export function useAllAlertmanagerAbilities(): Abilities { [AlertmanagerAction.CreateNotificationTemplate]: toAbility( hasConfigurationAPI, notificationsPermissions.create, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingTemplatesWrite : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingTemplatesWrite] : []) ), [AlertmanagerAction.ViewNotificationTemplate]: toAbility( AlwaysSupported, notificationsPermissions.read, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingTemplatesRead : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingTemplatesRead] : []) ), [AlertmanagerAction.UpdateNotificationTemplate]: toAbility( hasConfigurationAPI, notificationsPermissions.update, - isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingTemplatesWrite : null + ...(isGrafanaFlavoredAlertmanager ? [AccessControlAction.AlertingTemplatesWrite] : []) ), [AlertmanagerAction.DeleteNotificationTemplate]: toAbility(hasConfigurationAPI, notificationsPermissions.delete), // -- notification policies -- @@ -287,11 +292,27 @@ export function useAllAlertmanagerAbilities(): Abilities { [AlertmanagerAction.ViewSilence]: toAbility(AlwaysSupported, instancePermissions.read), [AlertmanagerAction.UpdateSilence]: toAbility(AlwaysSupported, instancePermissions.update), [AlertmanagerAction.PreviewSilencedInstances]: toAbility(AlwaysSupported, instancePermissions.read), - // -- mute timtings -- - [AlertmanagerAction.CreateMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.create), - [AlertmanagerAction.ViewMuteTiming]: toAbility(AlwaysSupported, notificationsPermissions.read), - [AlertmanagerAction.UpdateMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.update), - [AlertmanagerAction.DeleteMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.delete), + // -- mute timings -- + [AlertmanagerAction.CreateMuteTiming]: toAbility( + hasConfigurationAPI, + notificationsPermissions.create, + ...(isGrafanaFlavoredAlertmanager ? PERMISSIONS_TIME_INTERVALS_MODIFY : []) + ), + [AlertmanagerAction.ViewMuteTiming]: toAbility( + AlwaysSupported, + notificationsPermissions.read, + ...(isGrafanaFlavoredAlertmanager ? PERMISSIONS_TIME_INTERVALS_READ : []) + ), + [AlertmanagerAction.UpdateMuteTiming]: toAbility( + hasConfigurationAPI, + notificationsPermissions.update, + ...(isGrafanaFlavoredAlertmanager ? PERMISSIONS_TIME_INTERVALS_MODIFY : []) + ), + [AlertmanagerAction.DeleteMuteTiming]: toAbility( + hasConfigurationAPI, + notificationsPermissions.delete, + ...(isGrafanaFlavoredAlertmanager ? PERMISSIONS_TIME_INTERVALS_MODIFY : []) + ), [AlertmanagerAction.ExportMuteTimings]: toAbility(isGrafanaFlavoredAlertmanager, notificationsPermissions.read), }; @@ -355,7 +376,8 @@ function useCanSilence(rule: CombinedRule): [boolean, boolean] { } // just a convenient function -const toAbility = (supported: boolean, ...actions: Array): Ability => [ - supported, - actions.some((action) => action && ctx.hasPermission(action)), -]; +const toAbility = ( + supported: boolean, + /** If user has any of these permissions, then they are allowed to perform the action */ + ...actions: AccessControlAction[] +): Ability => [supported, actions.some((action) => action && ctx.hasPermission(action))]; diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index 3e89460e872..741a62fbe35 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -15,7 +15,7 @@ import { ProvisioningBadge } from '../../components/Provisioning'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { labelsSize } from '../../utils/labels'; -import { createContactPointLink } from '../../utils/misc'; +import { createContactPointSearchLink } from '../../utils/misc'; import { RulePluginOrigin } from '../../utils/rules'; import { ListItem } from './ListItem'; @@ -106,7 +106,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { Delivered to{' '} only firing notifications", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bc936363a98..df94a7497f3 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -137,7 +137,6 @@ "export-all": "Ēχpőřŧ äľľ", "view": "Vįęŵ" }, - "contact-point": "Cőʼnŧäčŧ Pőįʼnŧ", "contact-points": { "create": "Cřęäŧę čőʼnŧäčŧ pőįʼnŧ", "custom-template-value": "Cūşŧőm ŧęmpľäŧę väľūę", @@ -155,6 +154,7 @@ }, "last-delivery-attempt": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ", "last-delivery-failed": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ ƒäįľęđ", + "no-contact-points-found": "Ńő čőʼnŧäčŧ pőįʼnŧş ƒőūʼnđ", "no-delivery-attempts": "Ńő đęľįvęřy äŧŧęmpŧş", "no-integrations": "Ńő įʼnŧęģřäŧįőʼnş čőʼnƒįģūřęđ", "only-firing": "Đęľįvęřįʼnģ <1>őʼnľy ƒįřįʼnģ ʼnőŧįƒįčäŧįőʼnş",