WIP: create modal

This commit is contained in:
Matt Jacobson
2026-01-07 12:00:49 -05:00
parent b833a10a0c
commit 589b359fef
6 changed files with 193 additions and 33 deletions
@@ -38,13 +38,14 @@ import { withPageErrorBoundary } from './withPageErrorBoundary';
import { AlertmanagerGroup, Receiver, Route, ROUTES_META_SYMBOL } from '../../../plugins/datasource/alertmanager/types';
import { stringifyErrorLike } from './utils/misc';
import {
useCreateRoutingTree,
useDeleteRoutingTree,
useListNotificationPolicyRoutes,
useRootRouteSearch,
} from './components/notification-policies/useNotificationPolicyRoute';
import { useURLSearchParams } from './hooks/useURLSearchParams';
import { usePagination } from './hooks/usePagination';
import { useDeleteRoutingTreeModal } from './components/notification-policies/components/Modals';
import { useCreateRoutingTreeModal, useDeleteRoutingTreeModal } from './components/notification-policies/components/Modals';
import { ALL_ROUTING_TREES, useExportRoutingTree } from './components/notification-policies/useExportRoutingTree';
import { K8sAnnotations, ROOT_ROUTE_NAME } from './utils/k8s/constants';
import ConditionalWrap from './components/ConditionalWrap';
@@ -52,10 +53,11 @@ import { ProvisioningBadge } from './components/Provisioning';
import { getAnnotation } from './utils/k8s/utils';
import { Spacer } from './components/Spacer';
import MoreButton from './components/MoreButton';
import { useContactPointsWithStatus } from './components/contact-points/useContactPoints';
import { useGrafanaContactPoints } from './components/contact-points/useContactPoints';
import { useAlertGroupsModal } from './components/notification-policies/Modals';
import { normalizeMatchers } from './utils/matchers';
import { RoutingTreeFilter } from './components/notification-policies/components/RoutingTreeFilter';
import {config} from '@grafana/runtime';
enum ActiveTab {
NotificationPolicies = 'notification_policies',
@@ -126,7 +128,9 @@ const NotificationPoliciesTabs = () => {
const PolicyTreeTab = () => {
const { isGrafanaAlertmanager } = useAlertmanager();
if (!isGrafanaAlertmanager) {
const useMultiplePoliciesView = config.featureToggles.alertingMultiplePolicies;
if (!isGrafanaAlertmanager || !useMultiplePoliciesView) {
return <NotificationPoliciesTree />;
}
@@ -138,7 +142,6 @@ const PolicyTreeTab = () => {
const DEFAULT_PAGE_SIZE = 10;
const PolicyTreeTabContents = () => {
const { selectedAlertmanager } = useAlertmanager();
const [queryParams] = useURLSearchParams();
const [[createPoliciesSupported, createPoliciesAllowed], [exportPoliciesSupported, exportPoliciesAllowed]] =
@@ -151,18 +154,21 @@ const PolicyTreeTabContents = () => {
currentData: allPolicies,
isLoading,
error: fetchPoliciesError,
} = useListNotificationPolicyRoutes({ alertmanager: selectedAlertmanager ?? '' });
} = useListNotificationPolicyRoutes();
const [ExportDrawer, showExportDrawer] = useExportRoutingTree();
const [createTrigger] = useCreateRoutingTree();
const [CreateModal, showCreateModal] = useCreateRoutingTreeModal(createTrigger.execute);
const search = queryParams.get('search');
const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const shouldFetchContactPoints = contactPointsSupported && canSeeContactPoints;
const { contactPoints: receivers } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager ?? '',
fetchPolicies: false,
fetchStatuses: true,
const { contactPoints: receivers } = useGrafanaContactPoints({
skip: !shouldFetchContactPoints,
fetchStatuses: false,
fetchPolicies: false,
});
if (isLoading) {
@@ -177,15 +183,15 @@ const PolicyTreeTabContents = () => {
<Stack direction="row" gap={1}>
{createPoliciesSupported && (
<LinkButton
<Button
icon="plus"
aria-label={'add policy'}
variant="primary"
href="/alerting/routes/new"
disabled={!createPoliciesAllowed}
onClick={() => showCreateModal()}
>
Create policy
</LinkButton>
</Button>
)}
{exportPoliciesSupported && (
<Button
@@ -210,6 +216,7 @@ const PolicyTreeTabContents = () => {
receivers={receivers}
/>
)}
{CreateModal}
{ExportDrawer}
</>
);
@@ -218,7 +225,7 @@ const PolicyTreeTabContents = () => {
interface RoutingTreeListProps {
policies: Route[];
search?: string | null;
pageSize?: number;
pageSize: number;
receivers?: Receiver[];
}
@@ -3,7 +3,7 @@ import { Controller, useForm } from 'react-hook-form';
import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable';
import { Trans, t } from '@grafana/i18n';
import { Collapse, Field, Link, MultiSelect, useStyles2 } from '@grafana/ui';
import { Collapse, Field, Input, Link, MultiSelect, useStyles2 } from '@grafana/ui';
import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
@@ -30,9 +30,10 @@ export interface AmRootRouteFormProps {
actionButtons: ReactNode;
onSubmit: (route: Partial<FormAmRoute>) => void;
route: RouteWithID;
showNameField?: boolean;
}
export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route }: AmRootRouteFormProps) => {
export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route, showNameField }: AmRootRouteFormProps) => {
const styles = useStyles2(getFormStyles);
const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false);
const { isGrafanaAlertmanager } = useAlertmanager();
@@ -55,6 +56,30 @@ export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmi
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{showNameField && (<Field
required
label={t('alerting.am-root-route-form.name', 'Name')}
description={t(
'alerting.am-root-route-form.description-unique-route',
'A unique name for the routing tree'
)}
invalid={!!errors.name}
error={errors.name?.message}
>
<Input
{...register('name', {
required: true,
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Name is required';
}
return true
},
})}
className={styles.input}
data-testid="routing-tree-name"
/>
</Field>)}
<Field
label={t('alerting.am-root-route-form.label-default-contact-point', 'Default contact point')}
invalid={Boolean(errors.receiver) ? true : undefined}
@@ -298,10 +298,10 @@ const useAlertGroupsModal = (
return [modalElement, handleShow, handleDismiss];
};
const UpdatingModal: FC<Pick<ModalProps, 'isOpen'>> = ({ isOpen }) => (
export const UpdatingModal: FC<Pick<ModalProps, 'isOpen' | 'onDismiss'>> = ({ isOpen, onDismiss = () => {}} ) => (
<Modal
isOpen={isOpen}
onDismiss={() => {}}
onDismiss={onDismiss}
closeOnBackdropClick={false}
closeOnEscape={false}
ariaLabel={t('alerting.policies.update.updating', 'Updating...')}
@@ -694,9 +694,9 @@ const AllMatchesIndicator: FC = () => {
);
};
type DefaultPolicyIndicatorProps = { route: RouteWithID };
type DefaultPolicyIndicatorProps = { route?: RouteWithID };
const DefaultPolicyIndicator: FC<DefaultPolicyIndicatorProps> = ({route}) => {
export const DefaultPolicyIndicator: FC<DefaultPolicyIndicatorProps> = ({route={}}) => {
const styles = useStyles2(getStyles);
return (
<>
@@ -1,9 +1,16 @@
import { useCallback, useMemo, useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Button, Modal, ModalProps } from '@grafana/ui';
import { Button, Modal, ModalProps, useStyles2 } from '@grafana/ui';
import { stringifyErrorLike } from '../../../utils/misc';
import { FormAmRoute } from '../../../types/amroutes';
import { AmRootRouteForm } from '../EditDefaultPolicyForm';
import { UpdatingModal } from '../Modals';
import { getFormStyles } from '../formStyles';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { RouteWithID } from '../../../../../../plugins/datasource/alertmanager/types';
import { defaultGroupBy } from '../../../utils/amroutes';
/**
* This hook controls the delete modal for routing trees, showing loading and error states when appropriate
@@ -56,18 +63,12 @@ export const useDeleteRoutingTreeModal = (
closeOnEscape={!isLoading}
title={'Delete routing tree'}
>
<p>
Deleting this routing tree will permanently remove it.
</p>
<p>
Are you sure you want to delete this routing tree?
</p>
<p>Deleting this routing tree will permanently remove it.</p>
<p>Are you sure you want to delete this routing tree?</p>
<Modal.ButtonRow>
<Button type="button" variant="destructive" onClick={handleSubmit} disabled={isLoading}>
{isLoading
? "Deleting..."
: "Yes, delete routing tree"}
{isLoading ? 'Deleting...' : 'Yes, delete routing tree'}
</Button>
<Button type="button" variant="secondary" onClick={handleDismiss} disabled={isLoading}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
@@ -80,6 +81,92 @@ export const useDeleteRoutingTreeModal = (
return [modalElement, handleShow, handleDismiss] as const;
};
const emptyRouteWithID = {
id: '',
name: '',
group_by: defaultGroupBy,
}
/**
* This hook controls the create modal for routing trees, showing loading and error states when appropriate
*/
export const useCreateRoutingTreeModal = (handleCreate: (route: Partial<FormAmRoute>) => Promise<unknown>) => {
const styles = useStyles2(getFormStyles);
const [showModal, setShowModal] = useState(false);
const [route, setRoute] = useState<RouteWithID>(emptyRouteWithID);
const [error, setError] = useState<unknown | undefined>();
const [isLoading, setIsLoading] = useState(false);
const handleDismiss = useCallback(() => {
if (isLoading) {
return;
}
setShowModal(false);
setError(undefined);
setRoute(emptyRouteWithID);
}, [isLoading]);
const handleShow = useCallback(() => {
setShowModal(true);
setError(undefined);
setRoute(emptyRouteWithID);
}, []);
const handleSubmit = useCallback(
(newRoute: Partial<FormAmRoute>) => {
if (newRoute) {
setIsLoading(true);
handleCreate(newRoute)
.then(() => setShowModal(false))
.catch(setError)
.finally(() => {
setIsLoading(false);
});
}
},
[handleCreate]
);
const modalElement = useMemo(() => {
if (error) {
return <ErrorModal isOpen={showModal} onDismiss={handleDismiss} error={error} />;
}
if (isLoading) {
return <UpdatingModal isOpen={showModal} onDismiss={handleDismiss} />;
}
return (
<Modal
isOpen={showModal}
onDismiss={handleDismiss}
closeOnBackdropClick={true}
closeOnEscape={true}
title={'Create routing tree'}
>
<AmRootRouteForm
route={route}
showNameField={true}
onSubmit={handleSubmit}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
actionButtons={
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</Button>
<Button type="submit">
<Trans i18nKey="alerting.policies.save-policy">Add routing tree</Trans>
</Button>
</Modal.ButtonRow>
}
/>
</Modal>
);
}, [route, error, handleSubmit, handleDismiss, isLoading, showModal, styles.input]);
return [modalElement, handleShow, handleDismiss] as const;
};
interface ErrorModalProps extends Pick<ModalProps, 'isOpen' | 'onDismiss'> {
error: unknown;
}
@@ -35,6 +35,7 @@ import uFuzzy from '@leeoniya/ufuzzy';
import { useMemo } from 'react';
const {
useCreateNamespacedRoutingTreeMutation,
useDeleteNamespacedRoutingTreeMutation,
useListNamespacedRoutingTreeQuery,
useReplaceNamespacedRoutingTreeMutation,
@@ -83,13 +84,11 @@ export const useNotificationPolicyRoute = (
return k8sApiSupported ? k8sRouteQuery : amConfigQuery;
};
export const useListNotificationPolicyRoutes = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
const k8sApiSupported = shouldUseK8sApi(alertmanager);
export const useListNotificationPolicyRoutes = ({ skip }: Skippable = {}) => {
return useListNamespacedRoutingTreeQuery(
{ namespace: getAPINamespace() },
{
skip: skip || !k8sApiSupported,
skip: skip,
selectFromResult: (result) => {
return {
...result,
@@ -267,6 +266,48 @@ export function useDeleteRoutingTree() {
});
}
export function useCreateRoutingTree() {
const [createNamespacedRoutingTree] = useCreateNamespacedRoutingTreeMutation();
return useAsync(async (partialFormRoute: Partial<FormAmRoute>) => {
const namespace = getAPINamespace();
const {
name,
overrideGrouping,
groupBy,
overrideTimings,
groupWaitValue,
groupIntervalValue,
repeatIntervalValue,
receiver,
} = partialFormRoute;
// This does not "inherit" from any existing route, as this is a new routing tree. If not set, it will use the system
// defaults. Currently supported by group_by, group_wait, group_interval, and repeat_interval
const USE_DEFAULTS = undefined;
const newRoute: Route = {
name: name,
group_by: overrideGrouping ? groupBy : USE_DEFAULTS,
group_wait: overrideTimings && groupWaitValue ? groupWaitValue : USE_DEFAULTS,
group_interval: overrideTimings && groupIntervalValue ? groupIntervalValue : USE_DEFAULTS,
repeat_interval: overrideTimings && repeatIntervalValue ? repeatIntervalValue : USE_DEFAULTS,
receiver: receiver,
};
// defaults(newRoute, TIMING_OPTIONS_DEFAULTS)
// Create the K8s route object
const routeObject = createKubernetesRoutingTreeSpec(newRoute);
return createNamespacedRoutingTree({
namespace,
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
}).unwrap();
});
}
const fuzzyFinder = new uFuzzy({
intraMode: 1,
intraIns: 1,