Alerting: Use Alerting package for contact point selection (#104772)
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { Combobox } from '@grafana/ui';
|
||||
|
||||
interface ClearableProps<T> {
|
||||
isClearable: true;
|
||||
onChange: (option: T | null) => void;
|
||||
}
|
||||
|
||||
interface NotClearableProps<T> {
|
||||
isClearable?: false;
|
||||
onChange: (option: T) => void;
|
||||
}
|
||||
|
||||
type ComboboxClearableProps<T> = NotClearableProps<T> | ClearableProps<T>;
|
||||
|
||||
type AutoSizeConditionals =
|
||||
| {
|
||||
width: 'auto';
|
||||
minWidth: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
| {
|
||||
width?: number;
|
||||
minWidth?: never;
|
||||
maxWidth?: never;
|
||||
};
|
||||
|
||||
export type CustomComboBoxProps<T> = Omit<ComponentProps<typeof Combobox<string>>, 'options' | 'loading' | 'onChange'> &
|
||||
ComboboxClearableProps<T> &
|
||||
AutoSizeConditionals;
|
||||
+16
-9
@@ -6,17 +6,17 @@ import type { ContactPoint } from '../../../api/v0alpha1/types';
|
||||
import { useListContactPointsv0alpha1 } from '../../hooks/useContactPoints';
|
||||
import { getContactPointDescription } from '../../utils';
|
||||
|
||||
import { CustomComboBoxProps } from './ComboBox.types';
|
||||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'accent' });
|
||||
|
||||
type ContactPointSelectorProps = {
|
||||
onChange: (contactPoint: ContactPoint) => void;
|
||||
};
|
||||
export type ContactPointSelectorProps = CustomComboBoxProps<ContactPoint>;
|
||||
|
||||
/**
|
||||
* Contact Point Combobox which lists all available contact points
|
||||
* @TODO make ComboBox accept a ReactNode so we can use icons and such
|
||||
*/
|
||||
function ContactPointSelector({ onChange }: ContactPointSelectorProps) {
|
||||
function ContactPointSelector(props: ContactPointSelectorProps) {
|
||||
const { currentData: contactPoints, isLoading } = useListContactPointsv0alpha1();
|
||||
|
||||
// Create a mapping of options with their corresponding contact points
|
||||
@@ -35,16 +35,23 @@ function ContactPointSelector({ onChange }: ContactPointSelectorProps) {
|
||||
|
||||
const options = contactPointOptions.map<ComboboxOption>((item) => item.option);
|
||||
|
||||
const handleChange = ({ value }: ComboboxOption<string>) => {
|
||||
const selectedItem = contactPointOptions.find(({ option }) => option.value === value);
|
||||
if (!selectedItem) {
|
||||
const handleChange = (selectedOption: ComboboxOption<string> | null) => {
|
||||
if (selectedOption == null && props.isClearable) {
|
||||
props.onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(selectedItem.contactPoint);
|
||||
if (selectedOption) {
|
||||
const matchedOption = contactPointOptions.find(({ option }) => option.value === selectedOption.value);
|
||||
if (!matchedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(matchedOption.contactPoint);
|
||||
}
|
||||
};
|
||||
|
||||
return <Combobox loading={isLoading} onChange={handleChange} options={options} />;
|
||||
return <Combobox {...props} loading={isLoading} options={options} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export { ContactPointSelector };
|
||||
|
||||
@@ -140,6 +140,22 @@ const getRootRoute = async () => {
|
||||
};
|
||||
|
||||
describe('NotificationPolicies', () => {
|
||||
// combobox hack :/
|
||||
beforeAll(() => {
|
||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||
width: 120,
|
||||
height: 120,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}));
|
||||
|
||||
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
|
||||
value: mockGetBoundingClientRect,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
grantUserPermissions([
|
||||
@@ -202,11 +218,9 @@ describe('NotificationPolicies', () => {
|
||||
|
||||
await openDefaultPolicyEditModal();
|
||||
|
||||
// configure receiver & group by
|
||||
const receiverSelect = await ui.receiverSelect.find();
|
||||
|
||||
// The contact points are fetched from the k8s API, which we aren't overriding here
|
||||
// when we use a different
|
||||
const receiverSelect = ui.receiverSelect.get();
|
||||
await clickSelectOption(receiverSelect, 'lotsa-emails');
|
||||
|
||||
const groupSelect = ui.groupSelect.get();
|
||||
|
||||
+13
-73
@@ -1,9 +1,8 @@
|
||||
import { css, cx, keyframes } from '@emotion/css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert, IconButton, Select, SelectCommonProps, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Select, SelectCommonProps, Text } from '@grafana/ui';
|
||||
import { ContactPointReceiverSummary } from 'app/features/alerting/unified/components/contact-points/ContactPoint';
|
||||
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
|
||||
@@ -12,30 +11,22 @@ import { ContactPointWithMetadata } from '../contact-points/utils';
|
||||
|
||||
const MAX_CONTACT_POINTS_RENDERED = 500;
|
||||
|
||||
// Mock sleep method, as fetching receivers is very fast and may seem like it hasn't occurred
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const LOADING_SPINNER_DURATION = 1000;
|
||||
|
||||
type ContactPointSelectorProps = {
|
||||
selectProps: SelectCommonProps<ContactPointWithMetadata>;
|
||||
showRefreshButton?: boolean;
|
||||
/** Name of a contact point to optionally find and set as the preset value on the dropdown */
|
||||
selectedContactPointName?: string | null;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const ContactPointSelector = ({
|
||||
export const ExternalAlertmanagerContactPointSelector = ({
|
||||
selectProps,
|
||||
showRefreshButton,
|
||||
selectedContactPointName,
|
||||
onError = () => {},
|
||||
}: ContactPointSelectorProps) => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { contactPoints, isLoading, error, refetch } = useContactPointsWithStatus({
|
||||
const { contactPoints, isLoading, error } = useContactPointsWithStatus({
|
||||
alertmanager: selectedAlertmanager!,
|
||||
});
|
||||
const [loaderSpinning, setLoaderSpinning] = useState(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const options: Array<SelectableValue<ContactPointWithMetadata>> = contactPoints.map((contactPoint) => {
|
||||
return {
|
||||
@@ -53,14 +44,6 @@ export const ContactPointSelector = ({
|
||||
return options.find((option) => option.value?.name === selectedContactPointName) || null;
|
||||
}, [options, selectedContactPointName]);
|
||||
|
||||
// force some minimum wait period for fetching contact points
|
||||
const onClickRefresh = () => {
|
||||
setLoaderSpinning(true);
|
||||
Promise.all([refetch(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
|
||||
setLoaderSpinning(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// If the contact points are fetched successfully and the selected contact point is not in the list, show an error
|
||||
if (!isLoading && selectedContactPointName && !matchedContactPoint) {
|
||||
@@ -82,56 +65,13 @@ export const ContactPointSelector = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Select
|
||||
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
|
||||
options={options}
|
||||
value={matchedContactPoint}
|
||||
{...selectProps}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{showRefreshButton && (
|
||||
<IconButton
|
||||
name="sync"
|
||||
onClick={onClickRefresh}
|
||||
aria-label={t('alerting.contact-point-selector.aria-label-refresh-contact-points', 'Refresh contact points')}
|
||||
tooltip={t(
|
||||
'alerting.contact-point-selector.tooltip-refresh-contact-points-list',
|
||||
'Refresh contact points list'
|
||||
)}
|
||||
className={cx(styles.refreshButton, {
|
||||
[styles.loading]: loaderSpinning || isLoading,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Select
|
||||
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
|
||||
options={options}
|
||||
value={matchedContactPoint}
|
||||
{...selectProps}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const rotation = keyframes({
|
||||
from: {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
to: {
|
||||
transform: 'rotate(720deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
refreshButton: css({
|
||||
color: theme.colors.text.secondary,
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
loading: css({
|
||||
pointerEvents: 'none',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
animation: `${rotation} 2s infinite linear`,
|
||||
},
|
||||
[theme.transitions.handleMotion('reduce')]: {
|
||||
animation: `${rotation} 6s infinite linear`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
+27
-10
@@ -1,12 +1,14 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
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 { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
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';
|
||||
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import {
|
||||
amRouteToFormAmRoute,
|
||||
@@ -33,6 +35,7 @@ export interface AmRootRouteFormProps {
|
||||
export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route }: AmRootRouteFormProps) => {
|
||||
const styles = useStyles2(getFormStyles);
|
||||
const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false);
|
||||
const { isGrafanaAlertmanager } = useAlertmanager();
|
||||
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route.group_by));
|
||||
|
||||
const defaultValues = amRouteToFormAmRoute(route);
|
||||
@@ -59,15 +62,29 @@ export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmi
|
||||
>
|
||||
<div className={styles.container} data-testid="am-receiver-select">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, value, ...field } }) => (
|
||||
<ContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
onChange: (changeValue) => handleContactPointSelect(changeValue, onChange),
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { onChange, ref, value, ...field } }) =>
|
||||
isGrafanaAlertmanager ? (
|
||||
<GrafanaManagedContactPointSelector
|
||||
onChange={(contactPoint) => {
|
||||
handleContactPointSelect(contactPoint?.spec.title, onChange);
|
||||
}}
|
||||
isClearable={false}
|
||||
value={value}
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalAlertmanagerContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
onChange: (changeValue) => handleContactPointSelect(changeValue.value?.name, onChange),
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
control={control}
|
||||
name="receiver"
|
||||
rules={{
|
||||
|
||||
+28
-13
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import MuteTimingsSelector from 'app/features/alerting/unified/components/alertmanager-entities/MuteTimingsSelector';
|
||||
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
|
||||
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
@@ -51,7 +52,7 @@ export interface AmRoutesExpandedFormProps {
|
||||
export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults }: AmRoutesExpandedFormProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const formStyles = useStyles2(getFormStyles);
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager();
|
||||
const [, canSeeMuteTimings] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval);
|
||||
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route?.group_by));
|
||||
|
||||
@@ -177,17 +178,31 @@ export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults
|
||||
|
||||
<Field label={t('alerting.am-routes-expanded-form.label-contact-point', 'Contact point')}>
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, value, ...field } }) => (
|
||||
<ContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
className: formStyles.input,
|
||||
onChange: (value) => handleContactPointSelect(value, onChange),
|
||||
isClearable: true,
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { onChange, ref, value, ...field } }) =>
|
||||
isGrafanaAlertmanager ? (
|
||||
<GrafanaManagedContactPointSelector
|
||||
onChange={(contactPoint) => {
|
||||
handleContactPointSelect(contactPoint?.spec.title, onChange);
|
||||
}}
|
||||
isClearable
|
||||
value={value}
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalAlertmanagerContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
className: formStyles.input,
|
||||
onChange: (value) => handleContactPointSelect(value.value?.name, onChange),
|
||||
isClearable: true,
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
control={control}
|
||||
name="receiver"
|
||||
/>
|
||||
|
||||
@@ -2,13 +2,14 @@ import { css } from '@emotion/css';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, Icon, Input, Label, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
|
||||
import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { matcherToObjectMatcher } from '../../utils/alertmanager';
|
||||
import {
|
||||
normalizeMatchers,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
unquoteIfRequired,
|
||||
} from '../../utils/matchers';
|
||||
|
||||
import { ExternalAlertmanagerContactPointSelector } from './ContactPointSelector';
|
||||
|
||||
interface NotificationPoliciesFilterProps {
|
||||
onChangeMatchers: (labels: ObjectMatcher[]) => void;
|
||||
onChangeReceiver: (receiver: string | undefined) => void;
|
||||
@@ -29,6 +32,7 @@ const NotificationPoliciesFilter = ({
|
||||
matchingCount,
|
||||
}: NotificationPoliciesFilterProps) => {
|
||||
const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||
const { isGrafanaAlertmanager } = useAlertmanager();
|
||||
const [searchParams, setSearchParams] = useURLSearchParams();
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams);
|
||||
@@ -106,18 +110,43 @@ const NotificationPoliciesFilter = ({
|
||||
label={t('alerting.notification-policies-filter.label-search-by-contact-point', 'Search by contact point')}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<ContactPointSelector
|
||||
selectProps={{
|
||||
id: 'receiver',
|
||||
'aria-label': 'Search by contact point',
|
||||
onChange: (option) => {
|
||||
setSearchParams({ contactPoint: option?.value?.name });
|
||||
},
|
||||
width: 28,
|
||||
isClearable: true,
|
||||
}}
|
||||
selectedContactPointName={searchParams.get('contactPoint') ?? undefined}
|
||||
/>
|
||||
{isGrafanaAlertmanager ? (
|
||||
<GrafanaManagedContactPointSelector
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
id="receiver"
|
||||
onChange={(contactPoint) => {
|
||||
// clearing the contact point will return "null"
|
||||
if (!contactPoint) {
|
||||
setSearchParams({ contactPoint: undefined });
|
||||
} else {
|
||||
setSearchParams({ contactPoint: contactPoint.spec.title });
|
||||
}
|
||||
}}
|
||||
width={28}
|
||||
isClearable
|
||||
value={searchParams.get('contactPoint') ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<ExternalAlertmanagerContactPointSelector
|
||||
selectProps={{
|
||||
id: 'receiver',
|
||||
'aria-label': 'Search by contact point',
|
||||
onChange: (option) => {
|
||||
setSearchParams({ contactPoint: option?.value?.name });
|
||||
},
|
||||
width: 28,
|
||||
isClearable: true,
|
||||
placeholder: t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
),
|
||||
}}
|
||||
selectedContactPointName={searchParams.get('contactPoint') ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{hasFilters && (
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { ContactPointWithMetadata } from 'app/features/alerting/unified/components/contact-points/utils';
|
||||
|
||||
export const handleContactPointSelect = (
|
||||
value: SelectableValue<ContactPointWithMetadata>,
|
||||
name: string | undefined | null,
|
||||
onChange: ControllerRenderProps['onChange']
|
||||
) => {
|
||||
if (value === null) {
|
||||
if (name === null) {
|
||||
return onChange(null);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
if (!name) {
|
||||
return onChange('');
|
||||
}
|
||||
|
||||
return onChange(value.value?.name);
|
||||
return onChange(name);
|
||||
};
|
||||
|
||||
+5
-29
@@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -8,11 +7,8 @@ import { CollapsableSection, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
|
||||
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
|
||||
import { ContactPointWithMetadata } from '../../../contact-points/utils';
|
||||
import { NeedHelpInfo } from '../../NeedHelpInfo';
|
||||
|
||||
import { ContactPointDetails } from './contactPoint/ContactPointDetails';
|
||||
import { ContactPointSelector } from './contactPoint/ContactPointSelector';
|
||||
import { ActiveTimingFields } from './route-settings/ActiveTimingFields';
|
||||
import { MuteTimingFields } from './route-settings/MuteTimingFields';
|
||||
@@ -27,29 +23,8 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
|
||||
|
||||
const alertManagerName = alertManager.name;
|
||||
|
||||
const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState<
|
||||
ContactPointWithMetadata | undefined
|
||||
>();
|
||||
const { watch } = useFormContext<RuleFormValues>();
|
||||
|
||||
const contactPointInForm = watch(`contactPoints.${alertManagerName}.selectedContactPoint`);
|
||||
const { contactPoints } = useContactPointsWithStatus({
|
||||
// we only fetch the contact points with metadata for the first time we render an existing alert rule
|
||||
alertmanager: alertManagerName,
|
||||
skip: Boolean(selectedContactPointWithMetadata),
|
||||
});
|
||||
const contactPointWithMetadata = contactPoints.find((cp) => cp.name === contactPointInForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (contactPointWithMetadata && !selectedContactPointWithMetadata) {
|
||||
onSelectContactPoint(contactPointWithMetadata);
|
||||
}
|
||||
}, [contactPointWithMetadata, selectedContactPointWithMetadata]);
|
||||
|
||||
const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => {
|
||||
setSelectedContactPointWithMetadata(contactPoint);
|
||||
};
|
||||
|
||||
const hasRouteSettings =
|
||||
watch(`contactPoints.${alertManagerName}.overrideGrouping`) ||
|
||||
watch(`contactPoints.${alertManagerName}.overrideTimings`) ||
|
||||
@@ -67,11 +42,12 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
|
||||
<div className={styles.secondAlertManagerLine} />
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<ContactPointSelector alertManager={alertManagerName} onSelectContactPoint={onSelectContactPoint} />
|
||||
<ContactPointSelector alertManager={alertManagerName} />
|
||||
</Stack>
|
||||
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
|
||||
<ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} />
|
||||
)}
|
||||
{/* @TODO
|
||||
we can show the contact point details here when it's selected but we currently don't have a
|
||||
way to summarize the details from the ContactPoint type in @grafana/alerting
|
||||
*/}
|
||||
<div className={styles.routingSection}>
|
||||
<CollapsableSection
|
||||
label={t(
|
||||
|
||||
+23
-8
@@ -51,13 +51,29 @@ const selectFolderAndGroup = async (user: UserEvent) => {
|
||||
await clickSelectOption(groupInput, grafanaRulerGroup.name);
|
||||
};
|
||||
|
||||
const selectContactPoint = async (user: UserEvent, contactPointName: string) => {
|
||||
const selectContactPoint = async (contactPointName: string) => {
|
||||
const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find();
|
||||
await user.click(byRole('combobox').get(contactPointInput));
|
||||
await clickSelectOption(contactPointInput, contactPointName);
|
||||
};
|
||||
|
||||
// combobox hack
|
||||
beforeEach(() => {
|
||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||
width: 120,
|
||||
height: 120,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}));
|
||||
|
||||
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
|
||||
value: mockGetBoundingClientRect,
|
||||
});
|
||||
});
|
||||
|
||||
setupMswServer();
|
||||
|
||||
describe('Can create a new grafana managed alert using simplified routing', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
@@ -124,7 +140,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
//select contact point routing
|
||||
await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get());
|
||||
|
||||
await selectContactPoint(user, contactPointName);
|
||||
await selectContactPoint(contactPointName);
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.save.get());
|
||||
@@ -139,9 +155,8 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
|
||||
await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find());
|
||||
|
||||
await selectContactPoint(user, 'Email');
|
||||
|
||||
expect(await screen.findByText('Email')).toBeInTheDocument();
|
||||
await selectContactPoint('lotsa-emails');
|
||||
expect(screen.getByDisplayValue('lotsa-emails')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('switch modes enabled', () => {
|
||||
@@ -157,7 +172,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
await selectContactPoint(user, contactPointName);
|
||||
await selectContactPoint(contactPointName);
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.save.get());
|
||||
@@ -211,7 +226,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
|
||||
await selectFolderAndGroup(user);
|
||||
await selectContactPoint(user, contactPointName);
|
||||
await selectContactPoint(contactPointName);
|
||||
|
||||
await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode
|
||||
|
||||
|
||||
+70
-71
@@ -1,93 +1,92 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { QueryStatus } from '@reduxjs/toolkit/query';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import {
|
||||
ContactPointSelector as GrafanaManagedContactPointSelector,
|
||||
alertingAPIv0alpha1,
|
||||
} from '@grafana/alerting/unstable';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { ActionMeta, Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui';
|
||||
import { ContactPointSelector as ContactPointSelectorDropdown } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui';
|
||||
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
|
||||
import { createRelativeUrl } from 'app/features/alerting/unified/utils/url';
|
||||
|
||||
import { ContactPointWithMetadata } from '../../../../contact-points/utils';
|
||||
|
||||
export interface ContactPointSelectorProps {
|
||||
alertManager: string;
|
||||
onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void;
|
||||
}
|
||||
|
||||
export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) {
|
||||
const { control, watch, trigger, setError } = useFormContext<RuleFormValues>();
|
||||
export function ContactPointSelector({ alertManager }: ContactPointSelectorProps) {
|
||||
const { control, watch, trigger } = useFormContext<RuleFormValues>();
|
||||
|
||||
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
|
||||
const selectedContactPointField = `contactPoints.${alertManager}.selectedContactPoint` as const;
|
||||
const contactPointInForm = watch(selectedContactPointField);
|
||||
|
||||
// Wrap in useCallback to avoid infinite render loop
|
||||
const handleError = useCallback(
|
||||
(err: Error) => {
|
||||
setError(`contactPoints.${alertManager}.selectedContactPoint`, {
|
||||
message: err.message,
|
||||
});
|
||||
},
|
||||
[alertManager, setError]
|
||||
);
|
||||
// check if the contact point still exists, we'll use listReceiver to check if the contact point exists because getReceiver doesn't work with
|
||||
// contact point titles but with UUIDs (which is not what we store on the alert rule definition)
|
||||
const { currentData, status } = alertingAPIv0alpha1.endpoints.listReceiver.useQuery({
|
||||
fieldSelector: `spec.title=${contactPointInForm}`,
|
||||
});
|
||||
|
||||
// if we have a contact point selected, check if it still exists in the event that someone has deleted it
|
||||
const validateContactPoint = useCallback(() => {
|
||||
if (contactPointInForm) {
|
||||
trigger(`contactPoints.${alertManager}.selectedContactPoint`, { shouldFocus: true });
|
||||
}
|
||||
}, [alertManager, contactPointInForm, trigger]);
|
||||
const contactPointNotFound = contactPointInForm && status === QueryStatus.fulfilled && isEmpty(currentData?.items);
|
||||
|
||||
// validate the contact point and check if it still exists when mounting the component
|
||||
// validate the contact point and check if it still exists when we've gotten a response from the API
|
||||
useEffect(() => {
|
||||
validateContactPoint();
|
||||
}, [validateContactPoint]);
|
||||
if (contactPointInForm && status === QueryStatus.fulfilled) {
|
||||
trigger(selectedContactPointField, { shouldFocus: true });
|
||||
}
|
||||
}, [contactPointInForm, selectedContactPointField, status, trigger]);
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Field
|
||||
label={t('alerting.contact-point-selector.contact-point-picker-label-contact-point', 'Contact point')}
|
||||
data-testid="contact-point-picker"
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { onChange }, fieldState: { error } }) => (
|
||||
<>
|
||||
<Stack>
|
||||
<ContactPointSelectorDropdown
|
||||
selectProps={{
|
||||
onChange: (value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
|
||||
onChange(value?.value?.name);
|
||||
onSelectContactPoint(value?.value);
|
||||
},
|
||||
width: 50,
|
||||
}}
|
||||
showRefreshButton
|
||||
selectedContactPointName={contactPointInForm}
|
||||
onError={handleError}
|
||||
/>
|
||||
<LinkToContactPoints />
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Field
|
||||
label={t('alerting.contact-point-selector.contact-point-picker-label-contact-point', 'Contact point')}
|
||||
data-testid="contact-point-picker"
|
||||
>
|
||||
<Controller
|
||||
name={selectedContactPointField}
|
||||
render={({ field: { onChange }, fieldState: { error } }) => (
|
||||
<>
|
||||
<Stack>
|
||||
<GrafanaManagedContactPointSelector
|
||||
isClearable={false}
|
||||
onChange={(contactPoint) => onChange(contactPoint.spec.title)}
|
||||
width={50}
|
||||
value={contactPointInForm}
|
||||
/>
|
||||
<LinkToContactPoints />
|
||||
</Stack>
|
||||
|
||||
{/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component.
|
||||
The only way I found to check the custom error is to check if the field has a value and if it's not in the options. */}
|
||||
{/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component.
|
||||
The only way I found to check the custom error is to check if the field has a value and if it's not in the options. */}
|
||||
|
||||
{error && <FieldValidationMessage>{error?.message}</FieldValidationMessage>}
|
||||
</>
|
||||
)}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t(
|
||||
'alerting.contact-point-selector.message.contact-point-is-required',
|
||||
'Contact point is required.'
|
||||
),
|
||||
},
|
||||
}}
|
||||
control={control}
|
||||
name={`contactPoints.${alertManager}.selectedContactPoint`}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
{error && <FieldValidationMessage>{error?.message}</FieldValidationMessage>}
|
||||
</>
|
||||
)}
|
||||
rules={{
|
||||
validate: () => {
|
||||
if (contactPointNotFound) {
|
||||
return t(
|
||||
'alerting.contactPoints.validation.notFound',
|
||||
`Contact point "{{contactPoint}}" could not be found`,
|
||||
{
|
||||
contactPoint: contactPointInForm,
|
||||
}
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
required: {
|
||||
value: true,
|
||||
message: t(
|
||||
'alerting.contact-point-selector.message.contact-point-is-required',
|
||||
'Contact point is required.'
|
||||
),
|
||||
},
|
||||
}}
|
||||
control={control}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { css } from '@emotion/css';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { ContactPointSelector } from '@grafana/alerting/unstable';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
import { useRulesFilter } from '../../../hooks/useFilteredRules';
|
||||
import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions';
|
||||
import { RuleHealth } from '../../../search/rulesSearchParser';
|
||||
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import { alertStateToReadable } from '../../../utils/rules';
|
||||
import { PopupCard } from '../../HoverCard';
|
||||
import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker';
|
||||
@@ -231,29 +229,29 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }:
|
||||
/>
|
||||
</div>
|
||||
{canRenderContactPointSelector && (
|
||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="contactPointFilter">
|
||||
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
<ContactPointSelector
|
||||
selectedContactPointName={filterState.contactPoint}
|
||||
selectProps={{
|
||||
inputId: 'contactPointFilter',
|
||||
width: 40,
|
||||
onChange: (selectValue) => {
|
||||
handleContactPointChange(selectValue?.value?.name!);
|
||||
},
|
||||
isClearable: true,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</AlertmanagerProvider>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="contactPointFilter">
|
||||
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
<ContactPointSelector
|
||||
id="contactPointFilter"
|
||||
value={filterState.contactPoint ?? null}
|
||||
width={40}
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
isClearable
|
||||
onChange={(contactPoint) => {
|
||||
handleContactPointChange(contactPoint?.spec.title ?? '');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
)}
|
||||
{pluginsFilterEnabled && (
|
||||
<div>
|
||||
|
||||
@@ -846,13 +846,11 @@
|
||||
"tooltip-provisioned-contact-points": "Provisioned contact points cannot be edited in the UI"
|
||||
},
|
||||
"contact-point-selector": {
|
||||
"aria-label-refresh-contact-points": "Refresh contact points",
|
||||
"contact-point-picker-label-contact-point": "Contact point",
|
||||
"message": {
|
||||
"contact-point-is-required": "Contact point is required."
|
||||
},
|
||||
"title-failed-to-fetch-contact-points": "Failed to fetch contact points",
|
||||
"tooltip-refresh-contact-points-list": "Refresh contact points list"
|
||||
"title-failed-to-fetch-contact-points": "Failed to fetch contact points"
|
||||
},
|
||||
"contact-points": {
|
||||
"create": "Create contact point",
|
||||
@@ -912,6 +910,11 @@
|
||||
"contactPointFilter": {
|
||||
"label": "Contact point"
|
||||
},
|
||||
"contactPoints": {
|
||||
"validation": {
|
||||
"notFound": "Contact point \"{{contactPoint}}\" could not be found"
|
||||
}
|
||||
},
|
||||
"continue-matching-indicator": {
|
||||
"content-route-continue-matching-other-policies": "This route will continue matching other policies"
|
||||
},
|
||||
@@ -1908,6 +1911,7 @@
|
||||
},
|
||||
"notification-policies-filter": {
|
||||
"label-search-by-contact-point": "Search by contact point",
|
||||
"placeholder-search-by-contact-point": "Choose a contact point",
|
||||
"search-query-input-placeholder-search": "Search"
|
||||
},
|
||||
"notification-policies-list": {
|
||||
|
||||
Reference in New Issue
Block a user