mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
dc0a2fb55b
commit
552c24a66e
@ -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' },
|
||||||
|
@ -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)};
|
||||||
|
`,
|
||||||
|
});
|
@ -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)};
|
||||||
`,
|
`,
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user