mirror of
https://github.com/grafana/grafana.git
synced 2026-07-30 00:08:10 -05:00
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>
This commit is contained in:
co-authored by
Gilles De Mey
parent
d63590112f
commit
8f65e36b06
+15
-66
@@ -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,21 +27,17 @@ 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 [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 onClickRefresh = () => {
|
||||
setLoadingContactPoints(true);
|
||||
Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
|
||||
setLoadingContactPoints(false);
|
||||
});
|
||||
const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => {
|
||||
setSelectedContactPointWithMetadata(contactPoint);
|
||||
};
|
||||
|
||||
const options = contactPoints.map((receiver) => {
|
||||
const integrations = receiver?.grafana_managed_receiver_configs;
|
||||
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
|
||||
|
||||
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)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
+157
-46
@@ -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) {
|
||||
|
||||
export function ContactPointSelector({
|
||||
alertManager,
|
||||
options,
|
||||
onSelectContactPoint,
|
||||
refetchReceivers,
|
||||
}: ContactPointSelectorProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { control, watch } = useFormContext<RuleFormValues>();
|
||||
const { control, watch, trigger } = useFormContext<RuleFormValues>();
|
||||
|
||||
const options = contactPoints.map((receiver) => {
|
||||
const integrations = receiver?.grafana_managed_receiver_configs;
|
||||
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
|
||||
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
|
||||
|
||||
return { label: receiver.name, value: receiver, description };
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user