The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx

274 lines
9.0 KiB

import { css } from '@emotion/css';
import { pickBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useDebounce } from 'react-use';
import {
addDurationToDate,
dateTime,
GrafanaTheme2,
intervalToAbbreviatedDurationString,
isValidDate,
parseDuration,
} from '@grafana/data';
import { config, isFetchError, locationService } from '@grafana/runtime';
import {
Alert,
Button,
Field,
FieldSet,
Input,
LinkButton,
LoadingPlaceholder,
Stack,
TextArea,
useStyles2,
} from '@grafana/ui';
import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi';
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { SilenceFormFields } from '../../types/silence-form';
import { matcherFieldToMatcher } from '../../utils/alertmanager';
import { makeAMLink } from '../../utils/misc';
import MatchersField from './MatchersField';
import { SilencePeriod } from './SilencePeriod';
import { SilencedInstancesPreview } from './SilencedInstancesPreview';
import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils';
interface Props {
silenceId: string;
alertManagerSourceName: string;
}
/**
* Silences editor for editing an existing silence.
*
* Fetches silence details from API, based on `silenceId`
*/
const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const {
data: silence,
isLoading: getSilenceIsLoading,
error: errorGettingExistingSilence,
} = alertSilencesApi.endpoints.getSilence.useQuery({
id: silenceId,
datasourceUid: getDatasourceAPIUid(alertManagerSourceName),
ruleMetadata: true,
accessControl: true,
});
const ruleUid = silence?.matchers?.find((m) => m.name === MATCHER_ALERT_RULE_UID)?.value;
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
const defaultValues = useMemo(() => {
if (!silence) {
return;
}
const filteredMatchers = silence.matchers?.filter((m) => m.name !== MATCHER_ALERT_RULE_UID);
return getFormFieldsForSilence({ ...silence, matchers: filteredMatchers });
}, [silence]);
if (silenceId && getSilenceIsLoading) {
return <LoadingPlaceholder text="Loading existing silence information..." />;
}
const existingSilenceNotFound =
isFetchError(errorGettingExistingSilence) && errorGettingExistingSilence.status === 404;
if (existingSilenceNotFound) {
return <Alert title={`Existing silence "${silenceId}" not found`} severity="warning" />;
}
const canEditSilence = isGrafanaAlertManager ? silence?.accessControl?.write : true;
if (!canEditSilence) {
return <Alert title={`You do not have permission to edit/recreate this silence`} severity="error" />;
}
return (
<SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} />
);
};
type SilencesEditorProps = {
formValues?: SilenceFormFields;
alertManagerSourceName: string;
onSilenceCreated?: (response: SilenceCreatedResponse) => void;
onCancel?: () => void;
ruleUid?: string;
};
/**
* Base silences editor used for new silences (from both the list view and the drawer),
* and for editing existing silences
*/
export const SilencesEditor = ({
formValues = getDefaultSilenceFormValues(),
alertManagerSourceName,
onSilenceCreated,
onCancel,
ruleUid,
}: SilencesEditorProps) => {
const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation();
const formAPI = useForm({ defaultValues: formValues });
const styles = useStyles2(getStyles);
const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI;
const [duration, startsAt, endsAt, matchers] = watch(['duration', 'startsAt', 'endsAt', 'matchers']);
/** Default action taken after creation or cancellation, if corresponding method is not defined */
const defaultHandler = () => {
locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName));
};
const onSilenceCreatedHandler = onSilenceCreated || defaultHandler;
const onCancelHandler = onCancel || defaultHandler;
const onSubmit = async (data: SilenceFormFields) => {
const { id, startsAt, endsAt, comment, createdBy, matchers: matchersFields } = data;
if (ruleUid) {
matchersFields.push({ name: MATCHER_ALERT_RULE_UID, value: ruleUid, operator: MatcherOperator.equal });
}
const matchersToSend = matchersFields.map(matcherFieldToMatcher).filter((field) => field.name && field.value);
const payload = pickBy(
{
id,
startsAt,
endsAt,
comment,
createdBy,
matchers: matchersToSend,
},
(value) => !!value
) as SilenceCreatePayload;
await createSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), payload })
.unwrap()
.then((newSilenceResponse) => {
onSilenceCreatedHandler?.(newSilenceResponse);
});
};
// Keep duration and endsAt in sync
const [prevDuration, setPrevDuration] = useState(duration);
useDebounce(
() => {
if (isValidDate(startsAt) && isValidDate(endsAt)) {
if (duration !== prevDuration) {
setValue('endsAt', dateTime(addDurationToDate(new Date(startsAt), parseDuration(duration))).toISOString());
setPrevDuration(duration);
} else {
const startValue = new Date(startsAt).valueOf();
const endValue = new Date(endsAt).valueOf();
if (endValue > startValue) {
const nextDuration = intervalToAbbreviatedDurationString({
start: new Date(startsAt),
end: new Date(endsAt),
});
setValue('duration', nextDuration);
setPrevDuration(nextDuration);
}
}
}
},
700,
[clearErrors, duration, endsAt, prevDuration, setValue, startsAt]
);
const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name);
return (
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldSet className={styles.formContainer}>
<div className={styles.silencePeriod}>
<SilencePeriod />
<Field
label="Duration"
invalid={!!formState.errors.duration}
error={
formState.errors.duration &&
(formState.errors.duration.type === 'required' ? 'Required field' : formState.errors.duration.message)
}
>
<Input
{...register('duration', {
validate: (value) =>
Object.keys(parseDuration(value)).length === 0
? 'Invalid duration. Valid example: 1d 4h (Available units: y, M, w, d, h, m, s)'
: undefined,
})}
id="duration"
/>
</Field>
</div>
<MatchersField required={Boolean(!ruleUid)} ruleUid={ruleUid} />
<Field
label="Comment"
required
error={formState.errors.comment?.message}
invalid={!!formState.errors.comment}
>
<TextArea
{...register('comment', { required: { value: true, message: 'Required.' } })}
rows={5}
placeholder="Details about the silence"
id="comment"
/>
</Field>
{!userLogged && (
<Field
label="Created By"
required
error={formState.errors.createdBy?.message}
invalid={!!formState.errors.createdBy}
>
<Input
{...register('createdBy', { required: { value: true, message: 'Required.' } })}
placeholder="Who's creating the silence"
/>
</Field>
)}
<SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchers} ruleUid={ruleUid} />
</FieldSet>
<Stack gap={1}>
{isLoading && (
<Button disabled={true} icon="spinner" variant="primary">
Saving...
</Button>
)}
{!isLoading && <Button type="submit">Save silence</Button>}
<LinkButton onClick={onCancelHandler} variant={'secondary'}>
Cancel
</LinkButton>
</Stack>
</form>
</FormProvider>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
formContainer: css({
maxWidth: theme.breakpoints.values.md,
}),
alertRule: css({
paddingBottom: theme.spacing(2),
}),
silencePeriod: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
gap: theme.spacing(1),
maxWidth: theme.breakpoints.values.sm,
paddingTop: theme.spacing(2),
}),
});
export default ExistingSilenceEditor;