Alerting: Add silences table (#33138)

* Create table for silences

* Style table to figma designs

* Add rules table to silences

* Rebase with new rules table

* Remove redundant reducer

* fetch alertmanager alerts (#33142)

* fetch alertmanager alerts

* show the alerts json

* Use matching alerts from alertmanager api

* Add handle to expire silence

* Get silenced alerts closer to figma designs

* fix expire silence endpoint typo

* Style affected alerts table

* Add default empty string for alertmanager source

Co-authored-by: Domas <domasx2@gmail.com>
This commit is contained in:
Nathan Rodman 2021-04-27 13:46:34 -07:00 committed by GitHub
parent 968935b8b7
commit 1913d304a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 417 additions and 26 deletions

View File

@ -6,27 +6,35 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertManagerPicker } from './components/AlertManagerPicker';
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchSilencesAction } from './state/actions';
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';
const Silences: FC = () => {
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
const [alertManagerSourceName = '', setAlertManagerSourceName] = useAlertManagerSourceName();
const dispatch = useDispatch();
const silences = useUnifiedAlertingSelector((state) => state.silences);
useEffect(() => {
if (alertManagerSourceName) {
dispatch(fetchSilencesAction(alertManagerSourceName));
}
}, [alertManagerSourceName, dispatch]);
const alerts =
useUnifiedAlertingSelector((state) => state.amAlerts)[alertManagerSourceName] || initialAsyncRequestState;
const { result, loading, error } =
(alertManagerSourceName && silences[alertManagerSourceName]) || initialAsyncRequestState;
useEffect(() => {
function fetchAll() {
dispatch(fetchSilencesAction(alertManagerSourceName));
dispatch(fetchAmAlertsAction(alertManagerSourceName));
}
fetchAll();
const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [alertManagerSourceName, dispatch]);
if (!alertManagerSourceName) {
return <Redirect to="/alerting/silences" />;
}
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
return (
<AlertingPageWrapper pageId="silences">
@ -41,7 +49,13 @@ const Silences: FC = () => {
</Alert>
)}
{loading && <LoadingPlaceholder text="loading silences..." />}
{result && !loading && !error && <pre>{JSON.stringify(result, null, 2)}</pre>}
{result && !error && alerts.result && (
<SilencesTable
silences={result}
alertManagerAlerts={alerts.result}
alertManagerSourceName={alertManagerSourceName}
/>
)}
</AlertingPageWrapper>
);
};

View File

