mirror of
https://github.com/grafana/grafana.git
synced 2025-09-20 11:23:00 +08:00
Alerting: show state history (#42362)
This commit is contained in:
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 { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { alertStateToReadable, alertStateToState } from '../../utils/rules';
|
import { alertStateToReadable, alertStateToState } from '../../utils/rules';
|
||||||
import { StateTag } from '../StateTag';
|
import { StateTag } from '../StateTag';
|
||||||
interface Props {
|
interface Props {
|
||||||
state: PromAlertingRuleState | GrafanaAlertState;
|
state: PromAlertingRuleState | GrafanaAlertState | AlertState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlertStateTag: FC<Props> = ({ state }) => (
|
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 { useDispatch } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
@ -15,6 +15,8 @@ import * as ruleId from '../../utils/rule-id';
|
|||||||
import { deleteRuleAction } from '../../state/actions';
|
import { deleteRuleAction } from '../../state/actions';
|
||||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||||
import { getAlertmanagerByUid } from '../../utils/alertmanager';
|
import { getAlertmanagerByUid } from '../../utils/alertmanager';
|
||||||
|
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
|
||||||
|
import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rule: CombinedRule;
|
rule: CombinedRule;
|
||||||
@ -27,6 +29,8 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
const style = useStyles2(getStyles);
|
const style = useStyles2(getStyles);
|
||||||
const { namespace, group, rulerRule } = rule;
|
const { namespace, group, rulerRule } = rule;
|
||||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
||||||
|
const alertId = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.id ?? '' : '';
|
||||||
|
const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(alertId);
|
||||||
|
|
||||||
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
|
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
|
||||||
? 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) {
|
if (!isViewMode) {
|
||||||
rightButtons.push(
|
rightButtons.push(
|
||||||
<LinkButton
|
<LinkButton
|
||||||
@ -249,3 +264,10 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
font-size: ${theme.typography.size.sm};
|
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,
|
TestReceiversAlert,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { FolderDTO, NotifierDTO, ThunkResult } from 'app/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 {
|
import {
|
||||||
PostableRulerRuleGroupDTO,
|
PostableRulerRuleGroupDTO,
|
||||||
RulerGrafanaRuleDTO,
|
RulerGrafanaRuleDTO,
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
RulerRulesConfigDTO,
|
RulerRulesConfigDTO,
|
||||||
} from 'app/types/unified-alerting-dto';
|
} from 'app/types/unified-alerting-dto';
|
||||||
import { fetchNotifiers } from '../api/grafana';
|
import { fetchNotifiers } from '../api/grafana';
|
||||||
|
import { fetchAnnotations } from '../api/annotations';
|
||||||
import {
|
import {
|
||||||
expireSilence,
|
expireSilence,
|
||||||
fetchAlertManagerConfig,
|
fetchAlertManagerConfig,
|
||||||
@ -446,6 +447,11 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk(
|
|||||||
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
|
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fetchGrafanaAnnotationsAction = createAsyncThunk(
|
||||||
|
'unifiedalerting/fetchGrafanaAnnotations',
|
||||||
|
(alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId))
|
||||||
|
);
|
||||||
|
|
||||||
interface UpdateAlertManagerConfigActionOptions {
|
interface UpdateAlertManagerConfigActionOptions {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
||||||
|
@ -19,6 +19,7 @@ import {
|
|||||||
updateLotexNamespaceAndGroupAction,
|
updateLotexNamespaceAndGroupAction,
|
||||||
fetchExternalAlertmanagersAction,
|
fetchExternalAlertmanagersAction,
|
||||||
fetchExternalAlertmanagersConfigAction,
|
fetchExternalAlertmanagersConfigAction,
|
||||||
|
fetchGrafanaAnnotationsAction,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
export const reducer = combineReducers({
|
export const reducer = combineReducers({
|
||||||
@ -60,6 +61,7 @@ export const reducer = combineReducers({
|
|||||||
alertmanagerConfig: createAsyncSlice('alertmanagerConfig', fetchExternalAlertmanagersConfigAction).reducer,
|
alertmanagerConfig: createAsyncSlice('alertmanagerConfig', fetchExternalAlertmanagersConfigAction).reducer,
|
||||||
discoveredAlertmanagers: createAsyncSlice('discoveredAlertmanagers', fetchExternalAlertmanagersAction).reducer,
|
discoveredAlertmanagers: createAsyncSlice('discoveredAlertmanagers', fetchExternalAlertmanagersAction).reducer,
|
||||||
}),
|
}),
|
||||||
|
managedAlertStateHistory: createAsyncSlice('managedAlertStateHistory', fetchGrafanaAnnotationsAction).reducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AlertState } from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
GrafanaAlertState,
|
GrafanaAlertState,
|
||||||
PromAlertingRuleState,
|
PromAlertingRuleState,
|
||||||
@ -64,7 +65,7 @@ export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifi
|
|||||||
return 'ruleHash' in identifier;
|
return 'ruleHash' in identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string {
|
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState | AlertState): string {
|
||||||
if (state === PromAlertingRuleState.Inactive) {
|
if (state === PromAlertingRuleState.Inactive) {
|
||||||
return 'Normal';
|
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.Inactive]: 'good',
|
||||||
[PromAlertingRuleState.Firing]: 'bad',
|
[PromAlertingRuleState.Firing]: 'bad',
|
||||||
[PromAlertingRuleState.Pending]: 'warning',
|
[PromAlertingRuleState.Pending]: 'warning',
|
||||||
@ -93,6 +94,12 @@ export const alertStateToState: Record<PromAlertingRuleState | GrafanaAlertState
|
|||||||
[GrafanaAlertState.NoData]: 'info',
|
[GrafanaAlertState.NoData]: 'info',
|
||||||
[GrafanaAlertState.Normal]: 'good',
|
[GrafanaAlertState.Normal]: 'good',
|
||||||
[GrafanaAlertState.Pending]: 'warning',
|
[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) {
|
export function getFirstActiveAt(promRule: AlertingRule) {
|
||||||
|
@ -124,6 +124,7 @@ export interface PostableGrafanaRuleDefinition {
|
|||||||
data: AlertQuery[];
|
data: AlertQuery[];
|
||||||
}
|
}
|
||||||
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||||
|
id?: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
namespace_uid: string;
|
namespace_uid: string;
|
||||||
namespace_id: number;
|
namespace_id: number;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/* Prometheus internal models */
|
/* Prometheus internal models */
|
||||||
|
|
||||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
PromAlertingRuleState,
|
PromAlertingRuleState,
|
||||||
PromRuleType,
|
PromRuleType,
|
||||||
@ -140,3 +140,35 @@ export interface SilenceFilterState {
|
|||||||
queryString?: string;
|
queryString?: string;
|
||||||
silenceState?: 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;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user