Files
grafana/public/app/features/alerting/unified/components/notification-policies/Policy.tsx
2023-06-20 15:49:54 +03:00

654 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { css } from '@emotion/css';
import { uniqueId, groupBy, upperFirst, sumBy, isArray } from 'lodash';
import pluralize from 'pluralize';
import React, { FC, Fragment, ReactNode } from 'react';
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';
import { Span } from '@grafana/ui/src/unstable';
import { contextSrv } from 'app/core/core';
import { RouteWithID, Receiver, ObjectMatcher, AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types';
import { getNotificationsPermissions } from '../../utils/access-control';
import { normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
import { MetaText } from '../MetaText';
import { Spacer } from '../Spacer';
import { Strong } from '../Strong';
import { Matchers } from './Matchers';
import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions';
interface PolicyComponentProps {
receivers?: Receiver[];
alertGroups?: AlertmanagerGroup[];
contactPointsState?: ReceiversState;
readOnly?: boolean;
inheritedProperties?: Partial<InhertitableProperties>;
routesMatchingFilters?: RouteWithID[];
// routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
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 = [],
matchingInstancesPreview = { enabled: false },
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;
const groupBy = currentRoute.group_by;
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;
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;
const isEditable = canEditRoutes;
const isDeletable = canDeleteRoutes && !isDefaultPolicy;
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;
// 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 />}
<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}>
<Button
variant="secondary"
icon="plus"
size="sm"
onClick={() => onAddPolicy(currentRoute)}
type="button"
>
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}
/>
</MetaText>
)}
{!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>
</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 */}
{childPolicies.map((child) => {
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
return (
<Policy
key={uniqueId()}
routeTree={routeTree}
currentRoute={child}
receivers={receivers}
contactPointsState={contactPointsState}
readOnly={readOnly}
inheritedProperties={childInheritedProperties}
onAddPolicy={onAddPolicy}
onEditPolicy={onEditPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
alertManagerSourceName={alertManagerSourceName}
alertGroups={alertGroups}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={matchingInstancesPreview}
/>
);
})}
</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]) => {
// no idea how to do this with TypeScript without type casting...
return (
<Label
key={key}
// @ts-ignore
label={routePropertyToLabel(key)}
// @ts-ignore
value={<Strong>{routePropertyToValue(key, value)}</Strong>}
/>
);
})}
</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',
};
// @TODO make this work for cloud AMs too
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';
}
};
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;
};
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 1px ${borderColor};
border-radius: ${theme.shape.borderRadius(1)};
`,
};
},
childPolicies: css`
margin-left: ${theme.spacing(4)};
position: relative;
&:before {
content: '';
position: absolute;
height: calc(100% - 10px);
border-left: solid 1px ${theme.colors.border.weak};
margin-top: 0;
margin-left: -20px;
}
`,
policyItemWrapper: css`
padding: ${theme.spacing(1.5)};
`,
metadataRow: css`
background: ${theme.colors.background.secondary};
border-bottom-left-radius: ${theme.shape.borderRadius(1)};
border-bottom-right-radius: ${theme.shape.borderRadius(1)};
`,
matchersRow: css``,
policyWrapper: (hasFocus = false) => css`
flex: 1;
position: relative;
background: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius(1)};
border: solid 1px ${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 1px ${theme.colors.border.weak};
border-radius: ${theme.shape.borderRadius(1)};
padding: 0;
`,
});
export { Policy };