Alerting: Create and edit silences (#33593)

This commit is contained in:
Nathan Rodman 2021-05-06 00:29:02 -07:00 committed by GitHub
parent 04a85b1a2a
commit d994d0e762
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 614 additions and 86 deletions

View File

@ -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>
);

View File

@ -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> {

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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};
`,
});

View File

@ -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;

View File

@ -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"
/>
);

View File

@ -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;
`,
});

View File

@ -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 && (

View File

@ -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;

View File

@ -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;

View File

@ -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');
}
})()
)
);

View File

@ -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,
});

View 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;
};

View File

@ -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(