mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-05 12:22:31 +00:00
Extract alert history analytics model owner
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user