Extract alert history analytics model owner

This commit is contained in:
rcourtman
2026-03-22 17:58:13 +00:00
parent 6408ab9dec
commit 5c6bf8b306
8 changed files with 817 additions and 524 deletions
@@ -236,11 +236,15 @@ primitive.
The history tab itself now follows the same shell-versus-runtime rule. The
canonical history runtime owner is
`frontend-modern/src/features/alerts/useAlertHistoryState.ts`, which owns alert
history fetch, persistent filter state, trend-bucket derivation, grouped-row
projection, resource-incident panel loading, and history-clear flow. Future
alert history control-flow work should extend that feature hook instead of
putting data fetch or resource-incident state back into
`frontend-modern/src/features/alerts/useAlertHistoryState.ts`, which now owns
alert-history fetch, persistent filter state, resource-incident panel loading,
and history-clear flow, while the pure analytics model for history-item
projection, trend buckets, group labels, axis ticks, and selected bucket
detail now lives in
`frontend-modern/src/features/alerts/alertHistoryModel.ts`. Future alert
history control-flow work should extend the feature hook, while new grouping or
trend semantics should extend the history model instead of putting data fetch
or resource-incident state back into
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`.
Alert configuration load/save state, notification config reloads, and threshold
@@ -496,9 +496,12 @@ action timers or acknowledge mutations to the tab shell.
Alert history runtime now follows that same pattern. The shell stays in
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`, while
`frontend-modern/src/features/alerts/useAlertHistoryState.ts` owns history
fetch, persistent filters, grouped/trend derivation, resource-incident panel
state, and history-clear behavior. Future alert-history control flow should
extend that hook rather than rebuilding fetch and panel state in the tab shell.
fetch, persistent filters, resource-incident panel state, and history-clear
behavior, while `frontend-modern/src/features/alerts/alertHistoryModel.ts`
owns grouped/trend derivation and the bucket/range analytics contract. Future
alert-history control flow should extend the hook, and pure history analytics
should extend the model, rather than rebuilding either concern in the tab
shell.
Top-level settings surfaces must route through `Settings.tsx`,
`SettingsPageShell.tsx`, and
`frontend-modern/src/components/shared/SettingsPanel.tsx` instead of
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import {
buildAlertHistoryItems,
buildAlertHistoryParams,
buildAlertTrends,
buildSelectedBucketDetails,
formatAlertHistoryDuration,
getAlertBucketDurationLabel,
} from '../alertHistoryModel';
describe('alertHistoryModel', () => {
it('builds canonical history params for each range', () => {
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
expect(buildAlertHistoryParams('24h', now)).toEqual({
limit: 2000,
startTime: new Date(now - 24 * 60 * 60 * 1000).toISOString(),
});
expect(buildAlertHistoryParams('7d', now)).toEqual({
limit: 10000,
startTime: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(),
});
expect(buildAlertHistoryParams('30d', now)).toEqual({
limit: 10000,
startTime: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
});
expect(buildAlertHistoryParams('all', now)).toEqual({ limit: 0 });
});
it('formats durations across minute, hour, and day boundaries', () => {
const start = '2026-03-22T10:00:00.000Z';
expect(formatAlertHistoryDuration(start, '2026-03-22T10:45:00.000Z')).toBe('45m');
expect(formatAlertHistoryDuration(start, '2026-03-22T12:15:00.000Z')).toBe('2h 15m');
expect(formatAlertHistoryDuration(start, '2026-03-24T12:00:00.000Z')).toBe('2d 2h');
});
it('builds history items using canonical resource type resolution', () => {
const resource = {
id: 'resource-1',
name: 'vm-101',
displayName: 'vm-101',
type: 'vm',
} as unknown as Resource;
const activeAlerts: Record<string, Alert> = {
'alert-1': {
id: 'alert-1',
type: 'cpu',
level: 'critical',
resourceId: 'resource-1',
resourceName: 'vm-101',
node: 'px1',
message: 'CPU high',
startTime: '2026-03-22T09:00:00.000Z',
lastSeen: '2026-03-22T09:15:00.000Z',
value: 90,
threshold: 80,
acknowledged: false,
} as Alert,
};
const items = buildAlertHistoryItems({
activeAlerts,
alertHistory: [],
getResource: (resourceId) => (resourceId === 'resource-1' ? resource : undefined),
allResources: [resource],
now: Date.UTC(2026, 2, 22, 10, 0, 0),
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
id: 'alert-1',
resourceType: 'VM',
status: 'active',
title: 'CPU',
});
});
it('builds trends and selected bucket details from filtered alerts', () => {
const alerts = [
{ id: 'a', startTime: '2026-03-22T08:00:00.000Z' },
{ id: 'b', startTime: '2026-03-22T09:00:00.000Z' },
{ id: 'c', startTime: '2026-03-22T09:30:00.000Z' },
] as Array<{ id: string; startTime: string }>;
const trends = buildAlertTrends(alerts as any, '24h', Date.UTC(2026, 2, 22, 10, 0, 0));
expect(trends.bucketSize).toBe(1);
expect(trends.buckets.reduce((sum, value) => sum + value, 0)).toBe(3);
expect(getAlertBucketDurationLabel(trends.bucketSize)).toBe('1 hour');
const details = buildSelectedBucketDetails(1, trends, 'en-GB');
expect(details).not.toBeNull();
expect(details?.rangeLabel).toContain('Mar');
});
});
@@ -0,0 +1,637 @@
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers';
export const MS_PER_HOUR = 60 * 60 * 1000;
export type AlertHistoryRange = '24h' | '7d' | '30d' | 'all';
export type AlertSeverityFilter = 'all' | 'warning' | 'critical';
export interface HistoryItem {
id: string;
source: 'alert' | 'ai';
status: string;
startTime: string;
endTime?: string;
duration: string;
resourceName: string;
resourceType: string;
resourceId?: string;
node?: string;
nodeDisplayName?: string;
severity: string;
title: string;
rawAlertType?: string;
description?: string;
acknowledged?: boolean;
}
export interface AlertTrendSeries {
buckets: number[];
max: number;
bucketSize: number;
bucketTimes: number[];
rangeStart: number;
rangeHours: number;
}
export interface AlertAxisTick {
position: number;
label: string;
align: 'start' | 'center' | 'end';
}
export function buildAlertHistoryParams(range: AlertHistoryRange, now = Date.now()) {
const params: { limit?: number; startTime?: string } = {};
switch (range) {
case '24h':
params.limit = 2000;
params.startTime = new Date(now - 24 * MS_PER_HOUR).toISOString();
break;
case '7d':
params.limit = 10000;
params.startTime = new Date(now - 7 * 24 * MS_PER_HOUR).toISOString();
break;
case '30d':
params.limit = 10000;
params.startTime = new Date(now - 30 * 24 * MS_PER_HOUR).toISOString();
break;
case 'all':
params.limit = 0;
break;
default:
params.limit = 1000;
}
return params;
}
export function formatAlertHistoryDuration(
startTime: string,
endTime?: string,
now = Date.now(),
) {
const start = new Date(startTime).getTime();
const end = endTime ? new Date(endTime).getTime() : now;
const duration = end - start;
if (duration < 0) return '0m';
const minutes = Math.floor(duration / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
return `${minutes}m`;
}
export function formatAlertBucketRange(startMs: number, endMs: number, locale: string) {
const start = new Date(startMs);
const end = new Date(endMs);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
const startDay = start.toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
year: start.getFullYear() !== end.getFullYear() ? 'numeric' : undefined,
});
const endDay = end.toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const timeFormatter: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
};
const startTimeStr = start.toLocaleTimeString(locale, timeFormatter);
const endTimeStr = end.toLocaleTimeString(locale, timeFormatter);
if (sameDay) {
return `${startDay}, ${startTimeStr} – ${endTimeStr}`;
}
return `${startDay}, ${startTimeStr} → ${endDay}, ${endTimeStr}`;
}
export function resolveAlertHistoryResourceType({
resourceName,
metadata,
resourceId,
getResource,
allResources,
}: {
resourceName: string;
metadata?: Record<string, unknown>;
resourceId?: string;
getResource: (resourceId: string) => Resource | undefined;
allResources: Resource[];
}) {
const metadataType =
typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined;
if (metadataType && metadataType.trim().length > 0) {
return metadataType;
}
if (resourceId) {
const resource = getResource(resourceId);
if (resource) {
return unifiedTypeToAlertDisplayType(resource.type);
}
}
const byName = allResources.find(
(resource) => resource.name === resourceName || resource.displayName === resourceName,
);
if (byName) {
return unifiedTypeToAlertDisplayType(byName.type);
}
return 'Unknown';
}
export function buildAlertHistoryItems({
activeAlerts,
alertHistory,
getResource,
allResources,
now = Date.now(),
}: {
activeAlerts: Record<string, Alert>;
alertHistory: Alert[];
getResource: (resourceId: string) => Resource | undefined;
allResources: Resource[];
now?: number;
}) {
const items: HistoryItem[] = [];
Object.values(activeAlerts).forEach((alert) => {
items.push({
id: alert.id,
source: 'alert',
status: 'active',
startTime: alert.startTime,
duration: formatAlertHistoryDuration(alert.startTime, undefined, now),
resourceName: alert.resourceName,
resourceType: resolveAlertHistoryResourceType({
resourceName: alert.resourceName,
metadata: alert.metadata,
resourceId: alert.resourceId,
getResource,
allResources,
}),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
description: alert.message,
acknowledged: false,
});
});
const activeAlertIds = new Set(Object.keys(activeAlerts));
alertHistory.forEach((alert) => {
if (activeAlertIds.has(alert.id)) return;
items.push({
id: alert.id,
source: 'alert',
status: alert.acknowledged ? 'acknowledged' : 'resolved',
startTime: alert.startTime,
endTime: alert.lastSeen,
duration: formatAlertHistoryDuration(alert.startTime, alert.lastSeen, now),
resourceName: alert.resourceName,
resourceType: resolveAlertHistoryResourceType({
resourceName: alert.resourceName,
metadata: alert.metadata,
resourceId: alert.resourceId,
getResource,
allResources,
}),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
description: alert.message,
acknowledged: alert.acknowledged,
});
});
return items;
}
export function filterAlertHistoryItems(
items: HistoryItem[],
severityFilter: AlertSeverityFilter,
searchTerm: string,
) {
let filtered = items;
if (severityFilter !== 'all') {
filtered = filtered.filter((item) => item.severity === severityFilter);
}
const normalizedSearch = searchTerm.trim().toLowerCase();
if (normalizedSearch) {
filtered = filtered.filter((item) => {
const name = item.resourceName?.toLowerCase() ?? '';
const title = item.title?.toLowerCase() ?? '';
const description = item.description?.toLowerCase() ?? '';
const nodeName = item.node?.toLowerCase() ?? '';
return (
name.includes(normalizedSearch) ||
title.includes(normalizedSearch) ||
description.includes(normalizedSearch) ||
nodeName.includes(normalizedSearch)
);
});
}
return filtered;
}
export function buildAlertTrends(
filteredAlerts: HistoryItem[],
timeFilter: AlertHistoryRange,
now = Date.now(),
): AlertTrendSeries {
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440];
const maxBuckets = 30;
let bucketSizeHours: number;
let computedRangeHours: number;
let startTime: number;
if (timeFilter === '24h') {
bucketSizeHours = 1;
computedRangeHours = 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (timeFilter === '7d') {
bucketSizeHours = 6;
computedRangeHours = 7 * 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (timeFilter === '30d') {
bucketSizeHours = 24;
computedRangeHours = 30 * 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (!filteredAlerts.length) {
bucketSizeHours = 24;
computedRangeHours = 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else {
const earliest = filteredAlerts.reduce((min, alert) => {
const alertTime = new Date(alert.startTime).getTime();
return Math.min(min, alertTime);
}, now);
const rawRangeHours = Math.max(1, Math.ceil((now - earliest) / MS_PER_HOUR));
const rawBucketSize = Math.max(1, Math.ceil(rawRangeHours / maxBuckets));
bucketSizeHours = niceBucketSizes.find((size) => size >= rawBucketSize) ?? rawBucketSize;
computedRangeHours = Math.max(rawRangeHours, bucketSizeHours);
const bucketsNeeded = Math.min(
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
maxBuckets,
);
startTime = now - bucketsNeeded * bucketSizeHours * MS_PER_HOUR;
}
const bucketCount = Math.min(
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
maxBuckets,
);
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * MS_PER_HOUR);
const buckets = new Array(bucketCount).fill(0);
const bucketTimes = new Array(bucketCount)
.fill(0)
.map((_, index) => startTime + index * bucketSizeHours * MS_PER_HOUR);
const windowStart = startTime;
const windowEnd = now;
filteredAlerts.forEach((alert) => {
const alertTime = new Date(alert.startTime).getTime();
if (alertTime < windowStart || alertTime > windowEnd) return;
const rawIndex = Math.floor((alertTime - windowStart) / (bucketSizeHours * MS_PER_HOUR));
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
buckets[bucketIndex]++;
}
});
return {
buckets,
max: Math.max(...buckets, 1),
bucketSize: bucketSizeHours,
bucketTimes,
rangeStart: windowStart,
rangeHours: bucketCount * bucketSizeHours,
};
}
export function applyAlertHistoryWindow({
filteredItems,
timeFilter,
selectedBarIndex,
trends,
now = Date.now(),
}: {
filteredItems: HistoryItem[];
timeFilter: AlertHistoryRange;
selectedBarIndex: number | null;
trends: AlertTrendSeries;
now?: number;
}) {
let filtered = filteredItems;
if (selectedBarIndex !== null) {
const bucketStart = trends.bucketTimes[selectedBarIndex];
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
filtered = filtered.filter((alert) => {
const alertTime = new Date(alert.startTime).getTime();
return alertTime >= bucketStart && alertTime < bucketEnd;
});
} else if (timeFilter !== 'all') {
const cutoffMap: Record<'24h' | '7d' | '30d', number> = {
'24h': now - 24 * 60 * 60 * 1000,
'7d': now - 7 * 24 * 60 * 60 * 1000,
'30d': now - 30 * 24 * 60 * 60 * 1000,
};
const cutoff = cutoffMap[timeFilter];
if (cutoff) {
filtered = filtered.filter((alert) => new Date(alert.startTime).getTime() > cutoff);
}
}
return [...filtered].sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(),
);
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
export function getAlertHistoryDaySuffix(day: number) {
if (day >= 11 && day <= 13) return 'th';
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
export function formatAlertHistoryGroupLabel(date: Date, todayStart: number, yesterdayStart: number) {
const month = MONTH_NAMES[date.getMonth()];
const day = date.getDate();
const suffix = getAlertHistoryDaySuffix(day);
const absoluteDate = `${month} ${day}${suffix}`;
if (date.getTime() === todayStart) return `Today (${absoluteDate})`;
if (date.getTime() === yesterdayStart) return `Yesterday (${absoluteDate})`;
return absoluteDate;
}
export function getIncidentRowKey(alert: HistoryItem) {
return `${alert.id}::${alert.startTime}`;
}
export function groupAlertHistoryItems(alertData: HistoryItem[]) {
const now = new Date();
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStart = todayDate.getTime();
const yesterdayDate = new Date(todayDate);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterdayStart = yesterdayDate.getTime();
const groups = new Map<
number,
{
date: Date;
label: string;
fullLabel: string;
alerts: HistoryItem[];
}
>();
alertData.forEach((alert) => {
const alertDate = new Date(alert.startTime);
const dateOnly = new Date(alertDate.getFullYear(), alertDate.getMonth(), alertDate.getDate());
const dateKey = dateOnly.getTime();
if (!groups.has(dateKey)) {
groups.set(dateKey, {
date: dateOnly,
label: formatAlertHistoryGroupLabel(dateOnly, todayStart, yesterdayStart),
fullLabel: dateOnly.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
alerts: [],
});
}
groups.get(dateKey)!.alerts.push(alert);
});
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime());
}
export function getAlertBucketDurationLabel(bucketHours: number) {
if (!Number.isFinite(bucketHours) || bucketHours <= 0) return '—';
if (bucketHours % 24 === 0) {
const days = bucketHours / 24;
return `${days} day${days === 1 ? '' : 's'}`;
}
return `${bucketHours} hour${bucketHours === 1 ? '' : 's'}`;
}
export function formatAlertAxisTickLabel({
timestamp,
bucketHours,
totalHours,
locale,
isEnd = false,
now = Date.now(),
}: {
timestamp: number;
bucketHours: number;
totalHours: number;
locale: string;
isEnd?: boolean;
now?: number;
}) {
if (!Number.isFinite(timestamp)) return '—';
if (isEnd && Math.abs(now - timestamp) < bucketHours * MS_PER_HOUR * 0.75) {
return 'Now';
}
const date = new Date(timestamp);
const options: Intl.DateTimeFormatOptions = {};
if (totalHours <= 48) {
options.month = 'short';
options.day = 'numeric';
options.hour = '2-digit';
options.minute = '2-digit';
} else if (totalHours <= 24 * 90) {
options.month = 'short';
options.day = 'numeric';
if (bucketHours <= 12 || totalHours <= 24 * 14) {
options.hour = '2-digit';
}
} else {
options.year = 'numeric';
options.month = 'short';
options.day = 'numeric';
}
return date.toLocaleString(locale, options);
}
export function buildAlertRangeSummary(trends: AlertTrendSeries, locale: string) {
if (!trends.bucketTimes.length || trends.bucketSize <= 0) return null;
const bucketHours = trends.bucketSize;
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
const start = trends.bucketTimes[0];
const end = start + trends.buckets.length * bucketHours * MS_PER_HOUR;
return {
startLabel: formatAlertAxisTickLabel({
timestamp: start,
bucketHours,
totalHours,
locale,
}),
endLabel: formatAlertAxisTickLabel({
timestamp: end,
bucketHours,
totalHours,
locale,
isEnd: true,
}),
};
}
export function buildAlertAxisTicks(trends: AlertTrendSeries, locale: string): AlertAxisTick[] {
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
return [];
}
const bucketHours = trends.bucketSize;
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
const start = trends.bucketTimes[0];
const totalDurationMs = Math.max(
trends.buckets.length * bucketHours * MS_PER_HOUR,
bucketHours * MS_PER_HOUR,
);
const end = start + totalDurationMs;
const desiredTicks = Math.min(5, trends.bucketTimes.length + 1);
const step = Math.max(1, Math.round(trends.bucketTimes.length / Math.max(1, desiredTicks - 1)));
const ticks: Array<{ position: number; label: string }> = [];
for (let index = 0; index < trends.bucketTimes.length; index += step) {
const ts = trends.bucketTimes[index];
const position = Math.min(1, Math.max(0, (ts - start) / (totalDurationMs || 1)));
ticks.push({
position,
label: formatAlertAxisTickLabel({
timestamp: ts,
bucketHours,
totalHours,
locale,
}),
});
}
if (!ticks.length || ticks[0].position > 0.01) {
ticks.unshift({
position: 0,
label: formatAlertAxisTickLabel({ timestamp: start, bucketHours, totalHours, locale }),
});
} else {
ticks[0] = {
position: 0,
label: formatAlertAxisTickLabel({ timestamp: start, bucketHours, totalHours, locale }),
};
}
const lastTick = ticks[ticks.length - 1];
if (!lastTick || Math.abs(lastTick.position - 1) > 0.01) {
ticks.push({
position: 1,
label: formatAlertAxisTickLabel({
timestamp: end,
bucketHours,
totalHours,
locale,
isEnd: true,
}),
});
} else {
ticks[ticks.length - 1] = {
position: 1,
label: formatAlertAxisTickLabel({
timestamp: end,
bucketHours,
totalHours,
locale,
isEnd: true,
}),
};
}
return ticks.map((tick, index, array) => ({
position: tick.position,
label: tick.label,
align: index === 0 ? 'start' : index === array.length - 1 ? 'end' : 'center',
}));
}
export function buildSelectedBucketDetails(
selectedBarIndex: number | null,
trends: AlertTrendSeries,
locale: string,
) {
if (selectedBarIndex === null) return null;
const bucketStart = trends.bucketTimes[selectedBarIndex];
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
return {
rangeLabel: formatAlertBucketRange(bucketStart, bucketEnd, locale),
start: bucketStart,
end: bucketEnd,
};
}
@@ -66,6 +66,7 @@ import {
import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation';
import { getTypeColumnLabel } from '@/utils/typeColumnPresentation';
import { MS_PER_HOUR } from '../alertHistoryModel';
import { useAlertHistoryState } from '../useAlertHistoryState';
import { filterIncidentEvents, summarizeIncidentEvents } from '../types';
@@ -15,40 +15,32 @@ import {
} from '@/utils/alertAdministrationPresentation';
import { getAlertResourceIncidentLoadFailure } from '@/utils/alertIncidentPresentation';
import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers';
import {
applyAlertHistoryWindow,
buildAlertAxisTicks,
buildAlertHistoryItems,
buildAlertHistoryParams,
buildAlertRangeSummary,
buildAlertTrends,
buildSelectedBucketDetails,
filterAlertHistoryItems,
formatAlertBucketRange,
getAlertBucketDurationLabel,
getIncidentRowKey,
groupAlertHistoryItems,
type AlertHistoryRange,
type AlertSeverityFilter,
type HistoryItem,
} from './alertHistoryModel';
import { useAlertIncidentTimelineState } from './useAlertIncidentTimelineState';
import { INCIDENT_EVENT_TYPES } from './types';
const MS_PER_HOUR = 60 * 60 * 1000;
type AlertHistoryRange = '24h' | '7d' | '30d' | 'all';
type AlertSeverityFilter = 'all' | 'warning' | 'critical';
export interface UseAlertHistoryStateProps {
activeAlerts: Accessor<Record<string, Alert>>;
getResource: (resourceId: string) => Resource | undefined;
allResources: () => Resource[];
}
interface HistoryItem {
id: string;
source: 'alert' | 'ai';
status: string;
startTime: string;
endTime?: string;
duration: string;
resourceName: string;
resourceType: string;
resourceId?: string;
node?: string;
nodeDisplayName?: string;
severity: string;
title: string;
rawAlertType?: string;
description?: string;
acknowledged?: boolean;
}
export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const [timeFilter, setTimeFilter] = usePersistentSignal<AlertHistoryRange>(
'alertHistoryTimeFilter',
@@ -113,40 +105,13 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
'en-US';
const buildHistoryParams = (range: AlertHistoryRange) => {
const params: { limit?: number; startTime?: string } = {};
const now = Date.now();
switch (range) {
case '24h':
params.limit = 2000;
params.startTime = new Date(now - 24 * MS_PER_HOUR).toISOString();
break;
case '7d':
params.limit = 10000;
params.startTime = new Date(now - 7 * 24 * MS_PER_HOUR).toISOString();
break;
case '30d':
params.limit = 10000;
params.startTime = new Date(now - 30 * 24 * MS_PER_HOUR).toISOString();
break;
case 'all':
params.limit = 0;
break;
default:
params.limit = 1000;
}
return params;
};
let fetchRequestId = 0;
const fetchHistory = async (range: AlertHistoryRange) => {
const requestId = ++fetchRequestId;
setLoading(true);
try {
const alertHistoryData = await AlertsAPI.getHistory(buildHistoryParams(range));
const alertHistoryData = await AlertsAPI.getHistory(buildAlertHistoryParams(range));
if (requestId === fetchRequestId) {
setAlertHistory(alertHistoryData);
}
@@ -209,22 +174,6 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
void fetchHistory(range);
});
const formatDuration = (startTime: string, endTime?: string) => {
const start = new Date(startTime).getTime();
const end = endTime ? new Date(endTime).getTime() : Date.now();
const duration = end - start;
if (duration < 0) return '0m';
const minutes = Math.floor(duration / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
return `${minutes}m`;
};
const loadResourceIncidents = async (resourceId: string, limit = 10) => {
if (!resourceId) return;
@@ -268,471 +217,47 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
});
};
const formatBucketRange = (startMs: number, endMs: number) => {
const start = new Date(startMs);
const end = new Date(endMs);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
const startDay = start.toLocaleDateString(userLocale, {
month: 'short',
day: 'numeric',
year: start.getFullYear() !== end.getFullYear() ? 'numeric' : undefined,
});
const endDay = end.toLocaleDateString(userLocale, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const timeFormatter: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
};
const startTimeStr = start.toLocaleTimeString(userLocale, timeFormatter);
const endTimeStr = end.toLocaleTimeString(userLocale, timeFormatter);
if (sameDay) {
return `${startDay}, ${startTimeStr} – ${endTimeStr}`;
}
return `${startDay}, ${startTimeStr} → ${endDay}, ${endTimeStr}`;
};
const getResourceType = (
resourceName: string,
metadata?: Record<string, unknown> | undefined,
resourceId?: string,
) => {
const metadataType =
typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined;
if (metadataType && metadataType.trim().length > 0) {
return metadataType;
}
if (resourceId) {
const resource = props.getResource(resourceId);
if (resource) {
return unifiedTypeToAlertDisplayType(resource.type);
}
}
const byName = props
.allResources()
.find((resource) => resource.name === resourceName || resource.displayName === resourceName);
if (byName) {
return unifiedTypeToAlertDisplayType(byName.type);
}
return 'Unknown';
};
const allHistoryData = createMemo<HistoryItem[]>(() => {
const items: HistoryItem[] = [];
const activeAlerts = props.activeAlerts() || {};
Object.values(activeAlerts).forEach((alert) => {
items.push({
id: alert.id,
source: 'alert',
status: 'active',
startTime: alert.startTime,
duration: formatDuration(alert.startTime),
resourceName: alert.resourceName,
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
description: alert.message,
acknowledged: false,
});
return buildAlertHistoryItems({
activeAlerts: props.activeAlerts() || {},
alertHistory: alertHistory(),
getResource: props.getResource,
allResources: props.allResources(),
});
const activeAlertIds = new Set(Object.keys(activeAlerts));
alertHistory().forEach((alert) => {
if (activeAlertIds.has(alert.id)) return;
items.push({
id: alert.id,
source: 'alert',
status: alert.acknowledged ? 'acknowledged' : 'resolved',
startTime: alert.startTime,
endTime: alert.lastSeen,
duration: formatDuration(alert.startTime, alert.lastSeen),
resourceName: alert.resourceName,
resourceType: getResourceType(alert.resourceName, alert.metadata, alert.resourceId),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
description: alert.message,
acknowledged: alert.acknowledged,
});
});
return items;
});
const severityAndSearchFilteredItems = createMemo(() => {
let filtered = allHistoryData();
if (severityFilter() !== 'all') {
filtered = filtered.filter((item) => item.severity === severityFilter());
}
if (searchTerm()) {
const term = searchTerm().toLowerCase();
filtered = filtered.filter((item) => {
const name = item.resourceName?.toLowerCase() ?? '';
const title = item.title?.toLowerCase() ?? '';
const description = item.description?.toLowerCase() ?? '';
const nodeName = item.node?.toLowerCase() ?? '';
return (
name.includes(term) ||
title.includes(term) ||
description.includes(term) ||
nodeName.includes(term)
);
});
}
return filtered;
});
const alertData = createMemo(() => {
let filtered = severityAndSearchFilteredItems();
const currentTimeFilter = timeFilter();
if (selectedBarIndex() !== null) {
const trends = alertTrends();
const index = selectedBarIndex()!;
const bucketStart = trends.bucketTimes[index];
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
filtered = filtered.filter((alert) => {
const alertTime = new Date(alert.startTime).getTime();
return alertTime >= bucketStart && alertTime < bucketEnd;
});
} else if (currentTimeFilter !== 'all') {
const now = Date.now();
const cutoffMap: Record<'24h' | '7d' | '30d', number> = {
'24h': now - 24 * 60 * 60 * 1000,
'7d': now - 7 * 24 * 60 * 60 * 1000,
'30d': now - 30 * 24 * 60 * 60 * 1000,
};
const cutoff = cutoffMap[currentTimeFilter];
if (cutoff) {
filtered = filtered.filter((alert) => new Date(alert.startTime).getTime() > cutoff);
}
}
return [...filtered].sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(),
);
});
const monthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const getDaySuffix = (day: number) => {
if (day >= 11 && day <= 13) return 'th';
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
};
const formatAlertGroupLabel = (date: Date, todayStart: number, yesterdayStart: number) => {
const month = monthNames[date.getMonth()];
const day = date.getDate();
const suffix = getDaySuffix(day);
const absoluteDate = `${month} ${day}${suffix}`;
if (date.getTime() === todayStart) return `Today (${absoluteDate})`;
if (date.getTime() === yesterdayStart) return `Yesterday (${absoluteDate})`;
return absoluteDate;
};
const getIncidentRowKey = (alert: HistoryItem) => `${alert.id}::${alert.startTime}`;
const groupedAlerts = createMemo(() => {
const now = new Date();
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStart = todayDate.getTime();
const yesterdayDate = new Date(todayDate);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterdayStart = yesterdayDate.getTime();
const groups = new Map<
number,
{
date: Date;
label: string;
fullLabel: string;
alerts: HistoryItem[];
}
>();
alertData().forEach((alert) => {
const alertDate = new Date(alert.startTime);
const dateOnly = new Date(alertDate.getFullYear(), alertDate.getMonth(), alertDate.getDate());
const dateKey = dateOnly.getTime();
if (!groups.has(dateKey)) {
groups.set(dateKey, {
date: dateOnly,
label: formatAlertGroupLabel(dateOnly, todayStart, yesterdayStart),
fullLabel: dateOnly.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
alerts: [],
});
}
groups.get(dateKey)!.alerts.push(alert);
});
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime());
return filterAlertHistoryItems(allHistoryData(), severityFilter(), searchTerm());
});
const alertTrends = createMemo(() => {
const now = Date.now();
const filteredAlerts = severityAndSearchFilteredItems();
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440];
const maxBuckets = 30;
return buildAlertTrends(severityAndSearchFilteredItems(), timeFilter());
});
let bucketSizeHours: number;
let computedRangeHours: number;
let startTime: number;
const filter = timeFilter();
if (filter === '24h') {
bucketSizeHours = 1;
computedRangeHours = 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (filter === '7d') {
bucketSizeHours = 6;
computedRangeHours = 7 * 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (filter === '30d') {
bucketSizeHours = 24;
computedRangeHours = 30 * 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else if (!filteredAlerts.length) {
bucketSizeHours = 24;
computedRangeHours = 24;
startTime = now - computedRangeHours * MS_PER_HOUR;
} else {
const earliest = filteredAlerts.reduce((min, alert) => {
const alertTime = new Date(alert.startTime).getTime();
return Math.min(min, alertTime);
}, now);
const rawRangeHours = Math.max(1, Math.ceil((now - earliest) / MS_PER_HOUR));
const rawBucketSize = Math.max(1, Math.ceil(rawRangeHours / maxBuckets));
bucketSizeHours = niceBucketSizes.find((size) => size >= rawBucketSize) ?? rawBucketSize;
computedRangeHours = Math.max(rawRangeHours, bucketSizeHours);
const bucketsNeeded = Math.min(
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
maxBuckets,
);
startTime = now - bucketsNeeded * bucketSizeHours * MS_PER_HOUR;
}
const bucketCount = Math.min(
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
maxBuckets,
);
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * MS_PER_HOUR);
const buckets = new Array(bucketCount).fill(0);
const bucketTimes = new Array(bucketCount)
.fill(0)
.map((_, index) => startTime + index * bucketSizeHours * MS_PER_HOUR);
const windowStart = startTime;
const windowEnd = now;
filteredAlerts.forEach((alert) => {
const alertTime = new Date(alert.startTime).getTime();
if (alertTime < windowStart || alertTime > windowEnd) return;
const rawIndex = Math.floor((alertTime - windowStart) / (bucketSizeHours * MS_PER_HOUR));
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
buckets[bucketIndex]++;
}
const alertData = createMemo(() => {
return applyAlertHistoryWindow({
filteredItems: severityAndSearchFilteredItems(),
timeFilter: timeFilter(),
selectedBarIndex: selectedBarIndex(),
trends: alertTrends(),
});
return {
buckets,
max: Math.max(...buckets, 1),
bucketSize: bucketSizeHours,
bucketTimes,
rangeStart: windowStart,
rangeHours: bucketCount * bucketSizeHours,
};
});
const bucketDurationLabel = createMemo(() => {
const bucketHours = alertTrends().bucketSize;
if (!Number.isFinite(bucketHours) || bucketHours <= 0) return '—';
if (bucketHours % 24 === 0) {
const days = bucketHours / 24;
return `${days} day${days === 1 ? '' : 's'}`;
}
return `${bucketHours} hour${bucketHours === 1 ? '' : 's'}`;
const groupedAlerts = createMemo(() => {
return groupAlertHistoryItems(alertData());
});
const formatAxisTickLabel = (
timestamp: number,
bucketHours: number,
totalHours: number,
isEnd = false,
) => {
if (!Number.isFinite(timestamp)) return '—';
if (isEnd && Math.abs(Date.now() - timestamp) < bucketHours * MS_PER_HOUR * 0.75) {
return 'Now';
}
const date = new Date(timestamp);
const options: Intl.DateTimeFormatOptions = {};
if (totalHours <= 48) {
options.month = 'short';
options.day = 'numeric';
options.hour = '2-digit';
options.minute = '2-digit';
} else if (totalHours <= 24 * 90) {
options.month = 'short';
options.day = 'numeric';
if (bucketHours <= 12 || totalHours <= 24 * 14) {
options.hour = '2-digit';
}
} else {
options.year = 'numeric';
options.month = 'short';
options.day = 'numeric';
}
return date.toLocaleString(userLocale, options);
};
const bucketDurationLabel = createMemo(() => getAlertBucketDurationLabel(alertTrends().bucketSize));
const rangeSummary = createMemo(() => {
const trends = alertTrends();
if (!trends.bucketTimes.length || trends.bucketSize <= 0) return null;
const bucketHours = trends.bucketSize;
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
const start = trends.bucketTimes[0];
const end = start + trends.buckets.length * bucketHours * MS_PER_HOUR;
return {
startLabel: formatAxisTickLabel(start, bucketHours, totalHours),
endLabel: formatAxisTickLabel(end, bucketHours, totalHours, true),
};
return buildAlertRangeSummary(alertTrends(), userLocale);
});
const axisTicks = createMemo(() => {
const trends = alertTrends();
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
return [] as Array<{ position: number; label: string; align: 'start' | 'center' | 'end' }>;
}
const axisTicks = createMemo(() => buildAlertAxisTicks(alertTrends(), userLocale));
const bucketHours = trends.bucketSize;
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
const start = trends.bucketTimes[0];
const totalDurationMs = Math.max(
trends.buckets.length * bucketHours * MS_PER_HOUR,
bucketHours * MS_PER_HOUR,
);
const end = start + totalDurationMs;
const desiredTicks = Math.min(5, trends.bucketTimes.length + 1);
const step = Math.max(1, Math.round(trends.bucketTimes.length / Math.max(1, desiredTicks - 1)));
const ticks: Array<{ position: number; label: string }> = [];
for (let index = 0; index < trends.bucketTimes.length; index += step) {
const ts = trends.bucketTimes[index];
const position = Math.min(1, Math.max(0, (ts - start) / (totalDurationMs || 1)));
ticks.push({
position,
label: formatAxisTickLabel(ts, bucketHours, totalHours),
});
}
if (!ticks.length || ticks[0].position > 0.01) {
ticks.unshift({
position: 0,
label: formatAxisTickLabel(start, bucketHours, totalHours),
});
} else {
ticks[0] = {
position: 0,
label: formatAxisTickLabel(start, bucketHours, totalHours),
};
}
const lastTick = ticks[ticks.length - 1];
if (!lastTick || Math.abs(lastTick.position - 1) > 0.01) {
ticks.push({
position: 1,
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
});
} else {
ticks[ticks.length - 1] = {
position: 1,
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
};
}
return ticks.map((tick, index, arr) => ({
position: tick.position,
label: tick.label,
align: index === 0 ? 'start' : index === arr.length - 1 ? 'end' : 'center',
}));
});
const selectedBucketDetails = createMemo(() => {
const index = selectedBarIndex();
if (index === null) return null;
const trends = alertTrends();
const bucketStart = trends.bucketTimes[index];
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
return {
rangeLabel: formatBucketRange(bucketStart, bucketEnd),
start: bucketStart,
end: bucketEnd,
};
});
const selectedBucketDetails = createMemo(() =>
buildSelectedBucketDetails(selectedBarIndex(), alertTrends(), userLocale),
);
const clearAlertHistory = async () => {
if (!confirm(getAlertAdministrationClearHistoryConfirmation())) {
@@ -792,7 +317,8 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
rangeSummary,
axisTicks,
selectedBucketDetails,
formatBucketRange,
formatBucketRange: (startMs: number, endMs: number) =>
formatAlertBucketRange(startMs, endMs, userLocale),
getIncidentRowKey,
clearAlertHistory,
};
@@ -9,6 +9,7 @@ import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinati
import alertWebhookDestinationsStateSource from '@/features/alerts/useAlertWebhookDestinationsState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertHistoryModelSource from '@/features/alerts/alertHistoryModel.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertScheduleStateSource from '@/features/alerts/useAlertScheduleState.ts?raw';
@@ -280,6 +281,17 @@ describe('tab path helpers', () => {
expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource');
expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory');
expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState');
expect(alertHistoryStateSource).toContain('buildAlertHistoryItems');
expect(alertHistoryStateSource).toContain('buildAlertTrends');
expect(alertHistoryStateSource).toContain('groupAlertHistoryItems');
expect(alertHistoryStateSource).not.toContain('const formatDuration =');
expect(alertHistoryStateSource).not.toContain('const formatBucketRange =');
expect(alertHistoryStateSource).not.toContain('const formatAxisTickLabel =');
expect(alertHistoryStateSource).not.toContain('const monthNames = [');
expect(alertHistoryModelSource).toContain('export function buildAlertHistoryItems');
expect(alertHistoryModelSource).toContain('export function buildAlertTrends');
expect(alertHistoryModelSource).toContain('export function groupAlertHistoryItems');
expect(alertHistoryModelSource).toContain('export const MS_PER_HOUR');
expect(alertIncidentTimelineStateSource).toContain(
'export function useAlertIncidentTimelineState',
);
@@ -333,6 +333,7 @@ import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinati
import alertWebhookDestinationsStateSource from '@/features/alerts/useAlertWebhookDestinationsState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertHistoryModelSource from '@/features/alerts/alertHistoryModel.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertScheduleStateSource from '@/features/alerts/useAlertScheduleState.ts?raw';
@@ -2899,6 +2900,17 @@ describe('frontend resource type boundaries', () => {
expect(alertHistoryStateSource).toContain('AlertsAPI.getIncidentsForResource');
expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory');
expect(alertHistoryStateSource).toContain('useAlertIncidentTimelineState');
expect(alertHistoryStateSource).toContain('buildAlertHistoryItems');
expect(alertHistoryStateSource).toContain('buildAlertTrends');
expect(alertHistoryStateSource).toContain('groupAlertHistoryItems');
expect(alertHistoryStateSource).not.toContain('const formatDuration =');
expect(alertHistoryStateSource).not.toContain('const formatBucketRange =');
expect(alertHistoryStateSource).not.toContain('const formatAxisTickLabel =');
expect(alertHistoryStateSource).not.toContain('const monthNames = [');
expect(alertHistoryModelSource).toContain('export function buildAlertHistoryItems');
expect(alertHistoryModelSource).toContain('export function buildAlertTrends');
expect(alertHistoryModelSource).toContain('export function groupAlertHistoryItems');
expect(alertHistoryModelSource).toContain('export const MS_PER_HOUR');
expect(alertsPageSource).toContain('getAlertsSidebarTabClass');
expect(alertsPageSource).toContain('getAlertsMobileTabClass');
expect(alertsPageSource).toContain('getAlertsTabTitle');