Alerting: Fix silences preview (#66000)

* Use alertmanager /alerts endpoint to show preview of instances affected by silence

* Fix debounce dependency, add no instances warning

* Rename silences preview component

* Fix the preview file name, use IsNulLDate to check the date

* Fix valid matchers condition

* Cleanup

* Remove unused code
This commit is contained in:
Konrad Lalik 2023-04-26 10:27:37 +02:00 committed by GitHub
parent 044d7f61c7
commit c41c638b52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 253 deletions

View File

@ -1,10 +1,13 @@
import {
AlertmanagerAlert,
AlertmanagerChoice,
AlertManagerCortexConfig,
ExternalAlertmanagerConfig,
ExternalAlertmanagers,
ExternalAlertmanagersResponse,
Matcher,
} from '../../../../plugins/datasource/alertmanager/types';
import { matcherToOperator } from '../utils/alertmanager';
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { alertingApi } from './alertingApi';
@ -16,8 +19,34 @@ export interface AlertmanagersChoiceResponse {
numExternalAlertmanagers: number;
}
interface AlertmanagerAlertsFilter {
active?: boolean;
silenced?: boolean;
inhibited?: boolean;
unprocessed?: boolean;
matchers?: Matcher[];
}
// Based on https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml
export const alertmanagerApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getAlertmanagerAlerts: build.query<
AlertmanagerAlert[],
{ amSourceName: string; filter?: AlertmanagerAlertsFilter }
>({
query: ({ amSourceName, filter }) => {
// TODO Add support for active, silenced, inhibited, unprocessed filters
const filterMatchers = filter?.matchers
?.filter((matcher) => matcher.name && matcher.value)
.map((matcher) => `${matcher.name}${matcherToOperator(matcher)}${matcher.value}`);
return {
url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts`,
params: { filter: filterMatchers },
};
},
}),
getAlertmanagerChoiceStatus: build.query<AlertmanagersChoiceResponse, void>({
query: () => ({ url: '/api/v1/ngalert' }),
providesTags: ['AlertmanagerChoice'],

View File

@ -17,6 +17,7 @@ export interface DynamicTableColumnProps<T = unknown> {
renderCell: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
size?: number | string;
className?: string;
}
export interface DynamicTableItemProps<T = unknown> {
@ -134,7 +135,11 @@ export const DynamicTable = <T extends object>({
</div>
)}
{cols.map((col) => (
<div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
<div
className={cx(styles.cell, styles.bodyCell, col.className)}
data-column={col.label}
key={`${item.id}-${col.id}`}
>
{col.renderCell(item, index)}
</div>
))}

View File

@ -1,128 +0,0 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useDebounce } from 'react-use';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { Badge, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { Alert, AlertingRule } from 'app/types/unified-alerting';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
import { MatcherFieldValue, SilenceFormFields } from '../../types/silence-form';
import { findAlertInstancesWithMatchers } from '../../utils/matchers';
import { isAlertingRule } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AlertStateTag } from '../rules/AlertStateTag';
type MatchedRulesTableItemProps = DynamicTableItemProps<{
matchedInstance: Alert;
}>;
type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedInstance: Alert }>;
export const MatchedSilencedRules = () => {
const [matchedAlertRules, setMatchedAlertRules] = useState<MatchedRulesTableItemProps[]>([]);
const formApi = useFormContext<SilenceFormFields>();
const dispatch = useDispatch();
const { watch } = formApi;
const matchers: MatcherFieldValue[] = watch('matchers');
const styles = useStyles2(getStyles);
const columns = useColumns();
useEffect(() => {
dispatch(fetchAllPromAndRulerRulesAction());
}, [dispatch]);
const combinedNamespaces = useCombinedRuleNamespaces();
useDebounce(
() => {
const matchedInstances = combinedNamespaces.flatMap((namespace) => {
return namespace.groups.flatMap((group) => {
return group.rules
.map((combinedRule) => combinedRule.promRule)
.filter((rule): rule is AlertingRule => isAlertingRule(rule))
.flatMap((rule) => findAlertInstancesWithMatchers(rule.alerts ?? [], matchers));
});
});
setMatchedAlertRules(matchedInstances);
},
500,
[combinedNamespaces, matchers]
);
return (
<div>
<h4 className={styles.title}>
Affected alert instances
{matchedAlertRules.length > 0 ? (
<Badge className={styles.badge} color="blue" text={matchedAlertRules.length} />
) : null}
</h4>
<div className={styles.table}>
{matchers.every((matcher) => !matcher.value && !matcher.name) ? (
<span>Add a valid matcher to see affected alerts</span>
) : (
<DynamicTable
items={matchedAlertRules}
isExpandable={false}
cols={columns}
pagination={{ itemsPerPage: 5 }}
/>
)}
</div>
</div>
);
};
function useColumns(): MatchedRulesTableColumnProps[] {
return [
{
id: 'state',
label: 'State',
renderCell: function renderStateTag({ data: { matchedInstance } }) {
return <AlertStateTag state={matchedInstance.state} />;
},
size: '160px',
},
{
id: 'labels',
label: 'Labels',
renderCell: function renderName({ data: { matchedInstance } }) {
return <AlertLabels labels={matchedInstance.labels} />;
},
size: 'auto',
},
{
id: 'created',
label: 'Created',
renderCell: function renderSummary({ data: { matchedInstance } }) {
return (
<>
{matchedInstance.activeAt.startsWith('0001')
? '-'
: dateTime(matchedInstance.activeAt).format('YYYY-MM-DD HH:mm:ss')}
</>
);
},
size: '180px',
},
];
}
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
max-width: ${theme.breakpoints.values.lg}px;
`,
moreMatches: css`
margin-top: ${theme.spacing(1)};
`,
title: css`
display: flex;
align-items: center;
`,
badge: css`
margin-left: ${theme.spacing(1)};
`,
});

View File

@ -0,0 +1,129 @@
import { css } from '@emotion/css';
import React from 'react';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { Alert, Badge, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { isNullDate } from '../../utils/time';
import { AlertLabels } from '../AlertLabels';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AmAlertStateTag } from './AmAlertStateTag';
interface Props {
amSourceName: string;
matchers: Matcher[];
}
export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => {
const { useGetAlertmanagerAlertsQuery } = alertmanagerApi;
const styles = useStyles2(getStyles);
const columns = useColumns();
// By default the form contains an empty matcher - with empty name and value and = operator
// We don't want to fetch previews for empty matchers as it results in all alerts returned
const hasValidMatchers = matchers.some((matcher) => matcher.value && matcher.name);
const {
currentData: alerts = [],
isFetching,
isError,
} = useGetAlertmanagerAlertsQuery(
{ amSourceName, filter: { matchers } },
{ skip: !hasValidMatchers, refetchOnMountOrArgChange: true }
);
const tableItemAlerts = alerts.map<DynamicTableItemProps<AlertmanagerAlert>>((alert) => ({
id: alert.fingerprint,
data: alert,
}));
return (
<div>
<h4 className={styles.title}>
Affected alert instances
{tableItemAlerts.length > 0 ? (
<Badge className={styles.badge} color="blue" text={tableItemAlerts.length} />
) : null}
</h4>
{!hasValidMatchers && <span>Add a valid matcher to see affected alerts</span>}
{isError && (
<Alert title="Preview not available" severity="error">
Error occured when generating affected alerts preview. Are you matchers valid?
</Alert>
)}
{isFetching && <LoadingPlaceholder text="Loading..." />}
{!isFetching && !isError && hasValidMatchers && (
<div className={styles.table}>
{tableItemAlerts.length > 0 ? (
<DynamicTable
items={tableItemAlerts}
isExpandable={false}
cols={columns}
pagination={{ itemsPerPage: 10 }}
/>
) : (
<span>No matching alert instances found</span>
)}
</div>
)}
</div>
);
};
function useColumns(): Array<DynamicTableColumnProps<AlertmanagerAlert>> {
const styles = useStyles2(getStyles);
return [
{
id: 'state',
label: 'State',
renderCell: function renderStateTag({ data }) {
return <AmAlertStateTag state={data.status.state} />;
},
size: '120px',
className: styles.stateColumn,
},
{
id: 'labels',
label: 'Labels',
renderCell: function renderName({ data }) {
return <AlertLabels labels={data.labels} className={styles.alertLabels} />;
},
size: 'auto',
},
{
id: 'created',
label: 'Created',
renderCell: function renderSummary({ data }) {
return <>{isNullDate(data.startsAt) ? '-' : dateTime(data.startsAt).format('YYYY-MM-DD HH:mm:ss')}</>;
},
size: '180px',
},
];
}
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
max-width: ${theme.breakpoints.values.lg}px;
`,
moreMatches: css`
margin-top: ${theme.spacing(1)};
`,
title: css`
display: flex;
align-items: center;
`,
badge: css`
margin-left: ${theme.spacing(1)};
`,
stateColumn: css`
display: flex;
align-items: center;
`,
alertLabels: css`
justify-content: flex-start;
`,
});

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { pickBy } from 'lodash';
import { isEqual, pickBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useDebounce } from 'react-use';
@ -16,7 +16,7 @@ import {
import { config } from '@grafana/runtime';
import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
@ -28,9 +28,9 @@ import { parseQueryParamMatchers } from '../../utils/matchers';
import { makeAMLink } from '../../utils/misc';
import { initialAsyncRequestState } from '../../utils/redux';
import { MatchedSilencedRules } from './MatchedSilencedRules';
import MatchersField from './MatchersField';
import { SilencePeriod } from './SilencePeriod';
import { SilencedInstancesPreview } from './SilencedInstancesPreview';
interface Props {
silence?: Silence;
@ -104,6 +104,9 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
const formAPI = useForm({ defaultValues });
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const [matchersForPreview, setMatchersForPreview] = useState<Matcher[]>(
defaultValues.matchers.map(matcherFieldToMatcher)
);
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence);
@ -138,6 +141,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
const duration = watch('duration');
const startsAt = watch('startsAt');
const endsAt = watch('endsAt');
const matcherFields = watch('matchers');
// Keep duration and endsAt in sync
const [prevDuration, setPrevDuration] = useState(duration);
@ -164,6 +168,19 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
700,
[clearErrors, duration, endsAt, prevDuration, setValue, startsAt]
);
useDebounce(
() => {
// React-hook-form watch does not return referentialy equal values so this trick is needed
const newMatchers = matcherFields.filter((m) => m.name && m.value).map(matcherFieldToMatcher);
if (!isEqual(matchersForPreview, newMatchers)) {
setMatchersForPreview(newMatchers);
}
},
700,
[matcherFields]
);
const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name);
return (
@ -221,7 +238,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
/>
</Field>
)}
<MatchedSilencedRules />
<SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchersForPreview} />
</FieldSet>
<div className={styles.flexRow}>
{loading && (

View File

@ -1,8 +1,4 @@
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { mockPromAlert } from '../mocks';
import { getMatcherQueryParams, findAlertInstancesWithMatchers, parseQueryParamMatchers } from './matchers';
import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers';
describe('Unified Alerting matchers', () => {
describe('getMatcherQueryParams tests', () => {
@ -37,57 +33,4 @@ describe('Unified Alerting matchers', () => {
expect(matchers[0].value).toBe('TestData 1');
});
});
describe('matchLabelsToMatchers', () => {
it('should match for equal', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.equal }];
const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })];
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
expect(matchedAlerts).toHaveLength(1);
});
it('should match for not equal', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notEqual }];
const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })];
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
expect(matchedAlerts).toHaveLength(1);
});
it('should match for regex', () => {
const matchers = [{ name: 'foo', value: 'b{1}a.*', operator: MatcherOperator.regex }];
const alerts = [
mockPromAlert({ labels: { foo: 'bbr' } }),
mockPromAlert({ labels: { foo: 'aba' } }), // This does not match because the regex is implicitly anchored.
mockPromAlert({ labels: { foo: 'ba' } }),
mockPromAlert({ labels: { foo: 'bar' } }),
mockPromAlert({ labels: { foo: 'baz' } }),
mockPromAlert({ labels: { foo: 'bas' } }),
];
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
expect(matchedAlerts).toHaveLength(4);
expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual([
'ba',
'bar',
'baz',
'bas',
]);
});
it('should not match regex', () => {
const matchers = [{ name: 'foo', value: 'ba{3}', operator: MatcherOperator.notRegex }];
const alerts = [
mockPromAlert({ labels: { foo: 'bar' } }),
mockPromAlert({ labels: { foo: 'baz' } }),
mockPromAlert({ labels: { foo: 'baaa' } }),
mockPromAlert({ labels: { foo: 'bas' } }),
];
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
expect(matchedAlerts).toHaveLength(3);
expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual(['bar', 'baz', 'bas']);
});
});
});

View File

@ -1,10 +1,7 @@
import { uniqBy } from 'lodash';
import { Labels } from '@grafana/data';
import { Matcher, MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { Alert } from 'app/types/unified-alerting';
import { MatcherFieldValue } from '../types/silence-form';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { parseMatcher } from './alertmanager';
@ -29,61 +26,3 @@ export const getMatcherQueryParams = (labels: Labels) => {
return matcherUrlParams;
};
interface MatchedInstance {
id: string;
data: {
matchedInstance: Alert;
};
}
export const findAlertInstancesWithMatchers = (
instances: Alert[],
matchers: MatcherFieldValue[]
): MatchedInstance[] => {
const anchorRegex = (regexpString: string): RegExp => {
// Silence matchers are always fully anchored in the Alertmanager: https://github.com/prometheus/alertmanager/pull/748
if (!regexpString.startsWith('^')) {
regexpString = '^' + regexpString;
}
if (!regexpString.endsWith('$')) {
regexpString = regexpString + '$';
}
return new RegExp(regexpString);
};
const matchesInstance = (instance: Alert, matcher: MatcherFieldValue) => {
return Object.entries(instance.labels).some(([key, value]) => {
if (!matcher.name || !matcher.value) {
return false;
}
if (matcher.name !== key) {
return false;
}
switch (matcher.operator) {
case MatcherOperator.equal:
return matcher.value === value;
case MatcherOperator.notEqual:
return matcher.value !== value;
case MatcherOperator.regex:
const regex = anchorRegex(matcher.value);
return regex.test(value);
case MatcherOperator.notRegex:
const negregex = anchorRegex(matcher.value);
return !negregex.test(value);
default:
return false;
}
});
};
const filteredInstances = instances.filter((instance) => {
return matchers.every((matcher) => matchesInstance(instance, matcher));
});
const mappedInstances = filteredInstances.map((instance) => ({
id: `${instance.activeAt}-${instance.value}`,
data: { matchedInstance: instance },
}));
return mappedInstances;
};