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, useCallback } from 'react';
|
||||||
import React, { FC, useEffect } from 'react';
|
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
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 { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
import SilencesTable from './components/silences/SilencesTable';
|
||||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions';
|
import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions';
|
||||||
import { SILENCES_POLL_INTERVAL_MS } from './utils/constants';
|
import { SILENCES_POLL_INTERVAL_MS } from './utils/constants';
|
||||||
import { initialAsyncRequestState } from './utils/redux';
|
import { initialAsyncRequestState } from './utils/redux';
|
||||||
import SilencesTable from './components/silences/SilencesTable';
|
import SilencesEditor from './components/silences/SilencesEditor';
|
||||||
|
|
||||||
const Silences: FC = () => {
|
const Silences: FC = () => {
|
||||||
const [alertManagerSourceName = '', setAlertManagerSourceName] = useAlertManagerSourceName();
|
const [alertManagerSourceName = '', setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||||
@ -21,8 +22,10 @@ const Silences: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function fetchAll() {
|
function fetchAll() {
|
||||||
dispatch(fetchSilencesAction(alertManagerSourceName));
|
if (alertManagerSourceName) {
|
||||||
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
dispatch(fetchSilencesAction(alertManagerSourceName));
|
||||||
|
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchAll();
|
fetchAll();
|
||||||
const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS);
|
const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS);
|
||||||
@ -31,18 +34,15 @@ const Silences: FC = () => {
|
|||||||
};
|
};
|
||||||
}, [alertManagerSourceName, dispatch]);
|
}, [alertManagerSourceName, dispatch]);
|
||||||
|
|
||||||
|
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
|
||||||
|
const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]);
|
||||||
|
|
||||||
if (!alertManagerSourceName) {
|
if (!alertManagerSourceName) {
|
||||||
return <Redirect to="/alerting/silences" />;
|
return <Redirect to="/alerting/silences" />;
|
||||||
}
|
}
|
||||||
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper pageId="silences">
|
<AlertingPageWrapper pageId="silences">
|
||||||
<Field label="Choose alert manager">
|
|
||||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
|
||||||
</Field>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<Alert severity="error" title="Error loading silences">
|
<Alert severity="error" title="Error loading silences">
|
||||||
{error.message || 'Unknown error.'}
|
{error.message || 'Unknown error.'}
|
||||||
@ -50,11 +50,31 @@ const Silences: FC = () => {
|
|||||||
)}
|
)}
|
||||||
{loading && <LoadingPlaceholder text="loading silences..." />}
|
{loading && <LoadingPlaceholder text="loading silences..." />}
|
||||||
{result && !error && alerts.result && (
|
{result && !error && alerts.result && (
|
||||||
<SilencesTable
|
<Switch>
|
||||||
silences={result}
|
<Route exact path="/alerting/silences">
|
||||||
alertManagerAlerts={alerts.result}
|
<SilencesTable
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
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>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
|
@ -70,12 +70,12 @@ export async function fetchSilences(alertManagerSourceName: string): Promise<Sil
|
|||||||
export async function createOrUpdateSilence(
|
export async function createOrUpdateSilence(
|
||||||
alertmanagerSourceName: string,
|
alertmanagerSourceName: string,
|
||||||
payload: SilenceCreatePayload
|
payload: SilenceCreatePayload
|
||||||
): Promise<string> {
|
): Promise<Silence> {
|
||||||
const result = await getBackendSrv().post(
|
const result = await getBackendSrv().post(
|
||||||
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`,
|
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
return result.data.silenceID;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise<void> {
|
export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise<void> {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { useStyles } from '@grafana/ui';
|
import { IconButton, useStyles } from '@grafana/ui';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
@ -7,12 +7,14 @@ interface Props {
|
|||||||
labelKey: string;
|
labelKey: string;
|
||||||
value: string;
|
value: string;
|
||||||
isRegex?: boolean;
|
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)}>
|
<div className={useStyles(getStyles)}>
|
||||||
{labelKey}={isRegex && '~'}
|
{labelKey}={isRegex && '~'}
|
||||||
{value}
|
{value}
|
||||||
|
{!!onRemoveLabel && <IconButton name="times" size="xs" onClick={onRemoveLabel} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,23 +1,19 @@
|
|||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { useStyles } from '@grafana/ui';
|
import { useStyles } from '@grafana/ui';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { FC } from 'react';
|
import React from 'react';
|
||||||
import { AlertLabel } from './AlertLabel';
|
import { AlertLabel } from './AlertLabel';
|
||||||
|
|
||||||
interface Props {
|
type Props = { labels: Record<string, string> };
|
||||||
labels: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlertLabels: FC<Props> = ({ labels }) => {
|
export const AlertLabels = ({ labels }: Props) => {
|
||||||
const styles = useStyles(getStyles);
|
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('__')));
|
const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__')));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{pairs.map(([key, value]) => (
|
{pairs.map(([key, value], index) => (
|
||||||
<AlertLabel key={`${key}-${value}`} labelKey={key} value={value} />
|
<AlertLabel key={`${key}-${value}-${index}`} labelKey={key} value={value} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import React, { FC } from 'react';
|
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
|
<EmptyListCTA
|
||||||
title="You haven't created any silences yet"
|
title="You haven't created any silences yet"
|
||||||
buttonIcon="bell-slash"
|
buttonIcon="bell-slash"
|
||||||
buttonLink={`${config.appSubUrl ?? ''}alerting/silences/new`}
|
buttonLink={makeAMLink('alerting/silence/new', alertManagerSourceName)}
|
||||||
buttonTitle="New silence"
|
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 { dateMath, GrafanaTheme, toDuration } from '@grafana/data';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { AlertLabel } from '../AlertLabel';
|
|
||||||
import { StateTag } from '../StateTag';
|
import { StateTag } from '../StateTag';
|
||||||
import { CollapseToggle } from '../CollapseToggle';
|
import { CollapseToggle } from '../CollapseToggle';
|
||||||
import { ActionButton } from '../rules/ActionButton';
|
import { ActionButton } from '../rules/ActionButton';
|
||||||
import { ActionIcon } from '../rules/ActionIcon';
|
import { ActionIcon } from '../rules/ActionIcon';
|
||||||
import { useStyles } from '@grafana/ui';
|
import { useStyles, Link } from '@grafana/ui';
|
||||||
import SilencedAlertsTable from './SilencedAlertsTable';
|
import SilencedAlertsTable from './SilencedAlertsTable';
|
||||||
import { expireSilenceAction } from '../../state/actions';
|
import { expireSilenceAction } from '../../state/actions';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { Matchers } from './Matchers';
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
silence: Silence;
|
silence: Silence;
|
||||||
@ -23,7 +23,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const styles = useStyles(getStyles);
|
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 dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||||
const startsAtDate = dateMath.parse(startsAt);
|
const startsAtDate = dateMath.parse(startsAt);
|
||||||
@ -44,9 +44,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
<StateTag status={status.state}>{status.state}</StateTag>
|
<StateTag status={status.state}>{status.state}</StateTag>
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.matchersCell}>
|
<td className={styles.matchersCell}>
|
||||||
{matchers?.map(({ name, value, isRegex }) => {
|
<Matchers matchers={matchers} />
|
||||||
return <AlertLabel key={`${name}-${value}`} labelKey={name} value={value} isRegex={isRegex} />;
|
|
||||||
})}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{silencedAlerts.length}</td>
|
<td>{silencedAlerts.length}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -56,13 +54,17 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
</td>
|
</td>
|
||||||
<td className={styles.actionsCell}>
|
<td className={styles.actionsCell}>
|
||||||
{status.state === 'expired' ? (
|
{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}>
|
<ActionButton icon="bell" onClick={handleExpireSilenceClick}>
|
||||||
Unsilence
|
Unsilence
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
<ActionIcon icon="pen" tooltip="edit" />
|
{status.state !== 'expired' && (
|
||||||
|
<ActionIcon href={`/alerting/silence/${silence.id}/edit`} icon="pen" tooltip="edit" />
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{!isCollapsed && (
|
{!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 React, { FC } from 'react';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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 { css } from '@emotion/css';
|
||||||
import { AlertmanagerAlert, Silence } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertmanagerAlert, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import SilenceTableRow from './SilenceTableRow';
|
import SilenceTableRow from './SilenceTableRow';
|
||||||
import { getAlertTableStyles } from '../../styles/table';
|
import { getAlertTableStyles } from '../../styles/table';
|
||||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||||
|
import { AlertManagerPicker } from '../AlertManagerPicker';
|
||||||
|
import { makeAMLink } from '../../utils/misc';
|
||||||
interface Props {
|
interface Props {
|
||||||
silences: Silence[];
|
silences: Silence[];
|
||||||
alertManagerAlerts: AlertmanagerAlert[];
|
alertManagerAlerts: AlertmanagerAlert[];
|
||||||
alertManagerSourceName: string;
|
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 styles = useStyles2(getStyles);
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
|
|
||||||
const findSilencedAlerts = (id: string) => {
|
const findSilencedAlerts = (id: string) => {
|
||||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||||
};
|
};
|
||||||
if (!!silences.length) {
|
|
||||||
return (
|
return (
|
||||||
<table className={tableStyles.table}>
|
<>
|
||||||
<colgroup>
|
<Field label="Choose alert manager">
|
||||||
<col className={tableStyles.colExpand} />
|
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||||
<col className={styles.colState} />
|
</Field>
|
||||||
<col className={styles.colMatchers} />
|
{!!silences.length && (
|
||||||
<col />
|
<>
|
||||||
<col />
|
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
|
||||||
<col />
|
<Button className={styles.addNewSilence} icon="plus">
|
||||||
</colgroup>
|
New Silence
|
||||||
<thead>
|
</Button>
|
||||||
<tr>
|
</Link>
|
||||||
<th />
|
<table className={tableStyles.table}>
|
||||||
<th>State</th>
|
<colgroup>
|
||||||
<th>Matchers</th>
|
<col className={tableStyles.colExpand} />
|
||||||
<th>Alerts</th>
|
<col className={styles.colState} />
|
||||||
<th>Schedule</th>
|
<col className={styles.colMatchers} />
|
||||||
<th>Action</th>
|
<col />
|
||||||
</tr>
|
<col />
|
||||||
</thead>
|
<col />
|
||||||
<tbody>
|
</colgroup>
|
||||||
{silences.map((silence, index) => {
|
<thead>
|
||||||
const silencedAlerts = findSilencedAlerts(silence.id);
|
<tr>
|
||||||
return (
|
<th />
|
||||||
<SilenceTableRow
|
<th>State</th>
|
||||||
key={silence.id}
|
<th>Matchers</th>
|
||||||
silence={silence}
|
<th>Alerts</th>
|
||||||
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
|
<th>Schedule</th>
|
||||||
silencedAlerts={silencedAlerts}
|
<th>Action</th>
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
</tr>
|
||||||
/>
|
</thead>
|
||||||
);
|
<tbody>
|
||||||
})}
|
{silences.map((silence, index) => {
|
||||||
</tbody>
|
const silencedAlerts = findSilencedAlerts(silence.id);
|
||||||
</table>
|
return (
|
||||||
);
|
<SilenceTableRow
|
||||||
} else {
|
key={silence.id}
|
||||||
return <NoSilencesSplash />;
|
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) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
addNewSilence: css`
|
||||||
|
margin-bottom: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
colState: css`
|
colState: css`
|
||||||
width: 110px;
|
width: 110px;
|
||||||
`,
|
`,
|
||||||
colMatchers: css`
|
colMatchers: css`
|
||||||
width: 50%;
|
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;
|
export default SilencesTable;
|
||||||
|
@ -2,7 +2,12 @@ import { AppEvents } from '@grafana/data';
|
|||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { appEvents } from 'app/core/core';
|
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 { NotifierDTO, ThunkResult } from 'app/types';
|
||||||
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
|
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
import {
|
import {
|
||||||
@ -17,6 +22,7 @@ import {
|
|||||||
fetchAlertManagerConfig,
|
fetchAlertManagerConfig,
|
||||||
fetchAlerts,
|
fetchAlerts,
|
||||||
fetchSilences,
|
fetchSilences,
|
||||||
|
createOrUpdateSilence,
|
||||||
updateAlertmanagerConfig,
|
updateAlertmanagerConfig,
|
||||||
} from '../api/alertmanager';
|
} from '../api/alertmanager';
|
||||||
import { fetchRules } from '../api/prometheus';
|
import { fetchRules } from '../api/prometheus';
|
||||||
@ -366,3 +372,26 @@ export const expireSilenceAction = (alertManagerSourceName: string, silenceId: s
|
|||||||
dispatch(fetchAmAlertsAction(alertManagerSourceName));
|
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,
|
fetchSilencesAction,
|
||||||
saveRuleFormAction,
|
saveRuleFormAction,
|
||||||
updateAlertManagerConfigAction,
|
updateAlertManagerConfigAction,
|
||||||
|
createOrUpdateSilenceAction,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
export const reducer = combineReducers({
|
export const reducer = combineReducers({
|
||||||
@ -28,6 +29,7 @@ export const reducer = combineReducers({
|
|||||||
}),
|
}),
|
||||||
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
||||||
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
||||||
|
updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer,
|
||||||
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
|
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
|
||||||
.reducer,
|
.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')
|
() => 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',
|
path: '/alerting/notifications',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
|
Loading…
Reference in New Issue
Block a user