@ -1,3 +1,4 @@
import { urlUtil } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import {
AlertmanagerAlert,
@ -78,23 +79,27 @@ export async function createOrUpdateSilence(
export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise<void> {
await getBackendSrv().delete(
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences/${encodeURIComponent(silenceID)}`
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silence/${encodeURIComponent(silenceID)}`
);
}
export async function fetchAlerts(
alertmanagerSourceName: string,
matchers?: SilenceMatcher[]
matchers?: SilenceMatcher[],
silenced = true,
active = true,
inhibited = true
): Promise<AlertmanagerAlert[]> {
const filters =
matchers
?.map(
(matcher) =>
`filter=${encodeURIComponent(
`${escapeQuotes(matcher.name)}=${matcher.isRegex ? '~' : ''}"${escapeQuotes(matcher.value)}"`
)}`
)
.join('&') || '';
urlUtil.toUrlParams({ silenced, active, inhibited }) +
matchers
?.map(
(matcher) =>
`filter=${encodeURIComponent(
`${escapeQuotes(matcher.name)}=${matcher.isRegex ? '~' : ''}"${escapeQuotes(matcher.value)}"`
)}`
)
.join('&') || '';
const result = await getBackendSrv()
.fetch<AlertmanagerAlert[]>({

View File

@ -6,11 +6,13 @@ import { css } from '@emotion/css';
interface Props {
labelKey: string;
value: string;
isRegex?: boolean;
}
export const AlertLabel: FC<Props> = ({ labelKey, value }) => (
export const AlertLabel: FC<Props> = ({ labelKey, value, isRegex = false }) => (
<div className={useStyles(getStyles)}>
{labelKey}={value}
{labelKey}={isRegex && '~'}
{value}
</div>
);

View File

@ -1,11 +1,12 @@
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { SilenceState, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { css, cx } from '@emotion/css';
import React, { FC } from 'react';
type Props = {
status: PromAlertingRuleState;
status: PromAlertingRuleState | SilenceState | AlertState;
};
export const StateTag: FC<Props> = ({ children, status }) => {
@ -36,4 +37,20 @@ const getStyles = (theme: GrafanaTheme) => ({
background-color: ${theme.palette.brandDanger};
border: solid 1px ${theme.palette.brandDanger};
`,
[SilenceState.Expired]: css`
background-color: ${theme.palette.gray33};
border: solid 1px ${theme.palette.gray33};
`,
[SilenceState.Active]: css`
background-color: ${theme.palette.brandSuccess};
border: solid 1px ${theme.palette.brandSuccess};
`,
[AlertState.Unprocessed]: css`
background-color: ${theme.palette.gray33};
border: solid 1px ${theme.palette.gray33};
`,
[AlertState.Suppressed]: css`
background-color: ${theme.palette.brandPrimary};
border: solid 1px ${theme.palette.brandPrimary};
`,
});

View File

@ -0,0 +1,12 @@
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import React, { FC } from 'react';
import { config } from '@grafana/runtime';
export const NoSilencesSplash: FC = () => (
<EmptyListCTA
title="You haven't created any silences yet"
buttonIcon="bell-slash"
buttonLink={`${config.appSubUrl ?? ''}alerting/silences/new`}
buttonTitle="New silence"
/>
);

View File

@ -0,0 +1,127 @@
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 SilencedAlertsTable from './SilencedAlertsTable';
import { expireSilenceAction } from '../../state/actions';
import { useDispatch } from 'react-redux';
interface Props {
className?: string;
silence: Silence;
silencedAlerts: AlertmanagerAlert[];
alertManagerSourceName: string;
}
const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertManagerSourceName }) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const dispatch = useDispatch();
const styles = useStyles(getStyles);
const { status, matchers, startsAt, endsAt, comment, createdBy } = silence;
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
const startsAtDate = dateMath.parse(startsAt);
const endsAtDate = dateMath.parse(endsAt);
const duration = toDuration(endsAtDate?.diff(startsAtDate || '')).asSeconds();
const handleExpireSilenceClick = () => {
dispatch(expireSilenceAction(alertManagerSourceName, silence.id));
};
return (
<Fragment>
<tr className={className}>
<td>
<CollapseToggle isCollapsed={isCollapsed} onToggle={(value) => setIsCollapsed(value)} />
</td>
<td>
<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} />;
})}
</td>
<td>{silencedAlerts.length}</td>
<td>
{startsAtDate?.format(dateDisplayFormat)} {'-'}
<br />
{endsAtDate?.format(dateDisplayFormat)}
</td>
<td className={styles.actionsCell}>
{status.state === 'expired' ? (
<ActionButton icon="sync">Recreate</ActionButton>
) : (
<ActionButton icon="bell" onClick={handleExpireSilenceClick}>
Unsilence
</ActionButton>
)}
<ActionIcon icon="pen" tooltip="edit" />
</td>
</tr>
{!isCollapsed && (
<>
<tr className={className}>
<td />
<td>Comment</td>
<td colSpan={4}>{comment}</td>
</tr>
<tr className={className}>
<td />
<td>Schedule</td>
<td colSpan={4}>{`${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format(
dateDisplayFormat
)}`}</td>
</tr>
<tr className={className}>
<td />
<td>Duration</td>
<td colSpan={4}>{duration} seconds</td>
</tr>
<tr className={className}>
<td />
<td>Created by</td>
<td colSpan={4}>{createdBy}</td>
</tr>
{!!silencedAlerts.length && (
<tr className={cx(className, styles.alertRulesCell)}>
<td />
<td>Affected alerts</td>
<td colSpan={4}>
<SilencedAlertsTable silencedAlerts={silencedAlerts} />
</td>
</tr>
)}
</>
)}
</Fragment>
);
};
const getStyles = (theme: GrafanaTheme) => ({
matchersCell: css`
& > * + * {
margin-left: ${theme.spacing.xs};
}
`,
actionsCell: css`
text-align: right;
width: 1%;
white-space: nowrap;
& > * + * {
margin-left: ${theme.spacing.sm};
}
`,
alertRulesCell: css`
vertical-align: top;
`,
});
export default SilenceTableRow;

View File

@ -0,0 +1,66 @@
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
import React, { FC } from 'react';
import { getAlertTableStyles } from '../../styles/table';
import { useStyles } from '@grafana/ui';
import { SilencedAlertsTableRow } from './SilencedAlertsTableRow';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from '@emotion/css';
interface Props {
silencedAlerts: AlertmanagerAlert[];
}
const SilencedAlertsTable: FC<Props> = ({ silencedAlerts }) => {
const tableStyles = useStyles(getAlertTableStyles);
const styles = useStyles(getStyles);
if (!!silencedAlerts.length) {
return (
<table className={cx(tableStyles.table, styles.tableMargin)}>
<colgroup>
<col className={tableStyles.colExpand} />
<col className={styles.colState} />
<col />
<col className={styles.colName} />
<col />
</colgroup>
<thead>
<tr>
<th></th>
<th>State</th>
<th></th>
<th>Alert name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{silencedAlerts.map((alert, index) => {
return (
<SilencedAlertsTableRow
key={alert.fingerprint}
alert={alert}
className={index % 2 === 0 ? tableStyles.evenRow : ''}
/>
);
})}
</tbody>
</table>
);
} else {
return null;
}
};
const getStyles = (theme: GrafanaTheme) => ({
tableMargin: css`
margin-bottom: ${theme.spacing.sm};
`,
colState: css`
width: 110px;
`,
colName: css`
width: 65%;
`,
});
export default SilencedAlertsTable;

View File

@ -0,0 +1,51 @@
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
import React, { FC, useState } from 'react';
import { CollapseToggle } from '../CollapseToggle';
import { StateTag } from '../StateTag';
import { ActionIcon } from '../rules/ActionIcon';
import { getAlertTableStyles } from '../../styles/table';
import { useStyles } from '@grafana/ui';
import { dateTimeAsMoment, toDuration } from '@grafana/data';
import { AlertLabels } from '../AlertLabels';
interface Props {
alert: AlertmanagerAlert;
className?: string;
}
export const SilencedAlertsTableRow: FC<Props> = ({ alert, className }) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const tableStyles = useStyles(getAlertTableStyles);
const alertDuration = toDuration(dateTimeAsMoment(alert.endsAt).diff(alert.startsAt)).asSeconds();
const alertName = Object.entries(alert.labels).reduce((name, [labelKey, labelValue]) => {
if (labelKey === 'alertname' || labelKey === '__alert_rule_title__') {
name = labelValue;
}
return name;
}, '');
return (
<>
<tr className={className}>
<td>
<CollapseToggle isCollapsed={isCollapsed} onToggle={(collapsed) => setIsCollapsed(collapsed)} />
</td>
<td>
<StateTag status={alert.status.state}>{alert.status.state}</StateTag>
</td>
<td>for {alertDuration} seconds</td>
<td>{alertName}</td>
<td className={tableStyles.actionsCell}>
<ActionIcon icon="chart-line" href={alert.generatorURL} tooltip="View in explorer" />
</td>
</tr>
{!isCollapsed && (
<tr className={className}>
<td></td>
<td colSpan={5}>
<AlertLabels labels={alert.labels} />
</td>
</tr>
)}
</>
);
};

View File

@ -0,0 +1,74 @@
import React, { FC } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } 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';
interface Props {
silences: Silence[];
alertManagerAlerts: AlertmanagerAlert[];
alertManagerSourceName: string;
}
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
const styles = useStyles(getStyles);
const tableStyles = useStyles(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 />;
}
};
const getStyles = (theme: GrafanaTheme) => ({
colState: css`
width: 110px;
`,
colMatchers: css`
width: 50%;
`,
});
export default SilencesTable;

View File

@ -2,7 +2,7 @@ import { AppEvents } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { appEvents } from 'app/core/core';
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerAlert, AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
import { NotifierDTO, ThunkResult } from 'app/types';
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
import {
@ -12,7 +12,13 @@ import {
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { fetchNotifiers } from '../api/grafana';
import { fetchAlertManagerConfig, fetchSilences, updateAlertmanagerConfig } from '../api/alertmanager';
import {
expireSilence,
fetchAlertManagerConfig,
fetchAlerts,
fetchSilences,
updateAlertmanagerConfig,
} from '../api/alertmanager';
import { fetchRules } from '../api/prometheus';
import {
deleteRulerRulesGroup,
@ -341,3 +347,16 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateALert
})()
)
);
export const fetchAmAlertsAction = createAsyncThunk(
'unifiedalerting/fetchAmAlerts',
(alertManagerSourceName: string): Promise<AlertmanagerAlert[]> =>
withSerializedError(fetchAlerts(alertManagerSourceName, [], true, true, true))
);
export const expireSilenceAction = (alertManagerSourceName: string, silenceId: string): ThunkResult<void> => {
return async (dispatch) => {
await expireSilence(alertManagerSourceName, silenceId);
dispatch(fetchSilencesAction(alertManagerSourceName));
dispatch(fetchAmAlertsAction(alertManagerSourceName));
};
};

View File

@ -2,6 +2,7 @@ import { combineReducers } from 'redux';
import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
import {
fetchAlertManagerConfigAction,
fetchAmAlertsAction,
fetchExistingRuleAction,
fetchGrafanaNotifiersAction,
fetchPromRulesAction,
@ -27,6 +28,8 @@ export const reducer = combineReducers({
}),
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
.reducer,
});
export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@ -4,3 +4,4 @@ export const RULE_LIST_POLL_INTERVAL_MS = 20000;
export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager';
export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager';
export const SILENCES_POLL_INTERVAL_MS = 20000;