Alerting: Show affected alert rules when creating Silence (#44307)

* first things

* show affected rules when creating silence

* revert typescript bump

* fix yarn lock

* fix import order

* fixing tests

* some layout for affected alerts

* fix test

* add default description

* review part 1

* Add a badge for number of affected alerts

* fix test

* remove blank space
This commit is contained in:
Peter Holmberg 2022-02-01 09:49:05 +01:00 committed by GitHub
parent dc0a2fb55b
commit 552c24a66e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 236 additions and 22 deletions

View File

@ -60,7 +60,6 @@ const ui = {
matcherName: byPlaceholderText('label'), matcherName: byPlaceholderText('label'),
matcherValue: byPlaceholderText('value'), matcherValue: byPlaceholderText('value'),
comment: byPlaceholderText('Details about the silence'), comment: byPlaceholderText('Details about the silence'),
createdBy: byPlaceholderText('User'),
matcherOperatorSelect: byLabelText('operator'), matcherOperatorSelect: byLabelText('operator'),
matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }), matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }),
addMatcherButton: byRole('button', { name: 'Add matcher' }), addMatcherButton: byRole('button', { name: 'Add matcher' }),
@ -210,6 +209,7 @@ describe('Silence edit', () => {
const start = new Date(); const start = new Date();
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const now = dateTime().format('YYYY-MM-DD HH:mm');
const startDateString = dateTime(start).format('YYYY-MM-DD'); const startDateString = dateTime(start).format('YYYY-MM-DD');
const endDateString = dateTime(end).format('YYYY-MM-DD'); const endDateString = dateTime(end).format('YYYY-MM-DD');
@ -247,17 +247,13 @@ describe('Silence edit', () => {
userEvent.tab(); userEvent.tab();
userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging'); userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging');
userEvent.type(ui.editor.comment.get(), 'Test');
userEvent.type(ui.editor.createdBy.get(), 'Homer Simpson');
userEvent.click(ui.editor.submit.get()); userEvent.click(ui.editor.submit.get());
await waitFor(() => await waitFor(() =>
expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith(
'grafana', 'grafana',
expect.objectContaining({ expect.objectContaining({
comment: 'Test', comment: `created ${now}`,
createdBy: 'Homer Simpson',
matchers: [ matchers: [
{ isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, { isEqual: true, isRegex: false, name: 'foo', value: 'bar' },
{ isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' },

View File

@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useDebounce } from 'react-use';
import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, useStyles2 } from '@grafana/ui';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { RuleState } from '../rules/RuleState';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { Annotation } from '../../utils/constants';
import { findAlertRulesWithMatchers } from '../../utils/matchers';
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
import { CombinedRule } from 'app/types/unified-alerting';
import { MatcherFieldValue, SilenceFormFields } from '../../types/silence-form';
type MatchedRulesTableItemProps = DynamicTableItemProps<{
matchedRule: CombinedRule;
}>;
type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedRule: CombinedRule }>;
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 matchedRules = combinedNamespaces.flatMap((namespace) => {
return namespace.groups.flatMap((group) => {
return findAlertRulesWithMatchers(group.rules, matchers);
});
});
setMatchedAlertRules(matchedRules);
},
500,
[combinedNamespaces, matchers]
);
return (
<div>
<h4 className={styles.title}>
Affected alerts
{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.slice(0, 5) ?? []} isExpandable={false} cols={columns} />
{matchedAlertRules.length > 5 && (
<div className={styles.moreMatches}>and {matchedAlertRules.length - 5} more</div>
)}
</>
)}
</div>
</div>
);
};
function useColumns(): MatchedRulesTableColumnProps[] {
return [
{
id: 'state',
label: 'State',
renderCell: function renderStateTag({ data: { matchedRule } }) {
return <RuleState rule={matchedRule} isCreating={false} isDeleting={false} />;
},
size: '160px',
},
{
id: 'name',
label: 'Name',
renderCell: function renderName({ data: { matchedRule } }) {
return matchedRule.name;
},
size: '250px',
},
{
id: 'summary',
label: 'Summary',
renderCell: function renderSummary({ data: { matchedRule } }) {
return matchedRule.annotations[Annotation.summary] ?? '';
},
size: '400px',
},
];
}
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

@ -128,7 +128,7 @@ const getStyles = (theme: GrafanaTheme2) => {
min-width: 140px; min-width: 140px;
`, `,
matchers: css` matchers: css`
max-width: 585px; max-width: ${theme.breakpoints.values.sm}px;
margin: ${theme.spacing(1)} 0; margin: ${theme.spacing(1)} 0;
padding-top: ${theme.spacing(0.5)}; padding-top: ${theme.spacing(0.5)};
`, `,

View File

@ -14,6 +14,7 @@ import { useDebounce } from 'react-use';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { pickBy } from 'lodash'; import { pickBy } from 'lodash';
import MatchersField from './MatchersField'; import MatchersField from './MatchersField';
import { MatchedSilencedRules } from './MatchedSilencedRules';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { SilenceFormFields } from '../../types/silence-form'; import { SilenceFormFields } from '../../types/silence-form';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@ -79,7 +80,7 @@ const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence):
id: '', id: '',
startsAt: now.toISOString(), startsAt: now.toISOString(),
endsAt: endsAt.toISOString(), endsAt: endsAt.toISOString(),
comment: '', comment: `created ${dateTime().format('YYYY-MM-DD HH:mm')}`,
createdBy: config.bootData.user.name, createdBy: config.bootData.user.name,
duration: '2h', duration: '2h',
isRegex: false, isRegex: false,
@ -164,7 +165,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
<FormProvider {...formAPI}> <FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<FieldSet label={`${silence ? 'Recreate silence' : 'Create silence'}`}> <FieldSet label={`${silence ? 'Recreate silence' : 'Create silence'}`}>
<div className={styles.flexRow}> <div className={cx(styles.flexRow, styles.silencePeriod)}>
<SilencePeriod /> <SilencePeriod />
<Field <Field
label="Duration" label="Duration"
@ -197,18 +198,11 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
> >
<TextArea <TextArea
{...register('comment', { required: { value: true, message: 'Required.' } })} {...register('comment', { required: { value: true, message: 'Required.' } })}
rows={5}
placeholder="Details about the silence" placeholder="Details about the silence"
/> />
</Field> </Field>
<Field <MatchedSilencedRules />
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.' } })} placeholder="User" />
</Field>
</FieldSet> </FieldSet>
<div className={styles.flexRow}> <div className={styles.flexRow}>
{loading && ( {loading && (
@ -235,7 +229,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: ${theme.spacing(1, 0)}; margin: ${theme.spacing(1, 0)};
`, `,
textArea: css` textArea: css`
width: 600px; max-width: ${theme.breakpoints.values.sm}px;
`, `,
createdBy: css` createdBy: css`
width: 200px; width: 200px;
@ -249,6 +243,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-right: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)};
} }
`, `,
silencePeriod: css`
max-width: ${theme.breakpoints.values.sm}px;
`,
}); });
export default SilencesEditor; export default SilencesEditor;

View File

@ -16,7 +16,7 @@ import {
RulerRuleGroupDTO, RulerRuleGroupDTO,
RulerRulesConfigDTO, RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace, CombinedRule } from 'app/types/unified-alerting';
import DatasourceSrv from 'app/features/plugins/datasource_srv'; import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime'; import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime';
import { import {
@ -430,3 +430,22 @@ export const someRulerRules: RulerRulesConfigDTO = {
], ],
namespace2: [mockRulerRuleGroup({ name: 'group3', rules: [mockRulerAlertingRule({ alert: 'alert3' })] })], namespace2: [mockRulerRuleGroup({ name: 'group3', rules: [mockRulerAlertingRule({ alert: 'alert3' })] })],
}; };
export const mockCombinedRule = (partial?: Partial<CombinedRule>): CombinedRule => ({
name: 'mockRule',
query: 'expr',
group: {
name: 'mockCombinedRuleGroup',
rules: [],
},
namespace: {
name: 'mockCombinedNamespace',
groups: [{ name: 'mockCombinedRuleGroup', rules: [] }],
rulesSource: 'grafana',
},
labels: {},
annotations: {},
promRule: mockPromAlertingRule(),
rulerRule: mockRulerAlertingRule(),
...partial,
});

View File

@ -1,4 +1,6 @@
import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers'; import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { getMatcherQueryParams, findAlertRulesWithMatchers, parseQueryParamMatchers } from './matchers';
import { mockCombinedRule } from '../mocks';
describe('Unified Alerting matchers', () => { describe('Unified Alerting matchers', () => {
describe('getMatcherQueryParams tests', () => { describe('getMatcherQueryParams tests', () => {
@ -33,4 +35,46 @@ describe('Unified Alerting matchers', () => {
expect(matchers[0].value).toBe('TestData 1'); expect(matchers[0].value).toBe('TestData 1');
}); });
}); });
describe('matchLabelsToMatchers', () => {
it('should match for equal', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.equal }];
const rules = [mockCombinedRule({ labels: { foo: 'bar' } }), mockCombinedRule({ labels: { foo: 'baz' } })];
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
expect(matchedRules).toHaveLength(1);
});
it('should match for not equal', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notEqual }];
const rules = [mockCombinedRule({ labels: { foo: 'bar' } }), mockCombinedRule({ labels: { foo: 'baz' } })];
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
expect(matchedRules).toHaveLength(1);
});
it('should match for regex', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.regex }];
const rules = [
mockCombinedRule({ labels: { foo: 'bar' } }),
mockCombinedRule({ labels: { foo: 'baz' } }),
mockCombinedRule({ labels: { foo: 'bas' } }),
];
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
expect(matchedRules).toHaveLength(1);
});
it('should not match regex', () => {
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notRegex }];
const rules = [
mockCombinedRule({ labels: { foo: 'bar' } }),
mockCombinedRule({ labels: { foo: 'baz' } }),
mockCombinedRule({ labels: { foo: 'bas' } }),
];
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
expect(matchedRules).toHaveLength(2);
});
});
}); });

