mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: show state history (#42362)
This commit is contained in:
parent
dc57bcd458
commit
02039d7532
18
public/app/features/alerting/unified/api/annotations.test.ts
Normal file
18
public/app/features/alerting/unified/api/annotations.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
8
public/app/features/alerting/unified/api/annotations.ts
Normal file
8
public/app/features/alerting/unified/api/annotations.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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 }) => (
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 };
|
@ -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;
|
||||
}
|
@ -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 };
|
@ -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
|
||||
|
@ -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>;
|
||||
|
@ -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) {
|
||||
|
@ -124,6 +124,7 @@ export interface PostableGrafanaRuleDefinition {
|
||||
data: AlertQuery[];
|
||||
}
|
||||
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||
id?: string;
|
||||
uid: string;
|
||||
namespace_uid: string;
|
||||
namespace_id: number;
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user