Files

205 lines
6.4 KiB
TypeScript

import { css } from '@emotion/css';
import { groupBy } from 'lodash';
import React, { FC, FormEvent, useCallback, useState } from 'react';
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';
import { useManagedAlertStateHistory } from '../../hooks/useManagedAlertStateHistory';
import { AlertLabel } from '../AlertLabel';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AlertStateTag } from './AlertStateTag';
type StateHistoryRowItem = {
id: string;
state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState;
text?: string;
data?: StateHistoryItemData;
timestamp?: number;
stringifiedLabels: string;
};
type StateHistoryMap = Record<string, StateHistoryRowItem[]>;
type StateHistoryRow = DynamicTableItemProps<StateHistoryRowItem>;
interface RuleStateHistoryProps {
alertId: string;
}
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...'} />;
}
if (error && !loading) {
return <Alert title={'Failed to fetch alert state history'}>{error.message}</Alert>;
}
const columns: Array<DynamicTableColumnProps<StateHistoryRowItem>> = [
{ id: 'state', label: 'State', size: 'max-content', renderCell: renderStateCell },
{ id: 'value', label: '', size: 'auto', renderCell: renderValueCell },
{ id: 'timestamp', label: 'Time', size: 'max-content', renderCell: renderTimestampCell },
];
// 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,
}));
return (
<div key={groupKey}>
<header className={styles.tableGroupKey}>
<code>{groupKey}</code>
</header>
<DynamicTable cols={columns} items={tableItems} />
</div>
);
});
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 ?? [];
return (
<>
{item.data.text}
<LabelsWrapper>
{matches.map((match) => (
<AlertLabel key={match.metric} labelKey={match.metric} value={String(match.value)} />
))}
</LabelsWrapper>
</>
);
}
function renderStateCell(item: StateHistoryRow) {
return <AlertStateTag state={item.data.state} />;
}
function renderTimestampCell(item: StateHistoryRow) {
return (
<div className={TimestampStyle}>{item.data.timestamp && <span>{dateTimeFormat(item.data.timestamp)}</span>}</div>
);
}
const LabelsWrapper: FC<{}> = ({ children }) => {
const { wrapper } = useStyles2(getStyles);
return <div className={wrapper}>{children}</div>;
};
const TimestampStyle = css`
display: flex;
align-items: flex-end;
flex-direction: column;
`;
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
& > * {
margin-right: ${theme.spacing(1)};
}
`,
tableGroupKey: css`
margin-top: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(2)};
`,
});
export { StateHistory };