View File

@ -1,7 +1,9 @@
import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { Matcher, MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from '@grafana/data'; import { Labels } from '@grafana/data';
import { parseMatcher } from './alertmanager'; import { parseMatcher } from './alertmanager';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { MatcherFieldValue } from '../types/silence-form';
import { CombinedRule } from 'app/types/unified-alerting';
// Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[] // Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[]
export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
@ -24,3 +26,43 @@ export const getMatcherQueryParams = (labels: Labels) => {
return matcherUrlParams; return matcherUrlParams;
}; };
interface MatchedRule {
id: string;
data: {
matchedRule: CombinedRule;
};
}
export const findAlertRulesWithMatchers = (rules: CombinedRule[], matchers: MatcherFieldValue[]): MatchedRule[] => {
const hasMatcher = (rule: CombinedRule, matcher: MatcherFieldValue) => {
return Object.entries(rule.labels).some(([key, value]) => {
if (!matcher.name || !matcher.value) {
return false;
}
if (matcher.operator === MatcherOperator.equal) {
return matcher.name === key && matcher.value === value;
}
if (matcher.operator === MatcherOperator.notEqual) {
return matcher.name === key && matcher.value !== value;
}
if (matcher.operator === MatcherOperator.regex) {
return matcher.name === key && matcher.value.match(value);
}
if (matcher.operator === MatcherOperator.notRegex) {
return matcher.name === key && !matcher.value.match(value);
}
return false;
});
};
const filteredRules = rules.filter((rule) => {
return matchers.every((matcher) => hasMatcher(rule, matcher));
});
const mappedRules = filteredRules.map((rule) => ({
id: `${rule.namespace}-${rule.name}`,
data: { matchedRule: rule },
}));
return mappedRules;
};