From 6dbb87071f97ecd5172392f740cc5dbff0feed2a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 12:50:25 +0000 Subject: [PATCH] Extract alert history state owner --- .../v6/internal/subsystems/alerts.md | 9 + .../subsystems/frontend-primitives.md | 6 + .../__tests__/useAlertHistoryState.test.tsx | 109 +++ .../src/features/alerts/tabs/HistoryTab.tsx | 797 +---------------- .../features/alerts/useAlertHistoryState.ts | 799 ++++++++++++++++++ .../pages/__tests__/Alerts.helpers.test.ts | 15 +- .../frontendResourceTypeBoundaries.test.ts | 17 +- 7 files changed, 995 insertions(+), 757 deletions(-) create mode 100644 frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx create mode 100644 frontend-modern/src/features/alerts/useAlertHistoryState.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 1edbe0d37..d70d9483a 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -198,6 +198,15 @@ runtime presentation, tab-local interaction logic, and any history-table presentation or thresholds-table adapter logic that does not belong in a shared 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/tabs/HistoryTab.tsx`. + Alert configuration load/save state, notification config reloads, and threshold override normalization now route through `frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx` instead of diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2bebb02a6..70161fca3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -429,6 +429,12 @@ while `frontend-modern/src/features/alerts/OverviewTab.tsx` and surface composition. Future incident timeline fetch, note-save, or expansion control flow should extend that feature hook rather than forking back into either tab surface. +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. 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__/useAlertHistoryState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx new file mode 100644 index 000000000..c289f0f0f --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx @@ -0,0 +1,109 @@ +import { renderHook, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AlertsAPI } from '@/api/alerts'; +import { eventBus } from '@/stores/events'; + +import { useAlertHistoryState } from '../useAlertHistoryState'; + +vi.mock('@/api/alerts', () => ({ + AlertsAPI: { + addIncidentNote: vi.fn(), + clearHistory: vi.fn(), + getHistory: vi.fn(), + getIncidentTimeline: vi.fn(), + getIncidentsForResource: vi.fn(), + }, +})); + +vi.mock('@/stores/events', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe('useAlertHistoryState', () => { + beforeEach(() => { + vi.mocked(AlertsAPI.getHistory).mockReset(); + vi.mocked(AlertsAPI.getIncidentsForResource).mockReset(); + vi.mocked(AlertsAPI.clearHistory).mockReset(); + vi.mocked(eventBus.on).mockClear(); + vi.stubGlobal('confirm', vi.fn(() => true)); + localStorage.clear(); + }); + + it('owns alert history fetch, filters, resource incidents, and clear behavior outside the render tab', async () => { + const [activeAlerts] = createSignal({}); + const now = Date.now(); + const startTime = new Date(now - 30 * 60 * 1000).toISOString(); + const lastSeen = new Date(now - 10 * 60 * 1000).toISOString(); + + vi.mocked(AlertsAPI.getHistory).mockResolvedValue([ + { + id: 'alert-1', + type: 'cpu', + level: 'warning', + startTime, + lastSeen, + resourceId: 'resource-1', + resourceName: 'db-01', + message: 'CPU high', + acknowledged: false, + }, + ] as any); + vi.mocked(AlertsAPI.getIncidentsForResource).mockResolvedValue([ + { + id: 'incident-1', + alertType: 'CPU', + level: 'warning', + status: 'resolved', + openedAt: startTime, + closedAt: lastSeen, + events: [], + }, + ] as any); + vi.mocked(AlertsAPI.clearHistory).mockResolvedValue(undefined as any); + + const { result } = renderHook(() => + useAlertHistoryState({ + activeAlerts, + getResource: () => undefined, + allResources: () => [], + }), + ); + + await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(1)); + expect(result.alertData()).toHaveLength(1); + expect(eventBus.on).toHaveBeenCalledWith('org_switched', expect.any(Function)); + + await result.openResourceIncidentPanel('resource-1', 'db-01'); + + expect(AlertsAPI.getIncidentsForResource).toHaveBeenCalledWith('resource-1', 10); + expect(result.resourceIncidentPanel()).toEqual({ + resourceId: 'resource-1', + resourceName: 'db-01', + }); + expect(result.resourceIncidents()['resource-1']).toHaveLength(1); + + result.setTimeFilter('24h'); + await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(2)); + + await result.clearAlertHistory(); + expect(AlertsAPI.clearHistory).toHaveBeenCalledTimes(1); + expect(result.alertHistory()).toEqual([]); + }); +}); diff --git a/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx index 27e3d4bfd..2fc508a20 100644 --- a/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx @@ -1,8 +1,6 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'; +import { createMemo, For, Show } from 'solid-js'; -import type { Alert, Incident } from '@/types/api'; import type { Resource } from '@/types/resource'; -import { AlertsAPI } from '@/api/alerts'; import { useWebSocket } from '@/App'; import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters'; import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard'; @@ -22,15 +20,8 @@ import { TableRow, } from '@/components/shared/Table'; import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; -import { eventBus } from '@/stores/events'; -import { notificationStore } from '@/stores/notifications'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { logger } from '@/utils/logger'; import { hideTooltip, showTooltip } from '@/components/shared/Tooltip'; import { - getAlertAdministrationClearHistoryConfirmation, - getAlertAdministrationClearHistoryError, getAlertAdministrationClearHistoryLabel, getAlertAdministrationSectionDescription, getAlertAdministrationSectionTitle, @@ -63,7 +54,6 @@ import { getAlertResourceIncidentCountLabel, getAlertResourceIncidentEmptyState, getAlertResourceIncidentFilteredEventsEmptyState, - getAlertResourceIncidentLoadFailure, getAlertResourceIncidentLoadingState, getAlertResourceIncidentPanelTitle, getAlertResourceIncidentRefreshLabel, @@ -76,18 +66,8 @@ import { import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation'; import { getTypeColumnLabel } from '@/utils/typeColumnPresentation'; -import { - alertTypeDisplayLabel, - unifiedTypeToAlertDisplayType, -} from '../helpers'; -import { useAlertIncidentTimelineState } from '../useAlertIncidentTimelineState'; -import { - filterIncidentEvents, - INCIDENT_EVENT_TYPES, - summarizeIncidentEvents, -} from '../types'; - -const MS_PER_HOUR = 60 * 60 * 1000; +import { useAlertHistoryState } from '../useAlertHistoryState'; +import { filterIncidentEvents, summarizeIncidentEvents } from '../types'; export interface HistoryTabProps { hasAIAlertsFeature: () => boolean; @@ -101,737 +81,58 @@ export function HistoryTab(props: HistoryTabProps) { const alertFrequencySelectionPresentation = createMemo(() => getAlertFrequencySelectionPresentation(), ); - - const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>( - 'alertHistoryTimeFilter', - '7d', - { - deserialize: (raw) => - raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : '7d', - }, - ); - const [severityFilter, setSeverityFilter] = usePersistentSignal<'all' | 'warning' | 'critical'>( - 'alertHistorySeverityFilter', - 'all', - { - deserialize: (raw) => (raw === 'warning' || raw === 'critical' ? raw : 'all'), - }, - ); - const [searchTerm, setSearchTerm] = createSignal(''); - const [alertHistory, setAlertHistory] = createSignal([]); - const [loading, setLoading] = createSignal(true); - const [selectedBarIndex, setSelectedBarIndex] = createSignal(null); - const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{ - resourceId: string; - resourceName: string; - } | null>(null); - const [resourceIncidents, setResourceIncidents] = createSignal>({}); - const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal< - Record - >({}); - const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal>( - new Set(), - ); - const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal>( - new Set(INCIDENT_EVENT_TYPES), - ); const { isMobile } = useBreakpoint(); - const [filtersOpen, setFiltersOpen] = createSignal(false); const { + STORAGE_KEYS, + timeFilter, + setTimeFilter, + severityFilter, + setSeverityFilter, + searchTerm, + setSearchTerm, + alertHistory, + loading, + selectedBarIndex, + setSelectedBarIndex, + resourceIncidentPanel, + setResourceIncidentPanel, + resourceIncidents, + resourceIncidentLoading, + expandedResourceIncidentIds, + resourceIncidentEventFilters, + setResourceIncidentEventFilters, + filtersOpen, + setFiltersOpen, + activeFilterCount, incidentTimelines, incidentLoading, incidentErrors, expandedIncidents, incidentNoteDrafts, incidentNoteSaving, - eventFilters: historyIncidentEventFilters, - setEventFilters: setHistoryIncidentEventFilters, - resetState: resetIncidentTimelineState, + historyIncidentEventFilters, + setHistoryIncidentEventFilters, loadIncidentTimeline, toggleIncidentTimeline, setIncidentNoteDraft, saveIncidentNote, - } = useAlertIncidentTimelineState(); - - const activeFilterCount = createMemo(() => { - let count = 0; - if (timeFilter() !== '7d') count++; - if (severityFilter() !== 'all') count++; - return count; - }); - - const userLocale = - Intl.DateTimeFormat().resolvedOptions().locale || - (typeof navigator !== 'undefined' ? navigator.language : undefined) || - 'en-US'; - - const buildHistoryParams = (range: string) => { - 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: string) => { - const requestId = ++fetchRequestId; - setLoading(true); - - try { - const params = buildHistoryParams(range); - const alertHistoryData = await AlertsAPI.getHistory(params); - - if (requestId === fetchRequestId) { - setAlertHistory(alertHistoryData); - } - } catch (error) { - if (requestId === fetchRequestId) { - logger.error('Failed to load history:', error); - } - } finally { - if (requestId === fetchRequestId) { - setLoading(false); - } - } - }; - - let lastTimeFilterValue: string | null = null; - createEffect(() => { - const current = timeFilter(); - if (lastTimeFilterValue !== null && current !== lastTimeFilterValue) { - setSelectedBarIndex(null); - } - lastTimeFilterValue = current; - }); - - let lastSeverityFilterValue: string | null = null; - createEffect(() => { - const current = severityFilter(); - if (lastSeverityFilterValue !== null && current !== lastSeverityFilterValue) { - setSelectedBarIndex(null); - } - lastSeverityFilterValue = current; - }); - - onMount(() => { - void fetchHistory(timeFilter()); - - const unsubscribeOrgSwitched = eventBus.on('org_switched', () => { - setAlertHistory([]); - setSelectedBarIndex(null); - setResourceIncidentPanel(null); - setResourceIncidents({}); - setResourceIncidentLoading({}); - setExpandedResourceIncidentIds(new Set()); - resetIncidentTimelineState(); - void fetchHistory(timeFilter()); - }); - - onCleanup(() => { - unsubscribeOrgSwitched(); - fetchRequestId++; - }); - }); - - let skipInitialFetchEffect = true; - createEffect(() => { - const range = timeFilter(); - if (skipInitialFetchEffect) { - skipInitialFetchEffect = false; - return; - } - 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; - } - setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: true })); - try { - const incidents = await AlertsAPI.getIncidentsForResource(resourceId, limit); - setResourceIncidents((prev) => ({ ...prev, [resourceId]: incidents })); - } catch (error) { - logger.error(getAlertResourceIncidentLoadFailure(), error); - notificationStore.error(getAlertResourceIncidentLoadFailure()); - } finally { - setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false })); - } - }; - - const openResourceIncidentPanel = async (resourceId: string, resourceName: string) => { - if (!resourceId) { - return; - } - setResourceIncidentPanel({ resourceId, resourceName }); - setExpandedResourceIncidentIds(new Set()); - if (!(resourceId in resourceIncidents())) { - await loadResourceIncidents(resourceId); - } - }; - - const refreshResourceIncidentPanel = async () => { - const selection = resourceIncidentPanel(); - if (!selection) { - return; - } - await loadResourceIncidents(selection.resourceId); - }; - - const toggleResourceIncidentDetails = (incidentId: string) => { - setExpandedResourceIncidentIds((prev) => { - const next = new Set(prev); - if (next.has(incidentId)) { - next.delete(incidentId); - } else { - next.add(incidentId); - } - return next; - }); - }; - - 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'; - }; - - type HistoryItemSource = 'alert' | 'ai'; - interface HistoryItem { - id: string; - source: HistoryItemSource; - 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; - } - - const allHistoryData = createMemo(() => { - const items: HistoryItem[] = []; - - 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, - }); - }); - - 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') { - const currentSeverityFilter = severityFilter(); - filtered = filtered.filter((item) => item.severity === currentSeverityFilter); - } - - 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; - }; - - type AlertHistoryRow = ReturnType[number]; - const getIncidentRowKey = (alert: AlertHistoryRow) => `${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: AlertHistoryRow[]; - } - >(); - - 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()); - }); - - 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; - - 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 max = Math.max(...buckets, 1); - - return { - buckets, - max, - 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 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 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), - }; - }); - - 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 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, - }; + openResourceIncidentPanel, + refreshResourceIncidentPanel, + toggleResourceIncidentDetails, + alertData, + groupedAlerts, + alertTrends, + bucketDurationLabel, + rangeSummary, + axisTicks, + selectedBucketDetails, + formatBucketRange, + getIncidentRowKey, + clearAlertHistory, + } = useAlertHistoryState({ + activeAlerts: () => activeAlerts || {}, + getResource: props.getResource, + allResources: props.allResources, }); return ( @@ -1528,16 +829,8 @@ export function HistoryTab(props: HistoryTabProps) {