From 02039d75320afdbfb8fba34ece38492dbdf41aad Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 14 Dec 2021 16:36:54 +0100 Subject: [PATCH] Alerting: show state history (#42362) --- .../alerting/unified/api/annotations.test.ts | 18 +++ .../alerting/unified/api/annotations.ts | 8 ++ .../components/rules/AlertStateTag.tsx | 3 +- .../rules/RuleDetailsActionButtons.tsx | 24 +++- .../unified/components/rules/StateHistory.tsx | 126 ++++++++++++++++++ .../hooks/useManagedAlertStateHistory.ts | 19 +++ .../unified/hooks/useStateHistoryModal.tsx | 30 +++++ .../alerting/unified/state/actions.ts | 8 +- .../alerting/unified/state/reducers.ts | 2 + .../features/alerting/unified/utils/rules.ts | 11 +- public/app/types/unified-alerting-dto.ts | 1 + public/app/types/unified-alerting.ts | 34 ++++- 12 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 public/app/features/alerting/unified/api/annotations.test.ts create mode 100644 public/app/features/alerting/unified/api/annotations.ts create mode 100644 public/app/features/alerting/unified/components/rules/StateHistory.tsx create mode 100644 public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts create mode 100644 public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx diff --git a/public/app/features/alerting/unified/api/annotations.test.ts b/public/app/features/alerting/unified/api/annotations.test.ts new file mode 100644 index 00000000000..ca52b08ca65 --- /dev/null +++ b/public/app/features/alerting/unified/api/annotations.test.ts @@ -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 }); + }); +}); diff --git a/public/app/features/alerting/unified/api/annotations.ts b/public/app/features/alerting/unified/api/annotations.ts new file mode 100644 index 00000000000..2df5ee2cacd --- /dev/null +++ b/public/app/features/alerting/unified/api/annotations.ts @@ -0,0 +1,8 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { StateHistoryItem } from 'app/types/unified-alerting'; + +export function fetchAnnotations(alertId: string): Promise { + return getBackendSrv().get('/api/annotations', { + alertId, + }); +} diff --git a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx index 2f64c2b476d..efeaa6596dd 100644 --- a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx @@ -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 = ({ state }) => ( diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index b1c0f7449c2..ea1c1ce3247 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -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 = ({ rule, rulesSource }) => { const style = useStyles2(getStyles); const { namespace, group, rulerRule } = rule; const [ruleToDelete, setRuleToDelete] = useState(); + 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 = ({ rule, rulesSource }) => { ); } + if (alertId) { + leftButtons.push( + + + {StateHistoryModal} + + ); + } + if (!isViewMode) { rightButtons.push( ({ font-size: ${theme.typography.size.sm}; `, }); + +function isGrafanaRulerRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO { + if (!rule) { + return false; + } + return (rule as RulerGrafanaRuleDTO).grafana_alert != null; +} diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.tsx b/public/app/features/alerting/unified/components/rules/StateHistory.tsx new file mode 100644 index 00000000000..f7050eb198c --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/StateHistory.tsx @@ -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; + +interface RuleStateHistoryProps { + alertId: string; +} + +const StateHistory: FC = ({ alertId }) => { + const { loading, error, result = [] } = useManagedAlertStateHistory(alertId); + + if (loading && !error) { + return ; + } + + if (error && !loading) { + return {error.message}; + } + + const columns: Array> = [ + { 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 ; +}; + +function renderValueCell(item: StateHistoryRow) { + const matches = item.data.data?.evalMatches ?? []; + + return ( + <> + {item.data.text} + + {matches.map((match) => ( + + ))} + + + ); +} + +function renderStateCell(item: StateHistoryRow) { + return ; +} + +function renderTimestampCell(item: StateHistoryRow) { + return ( +
{item.data.timestamp && {dateTimeFormat(item.data.timestamp)}}
+ ); +} + +const LabelsWrapper: FC<{}> = ({ children }) => { + const { wrapper } = useStyles(getStyles); + return
{children}
; +}; + +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 }; diff --git a/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts b/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts new file mode 100644 index 00000000000..8fcb6a2201e --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts @@ -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>( + (state) => state.managedAlertStateHistory + ); + + useEffect(() => { + dispatch(fetchGrafanaAnnotationsAction(alertId)); + }, [dispatch, alertId]); + + return history; +} diff --git a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx new file mode 100644 index 00000000000..980977e60e5 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -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(false); + + const StateHistoryModal = useMemo( + () => ( + setShowModal(false)} + closeOnBackdropClick={true} + closeOnEscape={true} + title="State history" + > + + + ), + [alertId, showModal] + ); + + return { + StateHistoryModal, + showStateHistoryModal: () => setShowModal(true), + hideStateHistoryModal: () => setShowModal(false), + }; +} + +export { useStateHistoryModal }; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 7a4ba99dd58..d7a10fa2b16 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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 => withSerializedError(fetchNotifiers()) ); +export const fetchGrafanaAnnotationsAction = createAsyncThunk( + 'unifiedalerting/fetchGrafanaAnnotations', + (alertId: string): Promise => withSerializedError(fetchAnnotations(alertId)) +); + interface UpdateAlertManagerConfigActionOptions { alertManagerSourceName: string; oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index fd9636986ad..65f1aa8b157 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -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; diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 064404ed5ae..b708cd53dfe 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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 = { +export const alertStateToState: Record = { [PromAlertingRuleState.Inactive]: 'good', [PromAlertingRuleState.Firing]: 'bad', [PromAlertingRuleState.Pending]: 'warning', @@ -93,6 +94,12 @@ export const alertStateToState: Record