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'),
|
||||
matcherValue: byPlaceholderText('value'),
|
||||
comment: byPlaceholderText('Details about the silence'),
|
||||
createdBy: byPlaceholderText('User'),
|
||||
matcherOperatorSelect: byLabelText('operator'),
|
||||
matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }),
|
||||
addMatcherButton: byRole('button', { name: 'Add matcher' }),
|
||||
@ -210,6 +209,7 @@ describe('Silence edit', () => {
|
||||
|
||||
const start = new Date();
|
||||
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 endDateString = dateTime(end).format('YYYY-MM-DD');
|
||||
@ -247,17 +247,13 @@ describe('Silence edit', () => {
|
||||
userEvent.tab();
|
||||
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());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith(
|
||||
'grafana',
|
||||
expect.objectContaining({
|
||||
comment: 'Test',
|
||||
createdBy: 'Homer Simpson',
|
||||
comment: `created ${now}`,
|
||||
matchers: [
|
||||
{ isEqual: true, isRegex: false, name: 'foo', value: 'bar' },
|
||||
{ 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;
|
||||
`,
|
||||
matchers: css`
|
||||
max-width: 585px;
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
margin: ${theme.spacing(1)} 0;
|
||||
padding-top: ${theme.spacing(0.5)};
|
||||
`,
|
||||
|
@ -14,6 +14,7 @@ import { useDebounce } from 'react-use';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { pickBy } from 'lodash';
|
||||
import MatchersField from './MatchersField';
|
||||
import { MatchedSilencedRules } from './MatchedSilencedRules';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { SilenceFormFields } from '../../types/silence-form';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@ -79,7 +80,7 @@ const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence):
|
||||
id: '',
|
||||
startsAt: now.toISOString(),
|
||||
endsAt: endsAt.toISOString(),
|
||||
comment: '',
|
||||
comment: `created ${dateTime().format('YYYY-MM-DD HH:mm')}`,
|
||||
createdBy: config.bootData.user.name,
|
||||
duration: '2h',
|
||||
isRegex: false,
|
||||
@ -164,7 +165,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldSet label={`${silence ? 'Recreate silence' : 'Create silence'}`}>
|
||||
<div className={styles.flexRow}>
|
||||
<div className={cx(styles.flexRow, styles.silencePeriod)}>
|
||||
<SilencePeriod />
|
||||
<Field
|
||||
label="Duration"
|
||||
@ -197,18 +198,11 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
||||
>
|
||||
<TextArea
|
||||
{...register('comment', { required: { value: true, message: 'Required.' } })}
|
||||
rows={5}
|
||||
placeholder="Details about the silence"
|
||||
/>
|
||||
</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.' } })} placeholder="User" />
|
||||
</Field>
|
||||
<MatchedSilencedRules />
|
||||
</FieldSet>
|
||||
<div className={styles.flexRow}>
|
||||
{loading && (
|
||||
@ -235,7 +229,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
margin: ${theme.spacing(1, 0)};
|
||||
`,
|
||||
textArea: css`
|
||||
width: 600px;
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
`,
|
||||
createdBy: css`
|
||||
width: 200px;
|
||||
@ -249,6 +243,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
margin-right: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
silencePeriod: css`
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
`,
|
||||
});
|
||||
|
||||
export default SilencesEditor;
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} 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, GetDataSourceListFilters, config } from '@grafana/runtime';
|
||||
import {
|
||||
@ -430,3 +430,22 @@ export const someRulerRules: RulerRulesConfigDTO = {
|
||||
],
|
||||
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('getMatcherQueryParams tests', () => {
|
||||
@ -33,4 +35,46 @@ 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 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 { parseMatcher } from './alertmanager';
|
||||
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[]
|
||||
export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
|
||||
@ -24,3 +26,43 @@ export const getMatcherQueryParams = (labels: Labels) => {
|
||||
|
||||
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