From 5c6bf8b306e4bbde64d650c7f1e432979f18ea1d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 17:58:13 +0000 Subject: [PATCH] Extract alert history analytics model owner --- .../v6/internal/subsystems/alerts.md | 14 +- .../subsystems/frontend-primitives.md | 9 +- .../__tests__/alertHistoryModel.test.ts | 98 +++ .../src/features/alerts/alertHistoryModel.ts | 637 ++++++++++++++++++ .../src/features/alerts/tabs/HistoryTab.tsx | 1 + .../features/alerts/useAlertHistoryState.ts | 558 ++------------- .../pages/__tests__/Alerts.helpers.test.ts | 12 + .../frontendResourceTypeBoundaries.test.ts | 12 + 8 files changed, 817 insertions(+), 524 deletions(-) create mode 100644 frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts create mode 100644 frontend-modern/src/features/alerts/alertHistoryModel.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 46e1a31f2..712a19223 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 837626b8c..efc208385 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts new file mode 100644 index 000000000..1fc897dce --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts @@ -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 = { + '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'); + }); +}); diff --git a/frontend-modern/src/features/alerts/alertHistoryModel.ts b/frontend-modern/src/features/alerts/alertHistoryModel.ts new file mode 100644 index 000000000..cab1e1dfb --- /dev/null +++ b/frontend-modern/src/features/alerts/alertHistoryModel.ts @@ -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; + 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; + 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, + }; +} diff --git a/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx index 2fc508a20..3f1dd5783 100644 --- a/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx @@ -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'; diff --git a/frontend-modern/src/features/alerts/useAlertHistoryState.ts b/frontend-modern/src/features/alerts/useAlertHistoryState.ts index a56f67df9..1046471e1 100644 --- a/frontend-modern/src/features/alerts/useAlertHistoryState.ts +++ b/frontend-modern/src/features/alerts/useAlertHistoryState.ts @@ -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>; 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( '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 | 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(() => { - 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, }; diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 9eda5e715..4574a6b56 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -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', ); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index e424f00fd..e8c82b818 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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');