import { css } from '@emotion/css'; import { isArray, sumBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; import * as React from 'react'; import { FC, Fragment, ReactNode, useState } from 'react'; import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Badge, Button, Dropdown, Icon, IconButton, Menu, Stack, Text, TextLink, Tooltip, getTagColorsFromName, useStyles2, } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap'; import MoreButton from 'app/features/alerting/unified/components/MoreButton'; import { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants'; import { ContactPointReceiverSummary } from 'app/features/alerting/unified/components/contact-points/ContactPoint'; import { AlertmanagerGroup, MatcherOperator, ObjectMatcher, Receiver, RouteWithID, } from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types'; import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { getAmMatcherFormatter } from '../../utils/alertmanager'; import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createContactPointSearchLink, createMuteTimingLink } from '../../utils/misc'; import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies'; import { InsertPosition } from '../../utils/routeTree'; import { Authorize } from '../Authorize'; import { PopupCard } from '../HoverCard'; import { Label } from '../Label'; import { MetaText } from '../MetaText'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter'; import { Matchers } from './Matchers'; import { RoutesMatchingFilters } from './NotificationPoliciesList'; import { TimingOptions } from './timingOptions'; const POLICIES_PER_PAGE = 20; interface PolicyComponentProps { receivers?: Receiver[]; contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; inheritedProperties?: Partial; routesMatchingFilters?: RoutesMatchingFilters; matchingInstancesPreview?: { groupsMap?: Map; enabled: boolean; }; currentRoute: RouteWithID; alertManagerSourceName: string; onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void; onAddPolicy: (route: RouteWithID, position: InsertPosition) => void; onDeletePolicy: (route: RouteWithID) => void; onShowAlertInstances: ( alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter ) => void; isAutoGenerated?: boolean; isDefaultPolicy?: boolean; } const Policy = (props: PolicyComponentProps) => { const { receivers = [], contactPointsState, readOnly = false, provisioned = false, alertManagerSourceName, currentRoute, inheritedProperties, routesMatchingFilters = { filtersApplied: false, matchedRoutesWithPath: new Map(), }, matchingInstancesPreview = { enabled: false }, onEditPolicy, onAddPolicy, onDeletePolicy, onShowAlertInstances, isAutoGenerated = false, isDefaultPolicy = false, } = props; const styles = useStyles2(getStyles); const contactPoint = currentRoute.receiver; const continueMatching = currentRoute.continue ?? false; const matchers = normalizeMatchers(currentRoute); const hasMatchers = Boolean(matchers && matchers.length); const { filtersApplied, matchedRoutesWithPath } = routesMatchingFilters; const matchedRoutes = Array.from(matchedRoutesWithPath.keys()); // check if this route matches the filters const hasFocus = filtersApplied && matchedRoutes.some((route) => route.id === currentRoute.id); // check if this route belongs to a path that matches the filters const routesPath = Array.from(matchedRoutesWithPath.values()).flat(); const belongsToMatchPath = routesPath.some((route: RouteWithID) => route.id === currentRoute.id); // gather errors here const errors: ReactNode[] = []; // if the route has no matchers, is not the default policy (that one has none) and it does not continue // then we should warn the user that it's a suspicious setup const showMatchesAllLabelsWarning = !hasMatchers && !isDefaultPolicy && !continueMatching; // if the receiver / contact point has any errors show it on the policy const actualContactPoint = contactPoint ?? inheritedProperties?.receiver ?? ''; const contactPointErrors = contactPointsState ? getContactPointErrors(actualContactPoint, contactPointsState) : []; const allChildPolicies = currentRoute.routes ?? []; // filter child policies that match const childPolicies = filtersApplied ? // filter by the ones that belong to the path that matches the filters allChildPolicies.filter((policy) => routesPath.some((route: RouteWithID) => route.id === policy.id)) : allChildPolicies; const hasChildPolicies = childPolicies.length > 0; const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id); // sum all alert instances for all groups we're handling const numberOfAlertInstances = matchingAlertGroups ? sumBy(matchingAlertGroups, (group) => group.alerts.length) : undefined; // simplified routing permissions const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility( AlertmanagerAction.ViewAutogeneratedPolicyTree ); // we collapse the auto-generated policies by default const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute); const [showPolicyChildren, togglePolicyChildren] = useToggle(isAutogeneratedPolicyRoot ? false : true); const groupBy = currentRoute.group_by; const muteTimings = currentRoute.mute_time_intervals ?? []; const activeTimings = currentRoute.active_time_intervals ?? []; const timingOptions: TimingOptions = { group_wait: currentRoute.group_wait, group_interval: currentRoute.group_interval, repeat_interval: currentRoute.repeat_interval, }; contactPointErrors.forEach((error) => { errors.push(error); }); const [visibleChildPolicies, setVisibleChildPolicies] = useState(POLICIES_PER_PAGE); // build the menu actions for our policy const dropdownMenuActions: JSX.Element[] = useCreateDropdownMenuActions( isAutoGenerated, isDefaultPolicy, provisioned, onEditPolicy, currentRoute, toggleShowExportDrawer, onDeletePolicy ); // check if this policy should be visible. If it's autogenerated and the user is not allowed to see autogenerated // policies then we should not show it. Same if the user is not supported to see autogenerated policies. const hideCurrentPolicy = isAutoGenerated && (!isAllowedToSeeAutogeneratedChunk || !isSupportedToSeeAutogeneratedChunk); const hideCurrentPolicyForFilters = filtersApplied && !belongsToMatchPath; if (hideCurrentPolicy || hideCurrentPolicyForFilters) { return null; } const isImmutablePolicy = isDefaultPolicy || isAutogeneratedPolicyRoot; // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated const childPoliciesBelongingToMatchPath = childPolicies.filter((child) => routesPath.some((route: RouteWithID) => route.id === child.id) ); // child policies to render are the ones that belong to the path that matches the filters const childPoliciesToRender = filtersApplied ? childPoliciesBelongingToMatchPath : childPolicies; const pageOfChildren = childPoliciesToRender.slice(0, visibleChildPolicies); const moreCount = childPoliciesToRender.length - pageOfChildren.length; const showMore = moreCount > 0; return ( <>
{/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */} {continueMatching && } {showMatchesAllLabelsWarning && }
{/* Matchers and actions */}
{hasChildPolicies ? ( ) : null} {isImmutablePolicy ? ( isAutogeneratedPolicyRoot ? ( ) : ( ) ) : hasMatchers ? ( ) : ( No matchers )} {/* TODO maybe we should move errors to the gutter instead? */} {errors.length > 0 && } {provisioned && } {!isAutoGenerated && !readOnly && ( {isDefaultPolicy ? ( ) : ( onAddPolicy(currentRoute, 'above')} /> onAddPolicy(currentRoute, 'below')} /> onAddPolicy(currentRoute, 'child')} /> } > )} )} {dropdownMenuActions.length > 0 && ( {dropdownMenuActions}}> )}
{/* Metadata row */}
{showPolicyChildren && ( <> {pageOfChildren.map((child) => { const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); // This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy. const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated; /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable, then the child policy should not be editable either */ const isThisChildReadOnly = readOnly || provisioned || isAutoGenerated; return ( ); })} {showMore && ( )} )}
{showExportDrawer && }
); }; interface MetadataRowProps { matchingInstancesPreview: { groupsMap?: Map; enabled: boolean }; numberOfAlertInstances?: number; contactPoint?: string; groupBy?: string[]; muteTimings?: string[]; activeTimings?: string[]; timingOptions?: TimingOptions; inheritedProperties?: Partial; alertManagerSourceName: string; receivers: Receiver[]; matchingAlertGroups?: AlertmanagerGroup[]; matchers?: ObjectMatcher[]; isDefaultPolicy: boolean; onShowAlertInstances: ( alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter ) => void; } function MetadataRow({ numberOfAlertInstances, isDefaultPolicy, timingOptions, groupBy, muteTimings = [], activeTimings = [], matchingInstancesPreview, inheritedProperties, matchingAlertGroups, onShowAlertInstances, matchers, contactPoint, alertManagerSourceName, receivers, }: MetadataRowProps) { const styles = useStyles2(getStyles); const inheritedGrouping = inheritedProperties && inheritedProperties.group_by; const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0; const noGrouping = isArray(groupBy) && groupBy[0] === '...'; const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0; const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0; const hasMuteTimings = Boolean(muteTimings.length); const hasActiveTimings = Boolean(activeTimings.length); return (
{matchingInstancesPreview.enabled && ( { matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName)); }} data-testid="matching-instances" > {numberOfAlertInstances ?? '-'} instance )} {contactPoint && ( Delivered to{' '} )} {!inheritedGrouping && ( <> {customGrouping && ( Grouped by{' '} {groupBy.join(', ')} )} {singleGroup && ( Single group )} {noGrouping && ( Not grouping )} )} {hasMuteTimings && ( Muted when{' '} )} {hasActiveTimings && ( Active when{' '} )} {timingOptions && } {hasInheritedProperties && ( <> Inherited )}
); } export const useCreateDropdownMenuActions = ( isAutoGenerated: boolean, isDefaultPolicy: boolean, provisioned: boolean, onEditPolicy: (route: RouteWithID, isDefault?: boolean, readOnly?: boolean) => void, currentRoute: RouteWithID, toggleShowExportDrawer: () => void, onDeletePolicy: (route: RouteWithID) => void ) => { const [ [updatePoliciesSupported, updatePoliciesAllowed], [deletePolicySupported, deletePolicyAllowed], [exportPoliciesSupported, exportPoliciesAllowed], ] = useAlertmanagerAbilities([ AlertmanagerAction.UpdateNotificationPolicyTree, AlertmanagerAction.DeleteNotificationPolicy, AlertmanagerAction.ExportNotificationPolicies, ]); const dropdownMenuActions = []; const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy && !isAutoGenerated; const showEditAction = updatePoliciesSupported && updatePoliciesAllowed; const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy && !isAutoGenerated; if (showEditAction) { dropdownMenuActions.push( onEditPolicy(currentRoute, isDefaultPolicy)} /> ); } if (showExportAction) { dropdownMenuActions.push( ); } if (showDeleteAction) { dropdownMenuActions.push( onDeletePolicy(currentRoute)} /> ); } return dropdownMenuActions; }; export const AUTOGENERATED_ROOT_LABEL_NAME = '__grafana_autogenerated__'; export function isAutoGeneratedRootAndSimplifiedEnabled(route: RouteWithID) { const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; if (!simplifiedRoutingToggleEnabled) { return false; } if (!route.object_matchers) { return false; } return ( route.object_matchers.some((objectMatcher) => { return ( objectMatcher[0] === AUTOGENERATED_ROOT_LABEL_NAME && objectMatcher[1] === MatcherOperator.equal && objectMatcher[2] === 'true' ); }) ?? false ); // return simplifiedRoutingToggleEnabled && route.receiver === 'contact_point_5'; } const ProvisionedTooltip = (children: ReactNode) => ( {children} ); const Errors: FC<{ errors: React.ReactNode[] }> = ({ errors }) => ( {errors.map((error) => ( {error} ))} } > ); const ContinueMatchingIndicator: FC = () => { const styles = useStyles2(getStyles); return (
); }; const AllMatchesIndicator: FC = () => { const styles = useStyles2(getStyles); return (
); }; function DefaultPolicyIndicator() { const styles = useStyles2(getStyles); return ( <> Default policy All alert instances will be handled by the default policy if no other matching policies are found. ); } function AutogeneratedRootIndicator() { return ( Auto-generated policies ); } const InheritedProperties: FC<{ properties: InheritableProperties }> = ({ properties }) => ( {Object.entries(properties).map(([key, value]) => { if (!value) { return null; } return ); const TimeIntervals: FC<{ timings: string[]; alertManagerSourceName: string }> = ({ timings, alertManagerSourceName, }) => { const [, canSeeMuteTimings] = useAlertmanagerAbility(AlertmanagerAction.ViewMuteTiming); /* TODO make a better mute timing overview, allow combining multiple in to one overview */ /* Mute Timings} content={ // TODO show a combined view of all mute timings here, combining the weekdays, years, months, etc } >
{muteTimings.join(', ')}
*/ return (
{timings.map((timing, index) => { const Wrapper = canSeeMuteTimings ? TextLink : Text; return ( {timing} {index < timings.length - 1 && ', '} ); })}
); }; interface TimingOptionsMetaProps { timingOptions: TimingOptions; } export const TimingOptionsMeta = ({ timingOptions }: TimingOptionsMetaProps) => { const groupWait = timingOptions.group_wait; const groupInterval = timingOptions.group_interval; const repeatInterval = timingOptions.repeat_interval; // we don't have any timing options to show – we're inheriting everything from the parent // and those show up in a separate "inherited properties" component if (!groupWait && !groupInterval && !repeatInterval) { return null; } const metaOptions: ReactNode[] = []; if (groupWait) { metaOptions.push( Wait to group instances ); } if (groupInterval) { metaOptions.push( Wait before sending updates ); } if (repeatInterval) { metaOptions.push( Repeated every ); } return ( {metaOptions.map((meta, index) => ( {meta} {index < metaOptions.length - 1 && ' · '} ))} ); }; interface ContactPointDetailsProps { alertManagerSourceName: string; contactPoint: string; receivers: Receiver[]; } const ContactPointsHoverDetails: FC = ({ alertManagerSourceName, contactPoint, receivers, }) => { 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; const contactPointLink = 'id' in details && details.id ? createContactPointLink(details.id, alertManagerSourceName) : createContactPointSearchLink(details.name, alertManagerSourceName); return ( {contactPoint} } key={uniqueId()} content={ } > {contactPoint} ); }; function getContactPointErrors(contactPoint: string, contactPointsState: ReceiversState): JSX.Element[] { const notifierStates = Object.entries(contactPointsState[contactPoint]?.notifiers ?? []); const contactPointErrors = notifierStates.reduce((acc: JSX.Element[] = [], [_, notifierStatuses]) => { const notifierErrors = notifierStatuses .filter((status) => status.lastNotifyAttemptError) .map((status) => (