Alerting: Visualize autogenerated policy tree for simplified routing. (#79509)

* WIP

* WIP: disable some actions when is autogenerated policy

* WIP

* Wip: add checks for group by in auto-generated policy

* Make autogenerated policy readOnly and enable Readonly modal for it

* Use real check for autogenerated root

* Fix test

* Refactor: rename consts

* Add test for policy form being read only

* Add tests

* Update some code comments

* Fix Switch component not being styled as disabled

* Rename isAutogeneratedChunkOpen property to isBranchOpen and fix test

* Revert fix for Switch as it has moved to another separate PR

* Split Policy component in smaller sub components

* use useAlertmanagerAbility form for checking autogenerated tree visibility and fix container for autogenerated policy being rendered when it's not supported

* Update useAbilities test and dont use toAbility for ViewAutogeneratedPolicyTree

* Fix Policy being unmounted every 10 secs and move the collapsed/expanded state to each Policy component

* remove permissions from createDropdownMenuActions method parameters and convert the method to a hook

* Revert using PolicyItem

* Add test for createDropdownMenuActions

* Revert having a read only view form for the policy

* Remove readonly from default policy form

* Only show collapsible when node has children

* Split DefaultPolicyIndicator

* use hidehideCurrentPolicy instead of showCurrentPolicy

* Address some review suggestions
This commit is contained in:
Sonia Aguilar 2024-01-15 13:36:26 +01:00 committed by GitHub
parent 9f0bb9cb07
commit 10f0d094ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 588 additions and 315 deletions

View File

@ -1659,15 +1659,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/notification-policies/Policy.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

View File

@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { Alert, LoadingPlaceholder, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary, Stack } from '@grafana/ui';
import { Alert, LoadingPlaceholder, Stack, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
@ -16,12 +16,12 @@ import { useGetContactPointsState } from './api/receiversApi';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
import { findRoutesMatchingPredicate, NotificationPoliciesFilter } from './components/notification-policies/Filters';
import { NotificationPoliciesFilter, findRoutesMatchingPredicate } from './components/notification-policies/Filters';
import {
useAddPolicyModal,
useEditPolicyModal,
useDeletePolicyModal,
useAlertGroupsModal,
useDeletePolicyModal,
useEditPolicyModal,
} from './components/notification-policies/Modals';
import { Policy } from './components/notification-policies/Policy';
import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig';
@ -249,6 +249,7 @@ const AmRoutes = () => {
onShowAlertInstances={showAlertGroupsModal}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={{ groupsMap: routeAlertGroupsMap, enabled: !instancesPreviewError }}
isAutoGenerated={false}
/>
)}
</Stack>

View File

@ -322,6 +322,7 @@ const getStyles = (theme: GrafanaTheme2) => {
`,
noMatchersWarning: css`
padding: ${theme.spacing(1)} ${theme.spacing(2)};
margin-bottom: ${theme.spacing(1)};
`,
};
};

View File

@ -1,10 +1,10 @@
import { render, screen, within } from '@testing-library/react';
import { render, renderHook, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash';
import React from 'react';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import {
AlertmanagerGroup,
@ -19,7 +19,12 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../..
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { Policy } from './Policy';
import {
AUTOGENERATED_ROOT_LABEL_NAME,
Policy,
isAutoGeneratedRootAndSimplifiedEnabled,
useCreateDropdownMenuActions,
} from './Policy';
jest.mock('../../hooks/useAbilities', () => ({
...jest.requireActual('../../hooks/useAbilities'),
@ -163,6 +168,7 @@ describe('Policy', () => {
onAddPolicy={onAddPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
isAutoGenerated={false}
/>
);
// should have default policy
@ -379,3 +385,104 @@ const mockRoutes: RouteWithID = {
group_interval: undefined,
repeat_interval: undefined,
};
describe('isAutoGeneratedRootAndSimplifiedEnabled', () => {
it('returns false when simplified routing is not enabled', () => {
const route: RouteWithID = {
id: '1',
object_matchers: [['label', MatcherOperator.equal, 'true']],
};
config.featureToggles.alertingSimplifiedRouting = false;
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
});
it('returns false when object_matchers is not defined', () => {
const route: RouteWithID = {
id: '1',
};
config.featureToggles.alertingSimplifiedRouting = true;
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
});
it('returns true when object_matchers contains AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => {
const route: RouteWithID = {
id: '1',
object_matchers: [[AUTOGENERATED_ROOT_LABEL_NAME, MatcherOperator.equal, 'true']],
};
config.featureToggles.alertingSimplifiedRouting = true;
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(true);
});
it('returns false when object_matchers does not contain AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => {
const route: RouteWithID = {
id: '1',
object_matchers: [['label', MatcherOperator.equal, 'true']],
};
config.featureToggles.alertingSimplifiedRouting = true;
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
});
});
describe('useCreateDropdownMenuActions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const openDetailModal = jest.fn();
const currentRoute: RouteWithID = { id: '0', routes: [{ id: '1' }] };
const toggleShowExportDrawer = jest.fn();
const onDeletePolicy = jest.fn();
const testCases = [
{
isAutoGenerated: false,
isDefaultPolicy: true,
provisioned: false,
expectedMenu: ['edit-policy', 'export-policy'],
},
{
isAutoGenerated: false,
isDefaultPolicy: true,
provisioned: true,
expectedMenu: ['edit-policy', 'export-policy'],
},
{
isAutoGenerated: false,
isDefaultPolicy: false,
provisioned: false,
expectedMenu: ['edit-policy', 'delete-policy'],
},
{
isAutoGenerated: false,
isDefaultPolicy: false,
provisioned: true,
expectedMenu: ['edit-policy', 'delete-policy'],
},
{ isAutoGenerated: true, isDefaultPolicy: true, provisioned: true, expectedMenu: ['edit-policy'] },
{ isAutoGenerated: true, isDefaultPolicy: false, provisioned: false, expectedMenu: ['edit-policy'] },
{ isAutoGenerated: true, isDefaultPolicy: true, provisioned: false, expectedMenu: ['edit-policy'] },
{ isAutoGenerated: true, isDefaultPolicy: false, provisioned: true, expectedMenu: ['edit-policy'] },
];
testCases.forEach(({ isAutoGenerated, isDefaultPolicy, provisioned, expectedMenu }) => {
it(`Having all the permissions returns ${expectedMenu.length} menu items for isAutoGenerated=${isAutoGenerated}, isDefaultPolicy=${isDefaultPolicy}, provisioned=${provisioned}`, () => {
useAlertmanagerAbilitiesMock.mockReturnValue([
[true, true],
[true, true],
[true, true],
]);
const { result } = renderHook(() =>
useCreateDropdownMenuActions(
isAutoGenerated,
isDefaultPolicy,
provisioned,
openDetailModal,
currentRoute,
toggleShowExportDrawer,
onDeletePolicy
)
);
const menuItemsKeys = result.current.map((item) => item.key ?? '');
expect(menuItemsKeys).toEqual(expectedMenu);
});
});
});

View File

@ -6,11 +6,13 @@ import { Link } from 'react-router-dom';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Badge,
Button,
Dropdown,
Icon,
IconButton,
Menu,
Stack,
Text,
@ -19,14 +21,20 @@ import {
useStyles2,
} from '@grafana/ui';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import {
AlertmanagerGroup,
MatcherOperator,
ObjectMatcher,
Receiver,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities';
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies';
import { InhertitableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { Authorize } from '../Authorize';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
@ -37,7 +45,7 @@ import { Strong } from '../Strong';
import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter';
import { Matchers } from './Matchers';
import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions';
import { TIMING_OPTIONS_DEFAULTS, TimingOptions } from './timingOptions';
interface PolicyComponentProps {
receivers?: Receiver[];
@ -54,55 +62,42 @@ interface PolicyComponentProps {
routeTree: RouteWithID;
currentRoute: RouteWithID;
alertManagerSourceName: string;
onEditPolicy: (route: RouteWithID, isDefault?: boolean) => void;
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
onAddPolicy: (route: RouteWithID) => void;
onDeletePolicy: (route: RouteWithID) => void;
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
isAutoGenerated?: boolean;
}
const Policy: FC<PolicyComponentProps> = ({
receivers = [],
contactPointsState,
readOnly = false,
provisioned = false,
alertGroups = [],
alertManagerSourceName,
currentRoute,
routeTree,
inheritedProperties,
routesMatchingFilters = [],
matchingInstancesPreview = { enabled: false },
onEditPolicy,
onAddPolicy,
onDeletePolicy,
onShowAlertInstances,
}) => {
const styles = useStyles2(getStyles);
const isDefaultPolicy = currentRoute === routeTree;
const Policy = (props: PolicyComponentProps) => {
const {
receivers = [],
contactPointsState,
readOnly = false,
provisioned = false,
alertGroups = [],
alertManagerSourceName,
currentRoute,
routeTree,
inheritedProperties,
routesMatchingFilters = [],
matchingInstancesPreview = { enabled: false },
onEditPolicy,
onAddPolicy,
onDeletePolicy,
onShowAlertInstances,
isAutoGenerated = false,
} = props;
const [
[updatePoliciesSupported, updatePoliciesAllowed],
[deletePolicySupported, deletePolicyAllowed],
[exportPoliciesSupported, exportPoliciesAllowed],
] = useAlertmanagerAbilities([
AlertmanagerAction.UpdateNotificationPolicyTree,
AlertmanagerAction.DeleteNotificationPolicy,
AlertmanagerAction.ExportNotificationPolicies,
]);
const styles = useStyles2(getStyles);
const isDefaultPolicy = currentRoute === routeTree;
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
@ -116,33 +111,340 @@ const Policy: FC<PolicyComponentProps> = ({
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 [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;
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy;
const showEditAction = updatePoliciesSupported && updatePoliciesAllowed;
const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy;
// simplified routing permissions
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
AlertmanagerAction.ViewAutogeneratedPolicyTree
);
// collapsible policies variables
const isThisPolicyCollapsible = useShouldPolicyBeCollapsible(currentRoute);
const [isBranchOpen, toggleBranchOpen] = useToggle(false);
const renderChildPolicies = (isThisPolicyCollapsible && isBranchOpen) || !isThisPolicyCollapsible;
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,
};
contactPointErrors.forEach((error) => {
errors.push(error);
});
const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute);
// build the menu actions for our policy
const dropdownMenuActions: JSX.Element[] = [];
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);
if (hideCurrentPolicy) {
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
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}>
{isThisPolicyCollapsible && (
<IconButton
name={isBranchOpen ? 'angle-down' : 'angle-right'}
onClick={toggleBranchOpen}
aria-label={isBranchOpen ? 'Collapse' : 'Expand'}
/>
)}
{isImmutablePolicy ? (
isAutogeneratedPolicyRoot ? (
<AutogeneratedRootIndicator />
) : (
<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} />}
{provisioned && <ProvisioningBadge />}
<Stack direction="row" gap={0.5}>
{!isAutoGenerated && !readOnly && (
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Button
variant="secondary"
icon="plus"
size="sm"
onClick={() => onAddPolicy(currentRoute)}
disabled={provisioned}
type="button"
>
New nested policy
</Button>
</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}
groupBy={groupBy}
muteTimings={muteTimings}
timingOptions={timingOptions}
inheritedProperties={inheritedProperties}
alertManagerSourceName={alertManagerSourceName}
receivers={receivers}
matchingAlertGroups={matchingAlertGroups}
matchers={matchers}
isDefaultPolicy={isDefaultPolicy}
onShowAlertInstances={onShowAlertInstances}
/>
</Stack>
</div>
</div>
<div className={styles.childPolicies}>
{renderChildPolicies && (
<>
{childPolicies.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}
/>
);
})}
</>
)}
</div>
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
</Stack>
</>
);
};
/**
* This function returns if the policy should be collapsible or not.
* Add here conditions for policies that should be collapsible.
*/
function useShouldPolicyBeCollapsible(route: RouteWithID): boolean {
const childrenCount = route.routes?.length ?? 0;
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
AlertmanagerAction.ViewAutogeneratedPolicyTree
);
return (
childrenCount > 0 &&
isSupportedToSeeAutogeneratedChunk &&
isAllowedToSeeAutogeneratedChunk &&
isAutoGeneratedRootAndSimplifiedEnabled(route)
);
}
interface MetadataRowProps {
matchingInstancesPreview: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
numberOfAlertInstances?: number;
contactPoint?: string;
groupBy?: string[];
muteTimings?: string[];
timingOptions?: TimingOptions;
inheritedProperties?: Partial<InhertitableProperties>;
alertManagerSourceName: string;
receivers: Receiver[];
matchingAlertGroups?: AlertmanagerGroup[];
matchers?: ObjectMatcher[];
isDefaultPolicy: boolean;
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
}
function MetadataRow({
numberOfAlertInstances,
isDefaultPolicy,
timingOptions,
groupBy,
muteTimings = [],
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);
return (
<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 && (
// 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>Inherited</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: (nextValue?: any) => 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(
@ -150,7 +452,7 @@ const Policy: FC<PolicyComponentProps> = ({
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
icon="edit"
disabled={provisioned}
disabled={provisioned || isAutoGenerated}
label="Edit"
onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)}
/>
@ -173,7 +475,7 @@ const Policy: FC<PolicyComponentProps> = ({
<Menu.Item
destructive
icon="trash-alt"
disabled={provisioned}
disabled={provisioned || isAutoGenerated}
label="Delete"
onClick={() => onDeletePolicy(currentRoute)}
/>
@ -181,167 +483,31 @@ const Policy: FC<PolicyComponentProps> = ({
</Fragment>
);
}
// 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>
<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} />}
{provisioned && <ProvisioningBadge />}
{!readOnly && (
<Stack direction="row" gap={0.5}>
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Button
variant="secondary"
icon="plus"
size="sm"
onClick={() => onAddPolicy(currentRoute)}
disabled={provisioned}
type="button"
>
New nested policy
</Button>
</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 */}
<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 && (
// 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>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 || provisioned}
inheritedProperties={childInheritedProperties}
onAddPolicy={onAddPolicy}
onEditPolicy={onEditPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
alertManagerSourceName={alertManagerSourceName}
alertGroups={alertGroups}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={matchingInstancesPreview}
/>
);
})}
</div>
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
</Stack>
);
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>
@ -388,7 +554,7 @@ const AllMatchesIndicator: FC = () => {
);
};
const DefaultPolicyIndicator: FC = () => {
function DefaultPolicyIndicator() {
const styles = useStyles2(getStyles);
return (
<>
@ -398,7 +564,11 @@ const DefaultPolicyIndicator: FC = () => {
</span>
</>
);
};
}
function AutogeneratedRootIndicator() {
return <strong> Auto-generated policies</strong>;
}
const InheritedProperties: FC<{ properties: InhertitableProperties }> = ({ properties }) => (
<HoverCard
@ -627,84 +797,69 @@ const getStyles = (theme: GrafanaTheme2) => ({
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.radius.default};
`,
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`
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(2)};
border-bottom-right-radius: ${theme.shape.borderRadius(2)};
`,
policyWrapper: (hasFocus = false) => css`
flex: 1;
position: relative;
background: ${theme.colors.background.secondary};
border-radius: ${theme.shape.radius.default};
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.radius.default};
padding: 0;
`,
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({
background: theme.colors.background.secondary,
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 }),
}),
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,
}),
});
export { Policy };

