Alerting: Group alert state history by labels and allow filtering (#52784)

This commit is contained in:
Gilles De Mey 2022-07-26 17:00:04 +02:00 committed by GitHub
parent f3cc54f7ef
commit a1c6147374
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 251 additions and 42 deletions

View File

@ -0,0 +1,83 @@
import { AlertState } from '@grafana/data';
import { groupStateByLabels, matchKey } from './StateHistory';
describe('matchKey', () => {
it('should match with exact string match', () => {
const groups = ['{ foo=bar, baz=qux }', '{ abc=def, ghi=jkl }'];
const filter = 'foo=bar';
const results = groups.filter((group) => matchKey(group, filter));
expect(results).toStrictEqual([groups[0]]);
});
it('should match with regex match', () => {
const groups = ['{ foo=bar, baz=qux }', '{ abc=def, ghi=jkl }'];
const filter = '/abc=.*/';
const results = groups.filter((group) => matchKey(group, filter));
expect(results).toStrictEqual([groups[1]]);
});
it('should match everything with empty filter', () => {
const groups = ['{ foo=bar, baz=qux }', '{ abc=def, ghi=jkl }'];
const filter = '';
const results = groups.filter((group) => matchKey(group, filter));
expect(results).toStrictEqual(groups);
});
it('should match nothing with invalid regex', () => {
const groups = ['{ foo=bar, baz=qux }', '{ abc=def, ghi=jkl }'];
const filter = '[';
const results = groups.filter((group) => matchKey(group, filter));
expect(results).toStrictEqual([]);
});
});
describe('groupStateByLabels', () => {
it('should group a list by labels', () => {
const history = [
{
id: 1,
newState: AlertState.Alerting,
updated: 1658834395024,
text: 'CPU Usage {cpu=0, type=cpu} - Alerting',
data: {},
},
{
id: 2,
newState: AlertState.OK,
updated: 1658834346935,
text: 'CPU Usage {cpu=1, type=cpu} - Normal',
data: {},
},
];
const grouped = groupStateByLabels(history);
expect(grouped).toMatchSnapshot();
});
it('should group a list by labels even if the alert rule name has {}', () => {
const history = [
{
id: 1,
newState: AlertState.Alerting,
updated: 1658834395024,
text: 'CPU Usage {some} {curly stuff} {cpu=0, type=cpu} - Alerting',
data: {},
},
{
id: 2,
newState: AlertState.OK,
updated: 1658834346935,
text: 'CPU Usage {some} {curly stuff} {cpu=1, type=cpu} - Normal',
data: {},
},
];
const grouped = groupStateByLabels(history);
expect(grouped).toMatchSnapshot();
});
});

View File

@ -1,9 +1,9 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { FC } from 'react';
import { groupBy } from 'lodash';
import React, { FC, FormEvent, useCallback, useState } from 'react';
import { AlertState, dateTimeFormat, GrafanaTheme } from '@grafana/data';
import { Alert, LoadingPlaceholder, useStyles } from '@grafana/ui';
import { AlertState, dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
import { Alert, Field, Icon, Input, Label, LoadingPlaceholder, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { StateHistoryItem, StateHistoryItemData } from 'app/types/unified-alerting';
import { GrafanaAlertStateWithReason, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
@ -19,8 +19,11 @@ type StateHistoryRowItem = {
text?: string;
data?: StateHistoryItemData;
timestamp?: number;
stringifiedLabels: string;
};
type StateHistoryMap = Record<string, StateHistoryRowItem[]>;
type StateHistoryRow = DynamicTableItemProps<StateHistoryRowItem>;
interface RuleStateHistoryProps {
@ -28,8 +31,15 @@ interface RuleStateHistoryProps {
}
const StateHistory: FC<RuleStateHistoryProps> = ({ alertId }) => {
const [textFilter, setTextFilter] = useState<string>('');
const handleTextFilter = useCallback((event: FormEvent<HTMLInputElement>) => {
setTextFilter(event.currentTarget.value);
}, []);
const { loading, error, result = [] } = useManagedAlertStateHistory(alertId);
const styles = useStyles2(getStyles);
if (loading && !error) {
return <LoadingPlaceholder text={'Loading history...'} />;
}
@ -44,31 +54,105 @@ const StateHistory: FC<RuleStateHistoryProps> = ({ alertId }) => {
{ id: 'timestamp', label: 'Time', size: 'max-content', renderCell: renderTimestampCell },
];
const items: StateHistoryRow[] = result
.reduce((acc: StateHistoryRowItem[], item, index) => {
acc.push({
id: String(item.id),
state: item.newState,
text: item.text,
data: item.data,
timestamp: item.updated,
});
// group the state history list by unique set of labels
const tables = Object.entries(groupStateByLabels(result))
// sort and filter each table
.sort()
.filter(([groupKey]) => matchKey(groupKey, textFilter))
.map(([groupKey, items]) => {
const tableItems: StateHistoryRow[] = items.map((historyItem) => ({
id: historyItem.id,
data: historyItem,
}));
// if the preceding state is not the same, create a separate state entry this likely means the state was reset
if (!hasMatchingPrecedingState(index, result)) {
acc.push({ id: uniqueId(), state: item.prevState });
}
return (
<div key={groupKey}>
<header className={styles.tableGroupKey}>
<code>{groupKey}</code>
</header>
<DynamicTable cols={columns} items={tableItems} />
</div>
);
});
return acc;
}, [])
.map((historyItem) => ({
id: historyItem.id,
data: historyItem,
}));
return <DynamicTable cols={columns} items={items} />;
return (
<div>
<nav>
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Filter group</span>
<Tooltip
content={
<div>
Filter each state history group either by exact match or a regular expression, ex:{' '}
<code>{`region=eu-west-1`}</code> or <code>{`/region=us-.+/`}</code>
</div>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Input prefix={<Icon name={'search'} />} onChange={handleTextFilter} placeholder="Search" />
</Field>
</nav>
{tables}
</div>
);
};
// group state history by labels
export function groupStateByLabels(
history: Array<Pick<StateHistoryItem, 'id' | 'newState' | 'text' | 'data' | 'updated'>>
): StateHistoryMap {
const items: StateHistoryRowItem[] = history.map((item) => {
// let's grab the last matching set of `{<string>}` since the alert name could also contain { or }
const LABELS_REGEX = /{.*?}/g;
const stringifiedLabels = item.text.match(LABELS_REGEX)?.at(-1) ?? '';
return {
id: String(item.id),
state: item.newState,
// let's omit the labels for each entry since it's just added noise to each state history item
text: item.text.replace(stringifiedLabels, ''),
data: item.data,
timestamp: item.updated,
stringifiedLabels,
};
});
// we have to group our state history items by their unique combination of tags since we want to display a DynamicTable for each alert instance
// (effectively unique combination of labels)
return groupBy(items, (item) => item.stringifiedLabels);
}
// match a string either by exact text match or with regular expression when in the form of "/<regex>/"
export function matchKey(groupKey: string, textFilter: string) {
// if the text filter is empty we show all matches
if (textFilter === '') {
return true;
}
const isRegExp = textFilter.startsWith('/') && textFilter.endsWith('/');
// not a regular expression, use normal text matching
if (!isRegExp) {
return groupKey.includes(textFilter);
}
// regular expression, try parsing and applying
// when we fail to parse the text as a regular expression, we return no match
try {
return new RegExp(textFilter.slice(1, -1)).test(groupKey);
} catch (err) {
return false;
}
}
function renderValueCell(item: StateHistoryRow) {
const matches = item.data.data?.evalMatches ?? [];
@ -95,7 +179,7 @@ function renderTimestampCell(item: StateHistoryRow) {
}
const LabelsWrapper: FC<{}> = ({ children }) => {
const { wrapper } = useStyles(getStyles);
const { wrapper } = useStyles2(getStyles);
return <div className={wrapper}>{children}</div>;
};
@ -105,25 +189,16 @@ const TimestampStyle = css`
flex-direction: column;
`;
const getStyles = (theme: GrafanaTheme) => ({
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
& > * {
margin-right: ${theme.spacing.xs};
margin-right: ${theme.spacing(1)};
}
`,
tableGroupKey: css`
margin-top: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(2)};
`,
});
// this function will figure out if a given historyItem has a preceding historyItem where the states match - in other words
// the newState of the previous historyItem is the same as the prevState of the current historyItem
function hasMatchingPrecedingState(index: number, items: StateHistoryItem[]): boolean {
const currentHistoryItem = items[index];
const previousHistoryItem = items[index + 1];
if (!previousHistoryItem) {
return false;
}
return previousHistoryItem.newState === currentHistoryItem.prevState;
}
export { StateHistory };

View File

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`groupStateByLabels should group a list by labels 1`] = `
Object {
"{cpu=0, type=cpu}": Array [
Object {
"data": Object {},
"id": "1",
"state": "alerting",
"stringifiedLabels": "{cpu=0, type=cpu}",
"text": "CPU Usage - Alerting",
"timestamp": 1658834395024,
},
],
"{cpu=1, type=cpu}": Array [
Object {
"data": Object {},
"id": "2",
"state": "ok",
"stringifiedLabels": "{cpu=1, type=cpu}",
"text": "CPU Usage - Normal",
"timestamp": 1658834346935,
},
],
}
`;
exports[`groupStateByLabels should group a list by labels even if the alert rule name has {} 1`] = `
Object {
"{cpu=0, type=cpu}": Array [
Object {
"data": Object {},
"id": "1",
"state": "alerting",
"stringifiedLabels": "{cpu=0, type=cpu}",
"text": "CPU Usage {some} {curly stuff} - Alerting",
"timestamp": 1658834395024,
},
],
"{cpu=1, type=cpu}": Array [
Object {
"data": Object {},
"id": "2",
"state": "ok",
"stringifiedLabels": "{cpu=1, type=cpu}",
"text": "CPU Usage {some} {curly stuff} - Normal",
"timestamp": 1658834346935,
},
],
}
`;

View File

@ -158,7 +158,7 @@ interface EvalMatch {
}
export interface StateHistoryItemData {
noData: boolean;
noData?: boolean;
evalMatches?: EvalMatch[];
}