Alerting: Show warning when cp does not exist and invalidate the form (#81621)

* Show warning when cp does not exist and invalidate the form

* Set error in contact selectedContactPoint form field manually

* use RHF validate and trigger

* Fix defaultvalue not being set in contact point and update the error message text

* Simplify refetchReceivers prop definition in ContactPointSelectorProps

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
pull/81891/head
Sonia Aguilar 1 year ago committed by GitHub
parent d63590112f
commit 8f65e36b06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 79
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx
  2. 203
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx

@ -1,11 +1,11 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, CollapsableSection, IconButton, LoadingPlaceholder, Stack, TextLink, useStyles2 } from '@grafana/ui';
import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints';
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils';
@ -18,8 +18,6 @@ interface AlertManagerManualRoutingProps {
alertManager: AlertManagerDataSource;
}
const LOADING_SPINNER_DURATION = 1000;
export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRoutingProps) {
const styles = useStyles2(getStyles);
@ -29,20 +27,16 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
ContactPointWithMetadata | undefined
>();
// We need to provide a fake loading state for the contact points, because it might be that the response is so fast that the loading spinner is not shown,
// and the user might think that the contact points are not fetched.
// We will show the loading spinner for 1 second, and if the fetching takes more than 1 second, we will show the loading spinner until the fetching is done.
const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => {
setSelectedContactPointWithMetadata(contactPoint);
};
const [loadingContactPoints, setLoadingContactPoints] = useState(false);
// we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const options = contactPoints.map((receiver) => {
const integrations = receiver?.grafana_managed_receiver_configs;
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
const onClickRefresh = () => {
setLoadingContactPoints(true);
Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
setLoadingContactPoints(false);
});
};
return { label: receiver.name, value: receiver, description };
});
if (errorInContactPointStatus) {
return <Alert title="Failed to fetch contact points" severity="error" />;
@ -64,21 +58,10 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
<Stack direction="row" gap={1} alignItems="center">
<ContactPointSelector
alertManager={alertManagerName}
contactPoints={contactPoints}
onSelectContactPoint={setSelectedContactPointWithMetadata}
options={options}
onSelectContactPoint={onSelectContactPoint}
refetchReceivers={refetchReceivers}
/>
<div className={styles.contactPointsInfo}>
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label="Refresh contact points"
tooltip="Refresh contact points list"
className={cx(styles.refreshButton, {
[styles.loading]: loadingContactPoints,
})}
/>
<LinkToContactPoints />
</div>
</Stack>
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
<ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} />
@ -98,14 +81,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
</Stack>
);
}
function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
return (
<TextLink external href={createUrl(hrefToContactPoints)} aria-label="View or create contact points">
View or create contact points
</TextLink>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
firstAlertManagerLine: css({
@ -141,30 +116,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
marginTop: theme.spacing(2),
}),
contactPointsInfo: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
}),
refreshButton: css({
color: theme.colors.text.secondary,
cursor: 'pointer',
borderRadius: theme.shape.radius.circle,
overflow: 'hidden',
}),
loading: css({
pointerEvents: 'none',
animation: 'rotation 2s infinite linear',
'@keyframes rotation': {
from: {
transform: 'rotate(720deg)',
},
to: {
transform: 'rotate(0deg)',
},
},
}),
});

@ -1,75 +1,186 @@
import { css } from '@emotion/css';
import React from 'react';
import { css, cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { ActionMeta, Field, FieldValidationMessage, InputControl, Select, Stack, useStyles2 } from '@grafana/ui';
import {
ActionMeta,
Field,
FieldValidationMessage,
IconButton,
InputControl,
Select,
Stack,
TextLink,
useStyles2,
} from '@grafana/ui';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { ContactPointReceiverSummary } from '../../../../contact-points/ContactPoints';
import { ContactPointWithMetadata } from '../../../../contact-points/utils';
export interface ContactPointSelectorProps {
alertManager: string;
contactPoints: ContactPointWithMetadata[];
options: Array<{
label: string;
value: ContactPointWithMetadata;
description: React.JSX.Element;
}>;
onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void;
refetchReceivers: () => Promise<unknown>;
}
export function ContactPointSelector({ alertManager, contactPoints, onSelectContactPoint }: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
const { control, watch } = useFormContext<RuleFormValues>();
const options = contactPoints.map((receiver) => {
const integrations = receiver?.grafana_managed_receiver_configs;
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
export function ContactPointSelector({
alertManager,
options,
onSelectContactPoint,
refetchReceivers,
}: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
const { control, watch, trigger } = useFormContext<RuleFormValues>();
return { label: receiver.name, value: receiver, description };
});
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
const selectedContactPointWithMetadata = options.find(
(option) => option.value.name === watch(`contactPoints.${alertManager}.selectedContactPoint`)
)?.value;
const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value;
const selectedContactPointSelectableValue = selectedContactPointWithMetadata
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
: undefined;
const LOADING_SPINNER_DURATION = 1000;
const [loadingContactPoints, setLoadingContactPoints] = useState(false);
// we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 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 onClickRefresh = () => {
setLoadingContactPoints(true);
Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
setLoadingContactPoints(false);
validateContactPoint();
});
};
// validate the contact point and check if it still exists when mounting the component
useEffect(() => {
validateContactPoint();
}, [validateContactPoint]);
return (
<Stack direction="column">
<Field label="Contact point">
<InputControl
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<div className={styles.contactPointsSelector}>
<Select
{...field}
defaultValue={selectedContactPointSelectableValue}
aria-label="Contact point"
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
}}
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
// is shared with other components where the "description" _has_ to be a string.
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
// @ts-ignore
options={options}
width={50}
/>
</div>
{error && <FieldValidationMessage>{error.message}</FieldValidationMessage>}
</>
)}
rules={{ required: { value: true, message: 'Contact point is required.' } }}
control={control}
name={`contactPoints.${alertManager}.selectedContactPoint`}
/>
</Field>
<Stack direction="row" alignItems="center">
<Field label="Contact point">
<InputControl
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<div className={styles.contactPointsSelector}>
<Select
{...field}
aria-label="Contact point"
defaultValue={selectedContactPointSelectableValue}
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
}}
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
// is shared with other components where the "description" _has_ to be a string.
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
// @ts-ignore
options={options}
width={50}
/>
<div className={styles.contactPointsInfo}>
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label="Refresh contact points"
tooltip="Refresh contact points list"
className={cx(styles.refreshButton, {
[styles.loading]: loadingContactPoints,
})}
/>
<LinkToContactPoints />
</div>
</div>
{/* 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: 'Contact point is required.',
},
validate: {
contactPointExists: (value: string) => {
if (options.some((option) => option.value.name === value)) {
return true;
}
return `Contact point ${contactPointInForm} does not exist.`;
},
},
}}
control={control}
name={`contactPoints.${alertManager}.selectedContactPoint`}
/>
</Field>
</Stack>
</Stack>
);
}
function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
return (
<TextLink external href={createUrl(hrefToContactPoints)} aria-label="View or create contact points">
View or create contact points
</TextLink>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
contactPointsSelector: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
}),
contactPointsInfo: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(1),
}),
refreshButton: css({
color: theme.colors.text.secondary,
cursor: 'pointer',
borderRadius: theme.shape.radius.circle,
overflow: 'hidden',
}),
loading: css({
pointerEvents: 'none',
animation: 'rotation 2s infinite linear',
'@keyframes rotation': {
from: {
transform: 'rotate(720deg)',
},
to: {
transform: 'rotate(0deg)',
},
},
}),
warn: css({
color: theme.colors.warning.text,
}),
});

Loading…
Cancel
Save