mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Create and edit silences (#33593)
This commit is contained in:
parent
04a85b1a2a
commit
d994d0e762
@ -1,15 +1,16 @@
|
||||
import { Field, Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import React, { FC, useEffect, useCallback } from 'react';
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Redirect, Route, RouteChildrenProps, Switch } from 'react-router-dom';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||
import SilencesTable from './components/silences/SilencesTable';
|
||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions';
|
||||
import { SILENCES_POLL_INTERVAL_MS } from './utils/constants';
|
||||
import { initialAsyncRequestState } from './utils/redux';
|
||||
import SilencesTable from './components/silences/SilencesTable';
|
||||
import SilencesEditor from './components/silences/SilencesEditor';
|
||||
|
||||
const Silences: FC = () => {
|
||||
const [alertManagerSourceName = '', setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||
@ -21,8 +22,10 @@ const Silences: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
function fetchAll() {
|
||||
dispatch(fetchSilencesAction(alertManagerSourceName));
|
||||
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
||||
if (alertManagerSourceName) {
|
||||
dispatch(fetchSilencesAction(alertManagerSourceName));
|
||||
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
||||
}
|
||||
}
|
||||
fetchAll();
|
||||
const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS);
|
||||
@ -31,18 +34,15 @@ const Silences: FC = () => {
|
||||
};
|
||||
}, [alertManagerSourceName, dispatch]);
|
||||
|
||||
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
|
||||
const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]);
|
||||
|
||||
if (!alertManagerSourceName) {
|
||||
return <Redirect to="/alerting/silences" />;
|
||||
}
|
||||
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper pageId="silences">
|
||||
<Field label="Choose alert manager">
|
||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||
</Field>
|
||||
<br />
|
||||
<br />
|
||||
{error && !loading && (
|
||||
<Alert severity="error" title="Error loading silences">
|
||||
{error.message || 'Unknown error.'}
|
||||
@ -50,11 +50,31 @@ const Silences: FC = () => {
|
||||
)}
|
||||
{loading && <LoadingPlaceholder text="loading silences..." />}
|
||||
{result && !error && alerts.result && (
|
||||
<SilencesTable
|
||||
silences={result}
|
||||
alertManagerAlerts={alerts.result}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
/>
|
||||
<Switch>
|
||||
<Route exact path="/alerting/silences">
|
||||
<SilencesTable
|
||||
silences={result}
|
||||
alertManagerAlerts={alerts.result}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
setAlertManagerSourceName={setAlertManagerSourceName}
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path="/alerting/silence/new">
|
||||
<SilencesEditor alertManagerSourceName={alertManagerSourceName} />
|
||||
</Route>
|
||||
<Route exact path="/alerting/silence/:id/edit">
|
||||
{({ match }: RouteChildrenProps<{ id: string }>) => {
|
||||
return (
|
||||
match?.params.id && (
|
||||
<SilencesEditor
|
||||
silence={getSilenceById(match.params.id)}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}}
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
@ -70,12 +70,12 @@ export async function fetchSilences(alertManagerSourceName: string): Promise<Sil
|
||||
export async function createOrUpdateSilence(
|
||||
alertmanagerSourceName: string,
|
||||
payload: SilenceCreatePayload
|
||||
): Promise<string> {
|
||||
): Promise<Silence> {
|
||||
const result = await getBackendSrv().post(
|
||||
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`,
|
||||
payload
|
||||
);
|
||||
return result.data.silenceID;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise<void> {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { IconButton, useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@ -7,12 +7,14 @@ interface Props {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
isRegex?: boolean;
|
||||
onRemoveLabel?: () => void;
|
||||
}
|
||||
|
||||
export const AlertLabel: FC<Props> = ({ labelKey, value, isRegex = false }) => (
|
||||
export const AlertLabel: FC<Props> = ({ labelKey, value, isRegex = false, onRemoveLabel }) => (
|
||||
<div className={useStyles(getStyles)}>
|
||||
{labelKey}={isRegex && '~'}
|
||||
{value}
|
||||
{!!onRemoveLabel && <IconButton name="times" size="xs" onClick={onRemoveLabel} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -1,23 +1,19 @@
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { AlertLabel } from './AlertLabel';
|
||||
|
||||
interface Props {
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
type Props = { labels: Record<string, string> };
|
||||
|
||||
export const AlertLabels: FC<Props> = ({ labels }) => {
|
||||
export const AlertLabels = ({ labels }: Props) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
// transform to array of key value pairs and filter out "private" labels that start and end with double underscore
|
||||
const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__')));
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{pairs.map(([key, value]) => (
|
||||
<AlertLabel key={`${key}-${value}`} labelKey={key} value={value} />
|
||||
{pairs.map(([key, value], index) => (
|
||||
<AlertLabel key={`${key}-${value}-${index}`} labelKey={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
@ -0,0 +1,47 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AlertLabel } from '../AlertLabel';
|
||||
|
||||
type MatchersProps = { matchers: SilenceMatcher[]; onRemoveLabel?(index: number): void };
|
||||
|
||||
export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const removeLabel = useCallback(
|
||||
(index: number) => {
|
||||
if (!!onRemoveLabel) {
|
||||
onRemoveLabel(index);
|
||||
}
|
||||
},
|
||||
[onRemoveLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{matchers.map(({ name, value, isRegex }: SilenceMatcher, index) => {
|
||||
return (
|
||||
<AlertLabel
|
||||
key={`${name}-${value}-${index}`}
|
||||
labelKey={name}
|
||||
value={value}
|
||||
isRegex={isRegex}
|
||||
onRemoveLabel={!!onRemoveLabel ? () => removeLabel(index) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
wrapper: css`
|
||||
& > * {
|
||||
margin-top: ${theme.spacing.xs};
|
||||
margin-right: ${theme.spacing.xs};
|
||||
}
|
||||
padding-bottom: ${theme.spacing.xs};
|
||||
`,
|
||||
});
|
@ -0,0 +1,121 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Field, Input, InlineLabel, useStyles, Checkbox, IconButton } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form';
|
||||
import { SilenceFormFields } from '../../types/silence-form';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MatchersField: FC<Props> = ({ className }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
const formApi = useFormContext<SilenceFormFields>();
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = formApi;
|
||||
const { fields: matchers = [], append, remove } = useFieldArray<SilenceFormFields>({ name: 'matchers' });
|
||||
|
||||
return (
|
||||
<div className={cx(className, styles.wrapper)}>
|
||||
<Field label="Matchers" required>
|
||||
<div>
|
||||
<div className={styles.matchers}>
|
||||
{matchers.map((matcher, index) => {
|
||||
return (
|
||||
<div className={styles.row} key={`${matcher.id}`}>
|
||||
<Field
|
||||
label="Name"
|
||||
invalid={!!errors?.matchers?.[index]?.name}
|
||||
error={errors?.matchers?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`matchers.${index}.name` as const, {
|
||||
required: { value: true, message: 'Required.' },
|
||||
})}
|
||||
defaultValue={matcher.name}
|
||||
placeholder="name"
|
||||
/>
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
label="Value"
|
||||
invalid={!!errors?.matchers?.[index]?.value}
|
||||
error={errors?.matchers?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`matchers.${index}.value` as const, {
|
||||
required: { value: true, message: 'Required.' },
|
||||
})}
|
||||
defaultValue={matcher.value}
|
||||
placeholder="value"
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.regexCheckbox} label="Regex">
|
||||
<Checkbox {...register(`matchers.${index}.isRegex` as const)} defaultChecked={matcher.isRegex} />
|
||||
</Field>
|
||||
{matchers.length > 1 && (
|
||||
<IconButton
|
||||
className={styles.removeButton}
|
||||
tooltip="Remove matcher"
|
||||
name={'trash-alt'}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
append({ name: '', value: '', isRegex: false });
|
||||
}}
|
||||
>
|
||||
Add matcher
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
margin-top: ${theme.spacing.md};
|
||||
`,
|
||||
row: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.bg2};
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.sm} 0 ${theme.spacing.sm};
|
||||
`,
|
||||
equalSign: css`
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
margin-left: ${theme.spacing.xs};
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
regexCheckbox: css`
|
||||
margin-left: ${theme.spacing.md};
|
||||
`,
|
||||
removeButton: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
matchers: css`
|
||||
max-width: 585px;
|
||||
margin: ${theme.spacing.sm} 0;
|
||||
padding-top: ${theme.spacing.xs};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export default MatchersField;
|
@ -1,12 +1,16 @@
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import React, { FC } from 'react';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
|
||||
export const NoSilencesSplash: FC = () => (
|
||||
type Props = {
|
||||
alertManagerSourceName: string;
|
||||
};
|
||||
|
||||
export const NoSilencesSplash: FC<Props> = ({ alertManagerSourceName }) => (
|
||||
<EmptyListCTA
|
||||
title="You haven't created any silences yet"
|
||||
buttonIcon="bell-slash"
|
||||
buttonLink={`${config.appSubUrl ?? ''}alerting/silences/new`}
|
||||
buttonLink={makeAMLink('alerting/silence/new', alertManagerSourceName)}
|
||||
buttonTitle="New silence"
|
||||
/>
|
||||
);
|
||||
|
@ -0,0 +1,78 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { dateTime, GrafanaTheme } from '@grafana/data';
|
||||
import { Field, TimeRangeInput, useStyles } from '@grafana/ui';
|
||||
import React from 'react';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import { SilenceFormFields } from '../../types/silence-form';
|
||||
|
||||
export const SilencePeriod = () => {
|
||||
const { control, getValues } = useFormContext<SilenceFormFields>();
|
||||
const styles = useStyles(getStyles);
|
||||
const {
|
||||
field: { onChange: onChangeStartsAt, value: startsAt },
|
||||
fieldState: { invalid: startsAtInvalid },
|
||||
} = useController({
|
||||
name: 'startsAt',
|
||||
control,
|
||||
rules: {
|
||||
validate: (value) => getValues().endsAt > value,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
field: { onChange: onChangeEndsAt, value: endsAt },
|
||||
fieldState: { invalid: endsAtInvalid },
|
||||
} = useController({
|
||||
name: 'endsAt',
|
||||
control,
|
||||
rules: {
|
||||
validate: (value) => getValues().startsAt < value,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
field: { onChange: onChangeTimeZone, value: timeZone },
|
||||
} = useController({
|
||||
name: 'timeZone',
|
||||
control,
|
||||
});
|
||||
|
||||
const invalid = startsAtInvalid || endsAtInvalid;
|
||||
|
||||
const from = dateTime(startsAt);
|
||||
const to = dateTime(endsAt);
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={styles.timeRange}
|
||||
label="Silence start and end"
|
||||
error={invalid ? 'To is before or the same as from' : ''}
|
||||
invalid={invalid}
|
||||
>
|
||||
<TimeRangeInput
|
||||
value={{
|
||||
from,
|
||||
to,
|
||||
raw: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
}}
|
||||
timeZone={timeZone}
|
||||
onChange={(newValue) => {
|
||||
onChangeStartsAt(dateTime(newValue.from));
|
||||
onChangeEndsAt(dateTime(newValue.to));
|
||||
}}
|
||||
onChangeTimeZone={(newValue) => onChangeTimeZone(newValue)}
|
||||
hideTimeZone={false}
|
||||
hideQuickRanges={true}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
timeRange: css`
|
||||
width: 400px;
|
||||
`,
|
||||
});
|
@ -2,15 +2,15 @@ import React, { FC, Fragment, useState } from 'react';
|
||||
import { dateMath, GrafanaTheme, toDuration } from '@grafana/data';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AlertLabel } from '../AlertLabel';
|
||||
import { StateTag } from '../StateTag';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
import { ActionButton } from '../rules/ActionButton';
|
||||
import { ActionIcon } from '../rules/ActionIcon';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { useStyles, Link } from '@grafana/ui';
|
||||
import SilencedAlertsTable from './SilencedAlertsTable';
|
||||
import { expireSilenceAction } from '../../state/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Matchers } from './Matchers';
|
||||
interface Props {
|
||||
className?: string;
|
||||
silence: Silence;
|
||||
@ -23,7 +23,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const styles = useStyles(getStyles);
|
||||
const { status, matchers, startsAt, endsAt, comment, createdBy } = silence;
|
||||
const { status, matchers = [], startsAt, endsAt, comment, createdBy } = silence;
|
||||
|
||||
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||
const startsAtDate = dateMath.parse(startsAt);
|
||||
@ -44,9 +44,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
||||
<StateTag status={status.state}>{status.state}</StateTag>
|
||||
</td>
|
||||
<td className={styles.matchersCell}>
|
||||
{matchers?.map(({ name, value, isRegex }) => {
|
||||
return <AlertLabel key={`${name}-${value}`} labelKey={name} value={value} isRegex={isRegex} />;
|
||||
})}
|
||||
<Matchers matchers={matchers} />
|
||||
</td>
|
||||
<td>{silencedAlerts.length}</td>
|
||||
<td>
|
||||
@ -56,13 +54,17 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
||||
</td>
|
||||
<td className={styles.actionsCell}>
|
||||
{status.state === 'expired' ? (
|
||||
<ActionButton icon="sync">Recreate</ActionButton>
|
||||
<Link href={`/alerting/silence/${silence.id}/edit`}>
|
||||
<ActionButton icon="sync">Recreate</ActionButton>
|
||||
</Link>
|
||||
) : (
|
||||
<ActionButton icon="bell" onClick={handleExpireSilenceClick}>
|
||||
Unsilence
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionIcon icon="pen" tooltip="edit" />
|
||||
{status.state !== 'expired' && (
|
||||
<ActionIcon href={`/alerting/silence/${silence.id}/edit`} icon="pen" tooltip="edit" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{!isCollapsed && (
|
||||
|
@ -0,0 +1,156 @@
|
||||
import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { FC } from 'react';
|
||||
import { Alert, Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles } from '@grafana/ui';
|
||||
import { DefaultTimeZone, GrafanaTheme } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { pickBy } from 'lodash';
|
||||
import MatchersField from './MatchersField';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { SilenceFormFields } from '../../types/silence-form';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { createOrUpdateSilenceAction } from '../../state/actions';
|
||||
import { SilencePeriod } from './SilencePeriod';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
|
||||
interface Props {
|
||||
silence?: Silence;
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
const getDefaultFormValues = (silence?: Silence): SilenceFormFields => {
|
||||
if (silence) {
|
||||
return {
|
||||
id: silence.id,
|
||||
startsAt: new Date().toISOString(),
|
||||
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h
|
||||
comment: silence.comment,
|
||||
createdBy: silence.createdBy,
|
||||
duration: `2h`,
|
||||
isRegex: false,
|
||||
matchers: silence.matchers || [],
|
||||
matcherName: '',
|
||||
matcherValue: '',
|
||||
timeZone: DefaultTimeZone,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: '',
|
||||
startsAt: new Date().toISOString(),
|
||||
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h
|
||||
comment: '',
|
||||
createdBy: config.bootData.user.name,
|
||||
duration: '2h',
|
||||
isRegex: false,
|
||||
matchers: [{ name: '', value: '', isRegex: false }],
|
||||
matcherName: '',
|
||||
matcherValue: '',
|
||||
timeZone: DefaultTimeZone,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) => {
|
||||
const formAPI = useForm({ defaultValues: getDefaultFormValues(silence) });
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const { loading, error } = useUnifiedAlertingSelector((state) => state.updateSilence);
|
||||
|
||||
const { register, handleSubmit, formState } = formAPI;
|
||||
|
||||
const onSubmit = (data: SilenceFormFields) => {
|
||||
const { id, startsAt, endsAt, comment, createdBy, matchers } = data;
|
||||
const payload = pickBy(
|
||||
{
|
||||
id,
|
||||
startsAt,
|
||||
endsAt,
|
||||
comment,
|
||||
createdBy,
|
||||
matchers,
|
||||
},
|
||||
(value) => !!value
|
||||
) as SilenceCreatePayload;
|
||||
dispatch(
|
||||
createOrUpdateSilenceAction({
|
||||
alertManagerSourceName,
|
||||
payload,
|
||||
exitOnSave: true,
|
||||
successMessage: `Silence ${payload.id ? 'updated' : 'created'}`,
|
||||
})
|
||||
);
|
||||
};
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldSet label={`${silence ? 'Recreate silence' : 'Create silence'}`}>
|
||||
{error && (
|
||||
<Alert severity="error" title="Error saving silence">
|
||||
{error.message || (error as any)?.data?.message || String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
<SilencePeriod />
|
||||
<MatchersField />
|
||||
<Field
|
||||
className={cx(styles.field, styles.textArea)}
|
||||
label="Comment"
|
||||
required
|
||||
error={formState.errors.comment?.message}
|
||||
invalid={!!formState.errors.comment}
|
||||
>
|
||||
<TextArea {...register('comment', { required: { value: true, message: 'Required.' } })} />
|
||||
</Field>
|
||||
<Field
|
||||
className={cx(styles.field, styles.createdBy)}
|
||||
label="Created by"
|
||||
required
|
||||
error={formState.errors.createdBy?.message}
|
||||
invalid={!!formState.errors.createdBy}
|
||||
>
|
||||
<Input {...register('createdBy', { required: { value: true, message: 'Required.' } })} />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<div className={styles.flexRow}>
|
||||
{loading && (
|
||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||
Saving...
|
||||
</Button>
|
||||
)}
|
||||
{!loading && <Button type="submit">Submit</Button>}
|
||||
<LinkButton
|
||||
href={makeAMLink('alerting/silences', alertManagerSourceName)}
|
||||
variant={'secondary'}
|
||||
fill="outline"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
field: css`
|
||||
margin: ${theme.spacing.sm} 0;
|
||||
`,
|
||||
textArea: css`
|
||||
width: 600px;
|
||||
`,
|
||||
createdBy: css`
|
||||
width: 200px;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
& > * {
|
||||
margin-right: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
export default SilencesEditor;
|
@ -1,74 +1,117 @@
|
||||
import React, { FC } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Icon, useStyles2, Link, Button, Field } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { AlertmanagerAlert, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||
import SilenceTableRow from './SilenceTableRow';
|
||||
import { getAlertTableStyles } from '../../styles/table';
|
||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||
|
||||
import { AlertManagerPicker } from '../AlertManagerPicker';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
interface Props {
|
||||
silences: Silence[];
|
||||
alertManagerAlerts: AlertmanagerAlert[];
|
||||
alertManagerSourceName: string;
|
||||
setAlertManagerSourceName(name: string): void;
|
||||
}
|
||||
|
||||
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
|
||||
const SilencesTable: FC<Props> = ({
|
||||
silences,
|
||||
alertManagerAlerts,
|
||||
alertManagerSourceName,
|
||||
setAlertManagerSourceName,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const tableStyles = useStyles2(getAlertTableStyles);
|
||||
|
||||
const findSilencedAlerts = (id: string) => {
|
||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||
};
|
||||
if (!!silences.length) {
|
||||
return (
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
<col className={tableStyles.colExpand} />
|
||||
<col className={styles.colState} />
|
||||
<col className={styles.colMatchers} />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>State</th>
|
||||
<th>Matchers</th>
|
||||
<th>Alerts</th>
|
||||
<th>Schedule</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{silences.map((silence, index) => {
|
||||
const silencedAlerts = findSilencedAlerts(silence.id);
|
||||
return (
|
||||
<SilenceTableRow
|
||||
key={silence.id}
|
||||
silence={silence}
|
||||
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
|
||||
silencedAlerts={silencedAlerts}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
} else {
|
||||
return <NoSilencesSplash />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Choose alert manager">
|
||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||
</Field>
|
||||
{!!silences.length && (
|
||||
<>
|
||||
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
|
||||
<Button className={styles.addNewSilence} icon="plus">
|
||||
New Silence
|
||||
</Button>
|
||||
</Link>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
<col className={tableStyles.colExpand} />
|
||||
<col className={styles.colState} />
|
||||
<col className={styles.colMatchers} />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>State</th>
|
||||
<th>Matchers</th>
|
||||
<th>Alerts</th>
|
||||
<th>Schedule</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{silences.map((silence, index) => {
|
||||
const silencedAlerts = findSilencedAlerts(silence.id);
|
||||
return (
|
||||
<SilenceTableRow
|
||||
key={silence.id}
|
||||
silence={silence}
|
||||
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
|
||||
silencedAlerts={silencedAlerts}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.callout}>
|
||||
<Icon className={styles.calloutIcon} name="info-circle" />
|
||||
<span>Expired silences are automatically deleted after 5 days.</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
addNewSilence: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
colState: css`
|
||||
width: 110px;
|
||||
`,
|
||||
colMatchers: css`
|
||||
width: 50%;
|
||||
`,
|
||||
callout: css`
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
border-top: 3px solid ${theme.colors.info.border};
|
||||
border-radius: 2px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: ${theme.spacing(2)};
|
||||
|
||||
& > * {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
calloutIcon: css`
|
||||
color: ${theme.colors.info.text};
|
||||
`,
|
||||
});
|
||||
|
||||
export default SilencesTable;
|
||||
|
@ -2,7 +2,12 @@ import { AppEvents } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { AlertmanagerAlert, AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||
import {
|
||||
AlertmanagerAlert,
|
||||
AlertManagerCortexConfig,
|
||||
Silence,
|
||||
SilenceCreatePayload,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { NotifierDTO, ThunkResult } from 'app/types';
|
||||
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import {
|
||||
@ -17,6 +22,7 @@ import {
|
||||
fetchAlertManagerConfig,
|
||||
fetchAlerts,
|
||||
fetchSilences,
|
||||
createOrUpdateSilence,
|
||||
updateAlertmanagerConfig,
|
||||
} from '../api/alertmanager';
|
||||
import { fetchRules } from '../api/prometheus';
|
||||
@ -366,3 +372,26 @@ export const expireSilenceAction = (alertManagerSourceName: string, silenceId: s
|
||||
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
||||
};
|
||||
};
|
||||
|
||||
type UpdateSilenceActionOptions = {
|
||||
alertManagerSourceName: string;
|
||||
payload: SilenceCreatePayload;
|
||||
exitOnSave: boolean;
|
||||
successMessage?: string;
|
||||
};
|
||||
|
||||
export const createOrUpdateSilenceAction = createAsyncThunk<void, UpdateSilenceActionOptions, {}>(
|
||||
'unifiedalerting/updateSilence',
|
||||
({ alertManagerSourceName, payload, exitOnSave, successMessage }): Promise<void> =>
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
await createOrUpdateSilence(alertManagerSourceName, payload);
|
||||
if (successMessage) {
|
||||
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
|
||||
}
|
||||
if (exitOnSave) {
|
||||
locationService.push('/alerting/silences');
|
||||
}
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
fetchSilencesAction,
|
||||
saveRuleFormAction,
|
||||
updateAlertManagerConfigAction,
|
||||
createOrUpdateSilenceAction,
|
||||
} from './actions';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
@ -28,6 +29,7 @@ export const reducer = combineReducers({
|
||||
}),
|
||||
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
||||
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
||||
updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer,
|
||||
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
|
||||
.reducer,
|
||||
});
|
||||
|
16
public/app/features/alerting/unified/types/silence-form.ts
Normal file
16
public/app/features/alerting/unified/types/silence-form.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { TimeZone } from '@grafana/data';
|
||||
|
||||
export type SilenceFormFields = {
|
||||
id: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
timeZone: TimeZone;
|
||||
duration: string;
|
||||
comment: string;
|
||||
matchers: SilenceMatcher[];
|
||||
createdBy: string;
|
||||
matcherName: string;
|
||||
matcherValue: string;
|
||||
isRegex: boolean;
|
||||
};
|
@ -370,6 +370,18 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/silence/new',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/silence/:id/edit',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notifications',
|
||||
component: SafeDynamicImport(
|
||||
|
Loading…
Reference in New Issue
Block a user