Alerting: show state history (#42362)

This commit is contained in:
Gilles De Mey 2021-12-14 16:36:54 +01:00 committed by GitHub
parent dc57bcd458
commit 02039d7532
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 6 deletions

View File

@ -0,0 +1,18 @@
import '@grafana/runtime';
import { fetchAnnotations } from './annotations';
const get = jest.fn();
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({ get }),
}));
describe('annotations', () => {
beforeEach(() => get.mockClear());
it('should fetch annotation for an alertId', () => {
const ALERT_ID = 'abc123';
fetchAnnotations(ALERT_ID);
expect(get).toBeCalledWith('/api/annotations', { alertId: ALERT_ID });
});
});

View File

@ -0,0 +1,8 @@
import { getBackendSrv } from '@grafana/runtime';
import { StateHistoryItem } from 'app/types/unified-alerting';
export function fetchAnnotations(alertId: string): Promise<StateHistoryItem[]> {
return getBackendSrv().get('/api/annotations', {
alertId,
});
}

View File

@ -1,9 +1,10 @@
import { AlertState } from '@grafana/data';
import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import React, { FC } from 'react';
import { alertStateToReadable, alertStateToState } from '../../utils/rules';
import { StateTag } from '../StateTag';
interface Props {
state: PromAlertingRuleState | GrafanaAlertState;
state: PromAlertingRuleState | GrafanaAlertState | AlertState;
}
export const AlertStateTag: FC<Props> = ({ state }) => (

View File

@ -1,4 +1,4 @@
import React, { FC, useState } from 'react';
import React, { FC, Fragment, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { css } from '@emotion/css';
@ -15,6 +15,8 @@ import * as ruleId from '../../utils/rule-id';
import { deleteRuleAction } from '../../state/actions';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
interface Props {
rule: CombinedRule;
@ -27,6 +29,8 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
const style = useStyles2(getStyles);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const alertId = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.id ?? '' : '';
const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(alertId);
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
? rulesSource
@ -143,6 +147,17 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
);
}
if (alertId) {
leftButtons.push(
<Fragment key="history">
<Button className={style.button} size="xs" icon="history" onClick={() => showStateHistoryModal()}>
Show state history
</Button>
{StateHistoryModal}
</Fragment>
);
}
if (!isViewMode) {
rightButtons.push(
<LinkButton
@ -249,3 +264,10 @@ export const getStyles = (theme: GrafanaTheme2) => ({
font-size: ${theme.typography.size.sm};
`,
});
function isGrafanaRulerRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
if (!rule) {
return false;
}
return (rule as RulerGrafanaRuleDTO).grafana_alert != null;
}

View File

@ -0,0 +1,126 @@
import React, { FC } from 'react';
import { uniqueId } from 'lodash';
import { AlertState, dateTimeFormat, GrafanaTheme } from '@grafana/data';
import { Alert, LoadingPlaceholder, useStyles } from '@grafana/ui';
import { css } from '@emotion/css';
import { StateHistoryItem, StateHistoryItemData } from 'app/types/unified-alerting';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AlertStateTag } from './AlertStateTag';
import { useManagedAlertStateHistory } from '../../hooks/useManagedAlertStateHistory';
import { AlertLabel } from '../AlertLabel';
import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
type StateHistoryRowItem = {
id: string;
state: PromAlertingRuleState | GrafanaAlertState | AlertState;
text?: string;
data?: StateHistoryItemData;
timestamp?: number;
};
type StateHistoryRow = DynamicTableItemProps<StateHistoryRowItem>;
interface RuleStateHistoryProps {
alertId: string;
}
const StateHistory: FC<RuleStateHistoryProps> = ({ alertId }) => {
const { loading, error, result = [] } = useManagedAlertStateHistory(alertId);
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 },
];
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,
});
// 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 acc;
}, [])
.map((historyItem) => ({
id: historyItem.id,
data: historyItem,
}));
return <DynamicTable cols={columns} items={items} />;
};
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 } = useStyles(getStyles);
return <div className={wrapper}>{children}</div>;
};
const TimestampStyle = css`
display: flex;
align-items: flex-end;
flex-direction: column;
`;
const getStyles = (theme: GrafanaTheme) => ({
wrapper: css`
& > * {
margin-right: ${theme.spacing.xs};
}
`,
});
// 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,19 @@
import { StateHistoryItem } from 'app/types/unified-alerting';
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { fetchGrafanaAnnotationsAction } from '../state/actions';
import { AsyncRequestState } from '../utils/redux';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export function useManagedAlertStateHistory(alertId: string) {
const dispatch = useDispatch();
const history = useUnifiedAlertingSelector<AsyncRequestState<StateHistoryItem[]>>(
(state) => state.managedAlertStateHistory
);
useEffect(() => {
dispatch(fetchGrafanaAnnotationsAction(alertId));
}, [dispatch, alertId]);
return history;
}

View File

@ -0,0 +1,30 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '@grafana/ui';
import { StateHistory } from '../components/rules/StateHistory';
function useStateHistoryModal(alertId: string) {
const [showModal, setShowModal] = useState<boolean>(false);
const StateHistoryModal = useMemo(
() => (
<Modal
isOpen={showModal}
onDismiss={() => setShowModal(false)}
closeOnBackdropClick={true}
closeOnEscape={true}
title="State history"
>
<StateHistory alertId={alertId} />
</Modal>
),
[alertId, showModal]
);
return {
StateHistoryModal,
showStateHistoryModal: () => setShowModal(true),
hideStateHistoryModal: () => setShowModal(false),
};
}
export { useStateHistoryModal };

View File

@ -11,7 +11,7 @@ import {
TestReceiversAlert,
} from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types';
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
import { RuleIdentifier, RuleNamespace, RuleWithLocation, StateHistoryItem } from 'app/types/unified-alerting';
import {
PostableRulerRuleGroupDTO,
RulerGrafanaRuleDTO,
@ -19,6 +19,7 @@ import {
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { fetchNotifiers } from '../api/grafana';
import { fetchAnnotations } from '../api/annotations';
import {
expireSilence,
fetchAlertManagerConfig,
@ -446,6 +447,11 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk(
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
);
export const fetchGrafanaAnnotationsAction = createAsyncThunk(
'unifiedalerting/fetchGrafanaAnnotations',
(alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId))
);
interface UpdateAlertManagerConfigActionOptions {
alertManagerSourceName: string;
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile

View File

@ -19,6 +19,7 @@ import {
updateLotexNamespaceAndGroupAction,
fetchExternalAlertmanagersAction,
fetchExternalAlertmanagersConfigAction,
fetchGrafanaAnnotationsAction,
} from './actions';
export const reducer = combineReducers({
@ -60,6 +61,7 @@ export const reducer = combineReducers({
alertmanagerConfig: createAsyncSlice('alertmanagerConfig', fetchExternalAlertmanagersConfigAction).reducer,
discoveredAlertmanagers: createAsyncSlice('discoveredAlertmanagers', fetchExternalAlertmanagersAction).reducer,
}),
managedAlertStateHistory: createAsyncSlice('managedAlertStateHistory', fetchGrafanaAnnotationsAction).reducer,
});
export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@ -1,3 +1,4 @@
import { AlertState } from '@grafana/data';
import {
GrafanaAlertState,
PromAlertingRuleState,
@ -64,7 +65,7 @@ export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifi
return 'ruleHash' in identifier;
}
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string {
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState | AlertState): string {
if (state === PromAlertingRuleState.Inactive) {
return 'Normal';
}
@ -84,7 +85,7 @@ export const flattenRules = (rules: RuleNamespace[]) => {
}, []);
};
export const alertStateToState: Record<PromAlertingRuleState | GrafanaAlertState, State> = {
export const alertStateToState: Record<PromAlertingRuleState | GrafanaAlertState | AlertState, State> = {
[PromAlertingRuleState.Inactive]: 'good',
[PromAlertingRuleState.Firing]: 'bad',
[PromAlertingRuleState.Pending]: 'warning',
@ -93,6 +94,12 @@ export const alertStateToState: Record<PromAlertingRuleState | GrafanaAlertState
[GrafanaAlertState.NoData]: 'info',
[GrafanaAlertState.Normal]: 'good',
[GrafanaAlertState.Pending]: 'warning',
[AlertState.NoData]: 'info',
[AlertState.Paused]: 'warning',
[AlertState.Alerting]: 'bad',
[AlertState.OK]: 'good',
[AlertState.Pending]: 'warning',
[AlertState.Unknown]: 'info',
};
export function getFirstActiveAt(promRule: AlertingRule) {

View File

@ -124,6 +124,7 @@ export interface PostableGrafanaRuleDefinition {
data: AlertQuery[];
}
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
id?: string;
uid: string;
namespace_uid: string;
namespace_id: number;

View File

@ -1,6 +1,6 @@
/* Prometheus internal models */
import { DataSourceInstanceSettings } from '@grafana/data';
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
import {
PromAlertingRuleState,
PromRuleType,
@ -140,3 +140,35 @@ export interface SilenceFilterState {
queryString?: string;
silenceState?: string;
}
interface EvalMatch {
metric: string;
tags?: any;
value: number;
}
export interface StateHistoryItemData {
noData: boolean;
evalMatches?: EvalMatch[];
}
export interface StateHistoryItem {
id: number;
alertId: number;
alertName: string;
dashboardId: number;
panelId: number;
userId: number;
newState: AlertState;
prevState: AlertState;
created: number;
updated: number;
time: number;
timeEnd: number;
text: string;
tags: any[];
login: string;
email: string;
avatarUrl: string;
data: StateHistoryItemData;
}