View File

@ -78,6 +78,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a
true,
false,
],
"view-autogenerated-policy-tree": [
false,
false,
],
"view-contact-point": [
true,
false,
@ -183,6 +187,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi
true,
true,
],
"view-autogenerated-policy-tree": [
false,
false,
],
"view-contact-point": [
true,
true,
@ -288,6 +296,10 @@ exports[`alertmanager abilities should report everything is supported for builti
true,
false,
],
"view-autogenerated-policy-tree": [
true,
false,
],
"view-contact-point": [
true,
true,

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { contextSrv as ctx } from 'app/core/services/context_srv';
import { contextSrv, contextSrv as ctx } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
@ -45,6 +45,7 @@ export enum AlertmanagerAction {
UpdateNotificationPolicyTree = 'update-notification-policy-tree',
DeleteNotificationPolicy = 'delete-notification-policy',
ExportNotificationPolicies = 'export-notification-policies',
ViewAutogeneratedPolicyTree = 'view-autogenerated-policy-tree',
// silences these cannot be deleted only "expired" (updated)
CreateSilence = 'create-silence',
@ -190,6 +191,9 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!);
const instancePermissions = getInstancesPermissions(selectedAlertmanager!);
//we need to know user role to determine if they can view autogenerated policy tree
const isAdmin = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
// list out all of the abilities, and if the user has permissions to perform them
const abilities: Abilities<AlertmanagerAction> = {
// -- configuration --
@ -226,6 +230,7 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
isGrafanaFlavoredAlertmanager,
notificationsPermissions.provisioning.readSecrets
),
[AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin],
// -- silences --
// for now, all supported Alertmanager flavors have API endpoints for managing silences
[AlertmanagerAction.CreateSilence]: toAbility(AlwaysSupported, instancePermissions.create),