2023-03-02 13:49:38 +01:00
import { css } from '@emotion/css' ;
2023-06-08 13:01:40 +02:00
import { uniqueId , groupBy , upperFirst , sumBy , isArray } from 'lodash' ;
2023-03-02 13:49:38 +01:00
import pluralize from 'pluralize' ;
2023-05-30 15:15:22 +02:00
import React , { FC , Fragment , ReactNode } from 'react' ;
2023-03-02 13:49:38 +01:00
import { Link } from 'react-router-dom' ;
import { GrafanaTheme2 , IconName } from '@grafana/data' ;
import { Stack } from '@grafana/experimental' ;
import { Badge , Button , Dropdown , getTagColorsFromName , Icon , Menu , Tooltip , useStyles2 } from '@grafana/ui' ;
2023-06-08 13:01:40 +02:00
import { Span } from '@grafana/ui/src/unstable' ;
2023-03-02 13:49:38 +01:00
import { contextSrv } from 'app/core/core' ;
2023-06-08 13:01:40 +02:00
import { RouteWithID , Receiver , ObjectMatcher , AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types' ;
2023-03-02 13:49:38 +01:00
import { ReceiversState } from 'app/types' ;
import { getNotificationsPermissions } from '../../utils/access-control' ;
2023-05-30 15:15:22 +02:00
import { normalizeMatchers } from '../../utils/matchers' ;
2023-03-02 13:49:38 +01:00
import { createContactPointLink , createMuteTimingLink } from '../../utils/misc' ;
2023-06-08 13:01:40 +02:00
import { getInheritedProperties , InhertitableProperties } from '../../utils/notification-policies' ;
2023-03-02 13:49:38 +01:00
import { HoverCard } from '../HoverCard' ;
import { Label } from '../Label' ;
import { MetaText } from '../MetaText' ;
import { Spacer } from '../Spacer' ;
import { Strong } from '../Strong' ;
import { Matchers } from './Matchers' ;
2023-03-21 12:12:25 +01:00
import { TimingOptions , TIMING_OPTIONS_DEFAULTS } from './timingOptions' ;
2023-03-02 13:49:38 +01:00
interface PolicyComponentProps {
receivers? : Receiver [ ] ;
alertGroups? : AlertmanagerGroup [ ] ;
contactPointsState? : ReceiversState ;
readOnly? : boolean ;
2023-06-08 13:01:40 +02:00
inheritedProperties? : Partial < InhertitableProperties > ;
2023-03-02 13:49:38 +01:00
routesMatchingFilters? : RouteWithID [ ] ;
2023-05-30 15:15:22 +02:00
// routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
matchingInstancesPreview ? : { groupsMap? : Map < string , AlertmanagerGroup [ ] > ; enabled : boolean } ;
2023-03-02 13:49:38 +01:00
routeTree : RouteWithID ;
currentRoute : RouteWithID ;
alertManagerSourceName : string ;
onEditPolicy : ( route : RouteWithID , isDefault? : boolean ) = > void ;
onAddPolicy : ( route : RouteWithID ) = > void ;
onDeletePolicy : ( route : RouteWithID ) = > void ;
onShowAlertInstances : ( alertGroups : AlertmanagerGroup [ ] , matchers? : ObjectMatcher [ ] ) = > void ;
}
const Policy : FC < PolicyComponentProps > = ( {
receivers = [ ] ,
contactPointsState ,
readOnly = false ,
alertGroups = [ ] ,
alertManagerSourceName ,
currentRoute ,
routeTree ,
inheritedProperties ,
routesMatchingFilters = [ ] ,
2023-05-30 15:15:22 +02:00
matchingInstancesPreview = { enabled : false } ,
2023-03-02 13:49:38 +01:00
onEditPolicy ,
onAddPolicy ,
onDeletePolicy ,
onShowAlertInstances ,
} ) = > {
const styles = useStyles2 ( getStyles ) ;
const isDefaultPolicy = currentRoute === routeTree ;
const permissions = getNotificationsPermissions ( alertManagerSourceName ) ;
const canEditRoutes = contextSrv . hasPermission ( permissions . update ) ;
const canDeleteRoutes = contextSrv . hasPermission ( permissions . delete ) ;
const contactPoint = currentRoute . receiver ;
const continueMatching = currentRoute . continue ? ? false ;
2023-06-08 13:01:40 +02:00
const groupBy = currentRoute . group_by ;
2023-03-02 13:49:38 +01:00
const muteTimings = currentRoute . mute_time_intervals ? ? [ ] ;
const timingOptions : TimingOptions = {
group_wait : currentRoute.group_wait ,
group_interval : currentRoute.group_interval ,
repeat_interval : currentRoute.repeat_interval ,
} ;
const matchers = normalizeMatchers ( currentRoute ) ;
const hasMatchers = Boolean ( matchers && matchers . length ) ;
const hasMuteTimings = Boolean ( muteTimings . length ) ;
const hasFocus = routesMatchingFilters . some ( ( route ) = > 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 ) : [ ] ;
contactPointErrors . forEach ( ( error ) = > {
errors . push ( error ) ;
} ) ;
const hasInheritedProperties = inheritedProperties && Object . keys ( inheritedProperties ) . length > 0 ;
2023-06-08 13:01:40 +02:00
const childPolicies = currentRoute . routes ? ? [ ] ;
const inheritedGrouping = hasInheritedProperties && inheritedProperties . group_by ;
const noGrouping = isArray ( groupBy ) && groupBy [ 0 ] === '...' ;
const customGrouping = ! noGrouping && isArray ( groupBy ) && groupBy . length > 0 ;
const singleGroup = isDefaultPolicy && isArray ( groupBy ) && groupBy . length === 0 ;
2023-03-02 13:49:38 +01:00
const isEditable = canEditRoutes ;
const isDeletable = canDeleteRoutes && ! isDefaultPolicy ;
2023-05-30 15:15:22 +02:00
const matchingAlertGroups = matchingInstancesPreview ? . groupsMap ? . get ( currentRoute . id ) ;
2023-03-02 13:49:38 +01:00
// sum all alert instances for all groups we're handling
2023-05-30 15:15:22 +02:00
const numberOfAlertInstances = matchingAlertGroups
? sumBy ( matchingAlertGroups , ( group ) = > group . alerts . length )
: undefined ;
2023-03-02 13:49:38 +01:00
// TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated
return (
< Stack direction = "column" gap = { 1.5 } >
< div
className = { styles . policyWrapper ( hasFocus ) }
data - testid = { isDefaultPolicy ? 'am-root-route-container' : 'am-route-container' }
>
{ /* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */ }
{ continueMatching && < ContinueMatchingIndicator / > }
{ showMatchesAllLabelsWarning && < AllMatchesIndicator / > }
2023-06-20 14:49:54 +02:00
< div className = { styles . policyItemWrapper } >
< Stack direction = "column" gap = { 1 } >
{ /* Matchers and actions */ }
< div className = { styles . matchersRow } >
< Stack direction = "row" alignItems = "center" gap = { 1 } >
{ isDefaultPolicy ? (
< DefaultPolicyIndicator / >
) : hasMatchers ? (
< Matchers matchers = { matchers ? ? [ ] } / >
) : (
< span className = { styles . metadata } > No matchers < / span >
) }
< Spacer / >
{ /* TODO maybe we should move errors to the gutter instead? */ }
{ errors . length > 0 && < Errors errors = { errors } / > }
{ ! readOnly && (
< Stack direction = "row" gap = { 0.5 } >
2023-03-21 12:12:25 +01:00
< Button
variant = "secondary"
2023-06-20 14:49:54 +02:00
icon = "plus"
2023-03-21 12:12:25 +01:00
size = "sm"
2023-06-20 14:49:54 +02:00
onClick = { ( ) = > onAddPolicy ( currentRoute ) }
2023-03-21 12:12:25 +01:00
type = "button"
2023-06-20 14:49:54 +02:00
>
New nested policy
< / Button >
< Dropdown
overlay = {
< Menu >
< Menu.Item
icon = "edit"
disabled = { ! isEditable }
label = "Edit"
onClick = { ( ) = > onEditPolicy ( currentRoute , isDefaultPolicy ) }
/ >
{ isDeletable && (
< >
< Menu.Divider / >
< Menu.Item
destructive
icon = "trash-alt"
label = "Delete"
onClick = { ( ) = > onDeletePolicy ( currentRoute ) }
/ >
< / >
) }
< / Menu >
}
>
< Button
icon = "ellipsis-h"
variant = "secondary"
size = "sm"
type = "button"
aria - label = "more-actions"
data - testid = "more-actions"
/ >
< / Dropdown >
< / Stack >
) }
< / Stack >
< / div >
{ /* Metadata row */ }
< div className = { styles . metadataRow } >
< Stack direction = "row" alignItems = "center" gap = { 1 } >
{ matchingInstancesPreview . enabled && (
< MetaText
icon = "layers-alt"
onClick = { ( ) = > {
matchingAlertGroups && onShowAlertInstances ( matchingAlertGroups , matchers ) ;
} }
data - testid = "matching-instances"
>
< Strong > { numberOfAlertInstances ? ? '-' } < / Strong >
< span > { pluralize ( 'instance' , numberOfAlertInstances ) } < / span >
< / MetaText >
) }
{ contactPoint && (
< MetaText icon = "at" data-testid = "contact-point" >
< span > Delivered to < / span >
< ContactPointsHoverDetails
alertManagerSourceName = { alertManagerSourceName }
receivers = { receivers }
contactPoint = { contactPoint }
2023-03-21 12:12:25 +01:00
/ >
2023-03-02 13:49:38 +01:00
< / MetaText >
2023-06-20 14:49:54 +02:00
) }
{ ! inheritedGrouping && (
< >
{ customGrouping && (
< MetaText icon = "layer-group" data-testid = "grouping" >
< span > Grouped by < / span >
< Strong > { groupBy . join ( ', ' ) } < / Strong >
< / MetaText >
) }
{ singleGroup && (
< MetaText icon = "layer-group" >
< span > Single group < / span >
< / MetaText >
) }
{ noGrouping && (
< MetaText icon = "layer-group" >
< span > Not grouping < / span >
< / MetaText >
) }
< / >
) }
{ hasMuteTimings && (
< MetaText icon = "calendar-slash" data-testid = "mute-timings" >
< span > Muted when < / span >
< MuteTimings timings = { muteTimings } alertManagerSourceName = { alertManagerSourceName } / >
< / MetaText >
) }
{ timingOptions && Object . values ( timingOptions ) . some ( Boolean ) && (
< TimingOptionsMeta timingOptions = { timingOptions } / >
) }
{ hasInheritedProperties && (
< >
< MetaText icon = "corner-down-right-alt" data-testid = "inherited-properties" >
< span > Inherited < / span >
< InheritedProperties properties = { inheritedProperties } / >
< / MetaText >
< / >
) }
< / Stack >
< / div >
< / Stack >
< / div >
2023-03-02 13:49:38 +01:00
< / div >
< div className = { styles . childPolicies } >
{ /* pass the "readOnly" prop from the parent, because if you can't edit the parent you can't edit children */ }
2023-06-08 13:01:40 +02:00
{ childPolicies . map ( ( child ) = > {
const childInheritedProperties = getInheritedProperties ( currentRoute , child , inheritedProperties ) ;
2023-03-02 13:49:38 +01:00
return (
< Policy
key = { uniqueId ( ) }
routeTree = { routeTree }
2023-06-08 13:01:40 +02:00
currentRoute = { child }
2023-03-02 13:49:38 +01:00
receivers = { receivers }
contactPointsState = { contactPointsState }
readOnly = { readOnly }
2023-06-08 13:01:40 +02:00
inheritedProperties = { childInheritedProperties }
2023-03-02 13:49:38 +01:00
onAddPolicy = { onAddPolicy }
onEditPolicy = { onEditPolicy }
onDeletePolicy = { onDeletePolicy }
onShowAlertInstances = { onShowAlertInstances }
alertManagerSourceName = { alertManagerSourceName }
alertGroups = { alertGroups }
routesMatchingFilters = { routesMatchingFilters }
2023-05-30 15:15:22 +02:00
matchingInstancesPreview = { matchingInstancesPreview }
2023-03-02 13:49:38 +01:00
/ >
) ;
} ) }
< / div >
< / Stack >
) ;
} ;
const Errors : FC < { errors : React.ReactNode [ ] } > = ( { errors } ) = > (
< HoverCard
arrow
placement = "top"
content = {
< Stack direction = "column" gap = { 0.5 } >
{ errors . map ( ( error ) = > (
< Fragment key = { uniqueId ( ) } > { error } < / Fragment >
) ) }
< / Stack >
}
>
< span >
< Badge icon = "exclamation-circle" color = "red" text = { pluralize ( 'error' , errors . length , true ) } / >
< / span >
< / HoverCard >
) ;
const ContinueMatchingIndicator : FC = ( ) = > {
const styles = useStyles2 ( getStyles ) ;
return (
< Tooltip placement = "top" content = "This route will continue matching other policies" >
< div className = { styles . gutterIcon } data-testid = "continue-matching" >
< Icon name = "arrow-down" / >
< / div >
< / Tooltip >
) ;
} ;
const AllMatchesIndicator : FC = ( ) = > {
const styles = useStyles2 ( getStyles ) ;
return (
< Tooltip placement = "top" content = "This policy matches all labels" >
< div className = { styles . gutterIcon } data-testid = "matches-all" >
< Icon name = "exclamation-triangle" / >
< / div >
< / Tooltip >
) ;
} ;
const DefaultPolicyIndicator : FC = ( ) = > {
const styles = useStyles2 ( getStyles ) ;
return (
< >
< strong > Default policy < / strong >
< span className = { styles . metadata } >
All alert instances will be handled by the default policy if no other matching policies are found .
< / span >
< / >
) ;
} ;
const InheritedProperties : FC < { properties : InhertitableProperties } > = ( { properties } ) = > (
< HoverCard
arrow
placement = "top"
content = {
< Stack direction = "row" gap = { 0.5 } >
{ Object . entries ( properties ) . map ( ( [ key , value ] ) = > {
2023-06-08 13:01:40 +02:00
// no idea how to do this with TypeScript without type casting...
2023-03-02 13:49:38 +01:00
return (
< Label
key = { key }
// @ts-ignore
label = { routePropertyToLabel ( key ) }
2023-06-08 13:01:40 +02:00
// @ts-ignore
value = { < Strong > { routePropertyToValue ( key , value ) } < / Strong > }
2023-03-02 13:49:38 +01:00
/ >
) ;
} ) }
< / Stack >
}
>
< div >
< Strong > { pluralize ( 'property' , Object . keys ( properties ) . length , true ) } < / Strong >
< / div >
< / HoverCard >
) ;
const MuteTimings : FC < { timings : string [ ] ; alertManagerSourceName : string } > = ( {
timings ,
alertManagerSourceName ,
} ) = > {
/* TODO make a better mute timing overview, allow combining multiple in to one overview */
/ *
< HoverCard
arrow
placement = "top"
header = { < MetaText icon = "calendar-slash" > Mute Timings < / MetaText > }
content = {
// TODO show a combined view of all mute timings here, combining the weekdays, years, months, etc
< Stack direction = "row" gap = { 0.5 } >
< Label label = "Weekdays" value = "Saturday and Sunday" / >
< / Stack >
}
>
< div >
< Strong > { muteTimings . join ( ', ' ) } < / Strong >
< / div >
< / HoverCard >
* /
return (
< div >
< Strong >
{ timings . map ( ( timing ) = > (
< Link key = { timing } to = { createMuteTimingLink ( timing , alertManagerSourceName ) } >
{ timing }
< / Link >
) ) }
< / Strong >
< / div >
) ;
} ;
const TimingOptionsMeta : FC < { timingOptions : TimingOptions } > = ( { timingOptions } ) = > {
const groupWait = timingOptions . group_wait ? ? TIMING_OPTIONS_DEFAULTS . group_wait ;
const groupInterval = timingOptions . group_interval ? ? TIMING_OPTIONS_DEFAULTS . group_interval ;
return (
< MetaText icon = "hourglass" data-testid = "timing-options" >
< span > Wait < / span >
< Tooltip
placement = "top"
content = "How long to initially wait to send a notification for a group of alert instances."
>
< span >
< Strong > { groupWait } < / Strong > < span > to group instances < / span > ,
< / span >
< / Tooltip >
< Tooltip
placement = "top"
content = "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent."
>
< span >
< Strong > { groupInterval } < / Strong > < span > before sending updates < / span >
< / span >
< / Tooltip >
< / MetaText >
) ;
} ;
interface ContactPointDetailsProps {
alertManagerSourceName : string ;
contactPoint : string ;
receivers : Receiver [ ] ;
}
const INTEGRATION_ICONS : Record < string , IconName > = {
discord : 'discord' ,
email : 'envelope' ,
googlechat : 'google-hangouts-alt' ,
hipchat : 'hipchat' ,
line : 'line' ,
pagerduty : 'pagerduty' ,
slack : 'slack' ,
teams : 'microsoft' ,
telegram : 'telegram-alt' ,
} ;
2023-05-22 11:43:12 +02:00
// @TODO make this work for cloud AMs too
2023-03-02 13:49:38 +01:00
const ContactPointsHoverDetails : FC < ContactPointDetailsProps > = ( {
alertManagerSourceName ,
contactPoint ,
receivers ,
} ) = > {
const details = receivers . find ( ( receiver ) = > receiver . name === contactPoint ) ;
if ( ! details ) {
return (
< Link to = { createContactPointLink ( contactPoint , alertManagerSourceName ) } >
< Strong > { contactPoint } < / Strong >
< / Link >
) ;
}
const integrations = details . grafana_managed_receiver_configs ;
if ( ! integrations ) {
return (
< Link to = { createContactPointLink ( contactPoint , alertManagerSourceName ) } >
< Strong > { contactPoint } < / Strong >
< / Link >
) ;
}
const groupedIntegrations = groupBy ( details . grafana_managed_receiver_configs , ( config ) = > config . type ) ;
return (
< HoverCard
arrow
placement = "top"
header = {
< MetaText icon = "at" >
< div > Contact Point < / div >
< Strong > { contactPoint } < / Strong >
< / MetaText >
}
key = { uniqueId ( ) }
content = {
< Stack direction = "row" gap = { 0.5 } >
{ /* use "label" to indicate how many of that type we have in the contact point */ }
{ Object . entries ( groupedIntegrations ) . map ( ( [ type , integrations ] ) = > (
< Label
key = { uniqueId ( ) }
label = { integrations . length > 1 ? integrations.length : undefined }
icon = { INTEGRATION_ICONS [ type ] }
value = { upperFirst ( type ) }
/ >
) ) }
< / Stack >
}
>
< Link to = { createContactPointLink ( contactPoint , alertManagerSourceName ) } >
< Strong > { contactPoint } < / Strong >
< / Link >
< / HoverCard >
) ;
} ;
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 ) = > (
< Label
icon = "at"
key = { uniqueId ( ) }
label = { ` Contact Point › ${ status . name } ` }
value = { status . lastNotifyAttemptError }
/ >
) ) ;
return acc . concat ( notifierErrors ) ;
} , [ ] ) ;
return contactPointErrors ;
}
const routePropertyToLabel = ( key : keyof InhertitableProperties ) : string = > {
switch ( key ) {
case 'receiver' :
return 'Contact Point' ;
case 'group_by' :
return 'Group by' ;
case 'group_interval' :
return 'Group interval' ;
case 'group_wait' :
return 'Group wait' ;
case 'mute_time_intervals' :
return 'Mute timings' ;
case 'repeat_interval' :
return 'Repeat interval' ;
}
} ;
2023-06-08 13:01:40 +02:00
const routePropertyToValue = ( key : keyof InhertitableProperties , value : string | string [ ] ) : React . ReactNode = > {
const isNotGrouping = key === 'group_by' && Array . isArray ( value ) && value [ 0 ] === '...' ;
const isSingleGroup = key === 'group_by' && Array . isArray ( value ) && value . length === 0 ;
if ( isNotGrouping ) {
return (
< Span variant = "bodySmall" color = "secondary" >
Not grouping
< / Span >
) ;
}
if ( isSingleGroup ) {
return (
< Span variant = "bodySmall" color = "secondary" >
Single group
< / Span >
) ;
}
return Array . isArray ( value ) ? value . join ( ', ' ) : value ;
} ;
2023-03-02 13:49:38 +01:00
const getStyles = ( theme : GrafanaTheme2 ) = > ( {
matcher : ( label : string ) = > {
const { color , borderColor } = getTagColorsFromName ( label ) ;
return {
wrapper : css `
color : # fff ;
background : $ { color } ;
padding : $ { theme . spacing ( 0.33 ) } $ { theme . spacing ( 0.66 ) } ;
font - size : $ { theme . typography . bodySmall . fontSize } ;
border : solid 1 px $ { borderColor } ;
2023-06-20 14:49:54 +02:00
border - radius : $ { theme . shape . borderRadius ( 1 ) } ;
2023-03-02 13:49:38 +01:00
` ,
} ;
} ,
childPolicies : css `
margin - left : $ { theme . spacing ( 4 ) } ;
position : relative ;
& : before {
content : '' ;
position : absolute ;
height : calc ( 100 % - 10 px ) ;
border - left : solid 1 px $ { theme . colors . border . weak } ;
margin - top : 0 ;
margin - left : - 20 px ;
}
` ,
2023-06-20 14:49:54 +02:00
policyItemWrapper : css `
2023-03-02 13:49:38 +01:00
padding : $ { theme . spacing ( 1.5 ) } ;
` ,
2023-06-20 14:49:54 +02:00
metadataRow : css `
background : $ { theme . colors . background . secondary } ;
border - bottom - left - radius : $ { theme . shape . borderRadius ( 1 ) } ;
border - bottom - right - radius : $ { theme . shape . borderRadius ( 1 ) } ;
2023-03-02 13:49:38 +01:00
` ,
2023-06-20 14:49:54 +02:00
matchersRow : css ` ` ,
2023-03-02 13:49:38 +01:00
policyWrapper : ( hasFocus = false ) = > css `
flex : 1 ;
position : relative ;
background : $ { theme . colors . background . secondary } ;
2023-06-20 14:49:54 +02:00
border - radius : $ { theme . shape . borderRadius ( 1 ) } ;
2023-03-02 13:49:38 +01:00
border : solid 1 px $ { theme . colors . border . weak } ;
$ { hasFocus &&
css `
border - color : $ { theme . colors . primary . border } ;
` }
` ,
metadata : css `
color : $ { theme . colors . text . secondary } ;
font - size : $ { theme . typography . bodySmall . fontSize } ;
font - weight : $ { theme . typography . bodySmall . fontWeight } ;
` ,
break : css `
width : 100 % ;
height : 0 ;
margin - bottom : $ { theme . spacing ( 2 ) } ;
` ,
gutterIcon : css `
position : absolute ;
top : 0 ;
transform : translateY ( 50 % ) ;
left : - $ { theme . spacing ( 4 ) } ;
color : $ { theme . colors . text . secondary } ;
background : $ { theme . colors . background . primary } ;
width : 25px ;
height : 25px ;
text - align : center ;
border : solid 1 px $ { theme . colors . border . weak } ;
2023-06-20 14:49:54 +02:00
border - radius : $ { theme . shape . borderRadius ( 1 ) } ;
2023-03-02 13:49:38 +01:00
padding : 0 ;
` ,
} ) ;
export { Policy } ;