mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
Alerting: Group alert state history by labels and allow filtering (#52784)
This commit is contained in:
parent
f3cc54f7ef
commit
a1c6147374
@ -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();
|
||||
});
|
||||
});
|
@ -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 };
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
@ -158,7 +158,7 @@ interface EvalMatch {
|
||||
}
|
||||
|
||||
export interface StateHistoryItemData {
|
||||
noData: boolean;
|
||||
noData?: boolean;
|
||||
evalMatches?: EvalMatch[];
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user