The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/alerting/unified/components/notification-policies/Policy.tsx

1064 lines
36 KiB

import { css } from '@emotion/css';
import { defaults, groupBy, isArray, sumBy, uniqueId, upperFirst } from 'lodash';
import pluralize from 'pluralize';
import { FC, Fragment, ReactNode, useState } from 'react';
import * as React 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 { 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,
ObjectMatcher,
Receiver,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types';
import { RoutesMatchingFilters } from '../../NotificationPolicies';
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { getAmMatcherFormatter } from '../../utils/alertmanager';
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { InsertPosition } from '../../utils/routeTree';
import { Authorize } from '../Authorize';
import { HoverCard } 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 { TIMING_OPTIONS_DEFAULTS, TimingOptions } from './timingOptions';
interface PolicyComponentProps {
receivers?: Receiver[];
alertGroups?: AlertmanagerGroup[];
contactPointsState?: ReceiversState;
readOnly?: boolean;
provisioned?: boolean;
inheritedProperties?: Partial<InheritableProperties>;
routesMatchingFilters?: RoutesMatchingFilters;
matchingInstancesPreview?: {
groupsMap?: Map<string, AlertmanagerGroup[]>;
enabled: boolean;
};
routeTree: RouteWithID;
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;
}
const Policy = (props: PolicyComponentProps) => {
const {
receivers = [],
contactPointsState,
readOnly = false,
provisioned = false,
alertGroups = [],
alertManagerSourceName,
currentRoute,
routeTree,
inheritedProperties,
routesMatchingFilters = {
filtersApplied: false,
matchedRoutesWithPath: new Map<RouteWithID, RouteWithID[]>(),
},
matchingInstancesPreview = { enabled: false },
onEditPolicy,
onAddPolicy,
onDeletePolicy,
onShowAlertInstances,
isAutoGenerated = false,
} = props;
const styles = useStyles2(getStyles);
const isDefaultPolicy = currentRoute === routeTree;
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 POLICIES_PER_PAGE = 20;
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 (
<>
<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>
<Stack direction="row" alignItems="center" gap={1}>
{hasChildPolicies ? (
<IconButton
name={showPolicyChildren ? 'angle-down' : 'angle-right'}
onClick={togglePolicyChildren}
aria-label={showPolicyChildren ? 'Collapse' : 'Expand'}
/>
) : null}
{isImmutablePolicy ? (
isAutogeneratedPolicyRoot ? (
<AutogeneratedRootIndicator />
) : (
<DefaultPolicyIndicator />
)
) : hasMatchers ? (
<Matchers matchers={matchers ?? []} formatter={getAmMatcherFormatter(alertManagerSourceName)} />
) : (
<span className={styles.metadata}>
<Trans i18nKey="alerting.policies.no-matchers">No matchers</Trans>
</span>
)}
<Spacer />
{/* TODO maybe we should move errors to the gutter instead? */}
{errors.length > 0 && <Errors errors={errors} />}
{provisioned && <ProvisioningBadge />}
<Stack direction="row" gap={0.5}>
{!isAutoGenerated && !readOnly && (
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
{isDefaultPolicy ? (
<Button
variant="secondary"
icon="plus"
size="sm"
disabled={provisioned}
type="button"
onClick={() => onAddPolicy(currentRoute, 'child')}
>
<Trans i18nKey="alerting.policies.new-child">New child policy</Trans>
</Button>
) : (
<Dropdown
overlay={
<Menu>
<Menu.Item
label="New sibling above"
icon="arrow-up"
onClick={() => onAddPolicy(currentRoute, 'above')}
/>
<Menu.Item
label="New sibling below"
icon="arrow-down"
onClick={() => onAddPolicy(currentRoute, 'below')}
/>
<Menu.Divider />
<Menu.Item
label="New child policy"
icon="plus"
onClick={() => onAddPolicy(currentRoute, 'child')}
/>
</Menu>
}
>
<Button
size="sm"
variant="secondary"
disabled={provisioned}
icon="angle-down"
type="button"
>
<Trans i18nKey="alerting.policies.new-policy">Add new policy</Trans>
</Button>
</Dropdown>
)}
</ConditionalWrap>
</Authorize>
)}
{dropdownMenuActions.length > 0 && (
<Dropdown overlay={<Menu>{dropdownMenuActions}</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 */}
<MetadataRow
matchingInstancesPreview={matchingInstancesPreview}
numberOfAlertInstances={numberOfAlertInstances}
contactPoint={contactPoint ?? undefined}
groupBy={groupBy}
muteTimings={muteTimings}
activeTimings={activeTimings}
timingOptions={timingOptions}
inheritedProperties={inheritedProperties}
alertManagerSourceName={alertManagerSourceName}
receivers={receivers}
matchingAlertGroups={matchingAlertGroups}
matchers={matchers}
isDefaultPolicy={isDefaultPolicy}
onShowAlertInstances={onShowAlertInstances}
/>
</Stack>
</div>
</div>
<div className={styles.childPolicies}>
{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 (
<Policy
key={child.id}
routeTree={routeTree}
currentRoute={child}
receivers={receivers}
contactPointsState={contactPointsState}
readOnly={isThisChildReadOnly}
inheritedProperties={childInheritedProperties}
onAddPolicy={onAddPolicy}
onEditPolicy={onEditPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
alertManagerSourceName={alertManagerSourceName}
alertGroups={alertGroups}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={matchingInstancesPreview}
isAutoGenerated={isThisChildAutoGenerated}
provisioned={provisioned}
/>
);
})}
{showMore && (
<Button
size="sm"
icon="angle-down"
variant="secondary"
className={styles.moreButtons}
onClick={() => setVisibleChildPolicies(visibleChildPolicies + POLICIES_PER_PAGE)}
>
<Trans i18nKey="alerting.policies.n-more-policies" count={moreCount}>
{{ count: moreCount }} additional policies
</Trans>
</Button>
)}
</>
)}
</div>
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
</Stack>
</>
);
};
interface MetadataRowProps {
matchingInstancesPreview: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
numberOfAlertInstances?: number;
contactPoint?: string;
groupBy?: string[];
muteTimings?: string[];
activeTimings?: string[];
timingOptions?: TimingOptions;
inheritedProperties?: Partial<InheritableProperties>;
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 (
<div className={styles.metadataRow}>
<Stack direction="row" alignItems="center" gap={1}>
{matchingInstancesPreview.enabled && (
<MetaText
icon="layers-alt"
onClick={() => {
matchingAlertGroups &&
onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName));
}}
data-testid="matching-instances"
>
<Text color="primary">{numberOfAlertInstances ?? '-'}</Text>
<span>
<Trans i18nKey="alerting.policies.metadata.n-instances" count={numberOfAlertInstances ?? 0}>
instance
</Trans>
</span>
</MetaText>
)}
{contactPoint && (
<MetaText icon="at" data-testid="contact-point">
<span>
<Trans i18nKey="alerting.policies.metadata.delivered-to">Delivered to</Trans>
</span>
<ContactPointsHoverDetails
alertManagerSourceName={alertManagerSourceName}
receivers={receivers}
contactPoint={contactPoint}
/>
</MetaText>
)}
{!inheritedGrouping && (
<>
{customGrouping && (
<MetaText icon="layer-group" data-testid="grouping">
<span>
<Trans i18nKey="alerting.policies.metadata.grouped-by">Grouped by</Trans>
</span>
<Text color="primary">{groupBy.join(', ')}</Text>
</MetaText>
)}
{singleGroup && (
<MetaText icon="layer-group">
<span>
<Trans i18nKey="alerting.policies.metadata.grouping.single-group">Single group</Trans>
</span>
</MetaText>
)}
{noGrouping && (
<MetaText icon="layer-group">
<span>
<Trans i18nKey="alerting.policies.metadata.grouping.none">Not grouping</Trans>
</span>
</MetaText>
)}
</>
)}
{hasMuteTimings && (
<MetaText icon="calendar-slash" data-testid="mute-timings">
<span>
<Trans i18nKey="alerting.policies.metadata.mute-time">Muted when</Trans>
</span>
<TimeIntervals timings={muteTimings} alertManagerSourceName={alertManagerSourceName} />
</MetaText>
)}
{hasActiveTimings && (
<MetaText icon="calendar-alt" data-testid="active-timings">
<span>
<Trans i18nKey="alerting.policies.metadata.active-time">Active when</Trans>
</span>
<TimeIntervals timings={activeTimings} alertManagerSourceName={alertManagerSourceName} />
</MetaText>
)}
{timingOptions && (
// for the default policy we will also merge the default timings, that way a user can observe what the timing options would be
<TimingOptionsMeta
timingOptions={isDefaultPolicy ? defaults(timingOptions, TIMING_OPTIONS_DEFAULTS) : timingOptions}
/>
)}
{hasInheritedProperties && (
<>
<MetaText icon="corner-down-right-alt" data-testid="inherited-properties">
<span>
<Trans i18nKey="alerting.policies.metadata.inherited">Inherited</Trans>
</span>
<InheritedProperties properties={inheritedProperties} />
</MetaText>
</>
)}
</Stack>
</div>
);
}
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(
<Fragment key="edit-policy">
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
icon="edit"
disabled={provisioned || isAutoGenerated}
label="Edit"
onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)}
/>
</ConditionalWrap>
</Fragment>
);
}
if (showExportAction) {
dropdownMenuActions.push(
<Menu.Item key="export-policy" icon="download-alt" label="Export" onClick={toggleShowExportDrawer} />
);
}
if (showDeleteAction) {
dropdownMenuActions.push(
<Fragment key="delete-policy">
<Menu.Divider />
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
destructive
icon="trash-alt"
disabled={provisioned || isAutoGenerated}
label="Delete"
onClick={() => onDeletePolicy(currentRoute)}
/>
</ConditionalWrap>
</Fragment>
);
}
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) => (
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
<span>{children}</span>
</Tooltip>
);
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>
);
};
function DefaultPolicyIndicator() {
const styles = useStyles2(getStyles);
return (
<>
<Text element="h2" variant="body" weight="medium">
<Trans i18nKey="alerting.policies.default-policy.title">Default policy</Trans>
</Text>
<span className={styles.metadata}>
<Trans i18nKey="alerting.policies.default-policy.description">
All alert instances will be handled by the default policy if no other matching policies are found.
</Trans>
</span>
</>
);
}
function AutogeneratedRootIndicator() {
return (
<Text element="h3" variant="body" weight="medium">
<Trans i18nKey="alerting.policies.generated-policies">Auto-generated policies</Trans>
</Text>
);
}
const InheritedProperties: FC<{ properties: InheritableProperties }> = ({ properties }) => (
<HoverCard
arrow
placement="top"
content={
<Stack direction="row" gap={0.5}>
{Object.entries(properties).map(([key, value]) => {
if (!value) {
return null;
}
return <Label key={key} label={routePropertyToLabel(key)} value={routePropertyToValue(key, value)} />;
})}
</Stack>
}
>
<div>
<Text color="primary">{pluralize('property', Object.keys(properties).length, true)}</Text>
</div>
</HoverCard>
);
const TimeIntervals: 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>
{timings.map((timing, index) => (
<Fragment key={timing}>
<TextLink
href={createMuteTimingLink(timing, alertManagerSourceName)}
color="primary"
variant="bodySmall"
inline={false}
>
{timing}
</TextLink>
{index < timings.length - 1 && ', '}
</Fragment>
))}
</div>
);
};
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(
<Tooltip
placement="top"
content={t(
'alerting.policies.metadata.timingOptions.groupWait.description',
'How long to initially wait to send a notification for a group of alert instances.'
)}
>
<span>
<Trans i18nKey="alerting.policies.metadata.timingOptions.groupWait.label">
Wait <PrimaryText content={groupWait} /> to group instances
</Trans>
</span>
</Tooltip>
);
}
if (groupInterval) {
metaOptions.push(
<Tooltip
placement="top"
content={t(
'alerting.policies.metadata.timingOptions.groupInterval.description',
'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>
<Trans i18nKey="alerting.policies.metadata.timingOptions.groupInterval.label">
Wait <PrimaryText content={groupInterval} /> before sending updates
</Trans>
</span>
</Tooltip>
);
}
if (repeatInterval) {
metaOptions.push(
<Tooltip
placement="top"
content={t(
'alerting.policies.metadata.timingOptions.repeatInterval.description',
'How often notifications are sent if the group of alerts has not changed since the last notification.'
)}
>
<span>
<Trans i18nKey="alerting.policies.metadata.timingOptions.repeatInterval.label">
Repeated every <PrimaryText content={repeatInterval} />
</Trans>
</span>
</Tooltip>
);
}
return (
<MetaText icon="hourglass" data-testid="timing-options">
{metaOptions.map((meta, index) => (
<span key={uniqueId()}>
{meta}
{index < metaOptions.length - 1 && ' · '}
</span>
))}
</MetaText>
);
};
interface ContactPointDetailsProps {
alertManagerSourceName: string;
contactPoint: string;
receivers: Receiver[];
}
// @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 (
<TextLink
href={createContactPointLink(contactPoint, alertManagerSourceName)}
color="primary"
variant="bodySmall"
inline={false}
>
{contactPoint}
</TextLink>
);
}
const integrations = details.grafana_managed_receiver_configs;
if (!integrations) {
return (
<TextLink
href={createContactPointLink(contactPoint, alertManagerSourceName)}
color="primary"
variant="bodySmall"
inline={false}
>
{contactPoint}
</TextLink>
);
}
const groupedIntegrations = groupBy(details.grafana_managed_receiver_configs, (config) => config.type);
return (
<HoverCard
arrow
placement="top"
header={
<MetaText icon="at">
<div>
<Trans i18nKey="alerting.contact-point">Contact Point</Trans>
</div>
<Text color="primary">{contactPoint}</Text>
</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>
}
>
<TextLink
href={createContactPointLink(contactPoint, alertManagerSourceName)}
color="primary"
variant="bodySmall"
inline={false}
>
{contactPoint}
</TextLink>
</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 InheritableProperties | string): 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 'repeat_interval':
return 'Repeat interval';
default:
return key;
}
};
const routePropertyToValue = (
key: keyof InheritableProperties | string,
value: string | string[]
): NonNullable<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 (
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.policies.metadata.grouping.none">Not grouping</Trans>
</Text>
);
}
if (isSingleGroup) {
return (
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.policies.metadata.grouping.single-group">Single group</Trans>
</Text>
);
}
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)}`,
fontSize: theme.typography.bodySmall.fontSize,
border: `solid 1px ${borderColor}`,
borderRadius: theme.shape.radius.default,
}),
};
},
childPolicies: css({
marginLeft: theme.spacing(4),
position: 'relative',
'&:before': {
content: '""',
position: 'absolute',
height: 'calc(100% - 10px)',
borderLeft: `solid 1px ${theme.colors.border.weak}`,
marginTop: 0,
marginLeft: '-20px',
},
}),
policyItemWrapper: css({
padding: theme.spacing(1.5),
}),
metadataRow: css({
borderBottomLeftRadius: theme.shape.borderRadius(2),
borderBottomRightRadius: theme.shape.borderRadius(2),
}),
policyWrapper: (hasFocus = false) =>
css({
flex: 1,
position: 'relative',
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
border: `solid 1px ${theme.colors.border.weak}`,
...(hasFocus && {
borderColor: theme.colors.primary.border,
background: theme.colors.primary.transparent,
}),
}),
metadata: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.bodySmall.fontWeight,
}),
break: css({
width: '100%',
height: 0,
marginBottom: 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',
textAlign: 'center',
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
padding: 0,
}),
moreButtons: css({
marginTop: theme.spacing(0.5),
marginBottom: theme.spacing(1.5),
}),
});
export { Policy };