From a1c61473742d2c7a093c54c3e1a9fec644b23ee2 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 26 Jul 2022 17:00:04 +0200 Subject: [PATCH] Alerting: Group alert state history by labels and allow filtering (#52784) --- .../components/rules/StateHistory.test.tsx | 83 +++++++++ .../unified/components/rules/StateHistory.tsx | 157 +++++++++++++----- .../__snapshots__/StateHistory.test.tsx.snap | 51 ++++++ public/app/types/unified-alerting.ts | 2 +- 4 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/StateHistory.test.tsx create mode 100644 public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx b/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx new file mode 100644 index 00000000000..6ffd29f5899 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx @@ -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(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.tsx b/public/app/features/alerting/unified/components/rules/StateHistory.tsx index 8426740224a..2cac89d8a0a 100644 --- a/public/app/features/alerting/unified/components/rules/StateHistory.tsx +++ b/public/app/features/alerting/unified/components/rules/StateHistory.tsx @@ -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; + type StateHistoryRow = DynamicTableItemProps; interface RuleStateHistoryProps { @@ -28,8 +31,15 @@ interface RuleStateHistoryProps { } const StateHistory: FC = ({ alertId }) => { + const [textFilter, setTextFilter] = useState(''); + const handleTextFilter = useCallback((event: FormEvent) => { + setTextFilter(event.currentTarget.value); + }, []); + const { loading, error, result = [] } = useManagedAlertStateHistory(alertId); + const styles = useStyles2(getStyles); + if (loading && !error) { return ; } @@ -44,31 +54,105 @@ const StateHistory: FC = ({ 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 ( +
+
+ {groupKey} +
+ +
+ ); + }); - return acc; - }, []) - .map((historyItem) => ({ - id: historyItem.id, - data: historyItem, - })); - - return ; + return ( +
+
+ } + > + + + + + } + > + } onChange={handleTextFilter} placeholder="Search" /> + + + {tables} + + ); }; +// group state history by labels +export function groupStateByLabels( + history: Array> +): StateHistoryMap { + const items: StateHistoryRowItem[] = history.map((item) => { + // let's grab the last matching set of `{}` 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 "//" +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
{children}
; }; @@ -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 }; diff --git a/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap b/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap new file mode 100644 index 00000000000..6068c9caec1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap @@ -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, + }, + ], +} +`; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index c12cc41de7a..e8e0427ad94 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -158,7 +158,7 @@ interface EvalMatch { } export interface StateHistoryItemData { - noData: boolean; + noData?: boolean; evalMatches?: EvalMatch[]; }