diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 8f648bc80..c4e301f19 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -149,11 +149,12 @@ assume discovery metadata is always present when deriving override IDs or toggle styling. The alerts page shell in `frontend-modern/src/pages/Alerts.tsx` must now keep -destinations and schedule rendering feature-owned under +destinations, history, and schedule rendering feature-owned under `frontend-modern/src/features/alerts/tabs/`. New alert tab surfaces should be extracted as feature modules instead of remaining page-local function blocks, so the page owns navigation/save orchestration while tab files own their -runtime presentation and tab-local interaction logic. +runtime presentation, tab-local interaction logic, and any history-table +presentation that does not belong in a shared primitive. Alert filter metadata and grouped header consumers must also preserve the canonical `agent` and `node` header boundary when reusing shared filter diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 00a1df909..51d8a4be7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -223,10 +223,12 @@ The alerts page shell now follows that same page-shell rule for feature tabs: `frontend-modern/src/pages/Alerts.tsx` owns navigation, load/save orchestration, and cross-tab state, while feature-owned tab surfaces such as `frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` and +`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx` plus `frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx` own their tab-local rendering and interaction logic. Future alert tab cleanup should continue by extracting page-local tab blocks into feature modules rather than expanding the -top-level page file again. +top-level page file again, and history-table behavior should stay feature-owned +unless it graduates into a shared primitive used by more than one alert surface. 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/tabs/HistoryTab.tsx b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx new file mode 100644 index 000000000..605431394 --- /dev/null +++ b/frontend-modern/src/features/alerts/tabs/HistoryTab.tsx @@ -0,0 +1,1624 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, 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'; +import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel'; +import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; +import { Card } from '@/components/shared/Card'; +import { LabeledFilterSelect } from '@/components/shared/FilterToolbar'; +import { PageControls } from '@/components/shared/PageControls'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { SectionHeader } from '@/components/shared/SectionHeader'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + 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, +} from '@/utils/alertAdministrationPresentation'; +import { + getAlertBucketCountLabel, + getAlertHistoryEmptyState, + getAlertHistoryLoadingState, + getAlertHistorySearchPlaceholder, +} from '@/utils/alertOverviewPresentation'; +import { + getAlertFrequencyClearFilterButtonClass, + getAlertFrequencySelectionPresentation, +} from '@/utils/alertFrequencyPresentation'; +import { + getAlertHistoryResourceTypeBadgeClass, + getAlertHistorySourcePresentation, +} from '@/utils/alertHistoryPresentation'; +import { + getAlertHistoryStatusPresentation, + getAlertIncidentLevelBadgeClass, + getAlertIncidentStatusPresentation, + getAlertIncidentTimelineHeadingClass, + getAlertIncidentTimelineMetaRowClass, + getAlertIncidentTimelineOutputClass, + getAlertResourceIncidentAcknowledgedByLabel, + getAlertResourceIncidentActivityChipClass, + getAlertResourceIncidentActivitySummaryClass, + getAlertResourceIncidentCardClass, + getAlertResourceIncidentCountLabel, + getAlertResourceIncidentEmptyState, + getAlertResourceIncidentFilteredEventsEmptyState, + getAlertResourceIncidentLoadFailure, + getAlertResourceIncidentLoadingState, + getAlertResourceIncidentNoteSaveFailure, + getAlertResourceIncidentNoteSavedLabel, + getAlertResourceIncidentPanelTitle, + getAlertResourceIncidentRefreshLabel, + getAlertResourceIncidentSummaryRowClass, + getAlertResourceIncidentTimelineFailure, + getAlertResourceIncidentToggleButtonClass, + getAlertResourceIncidentToggleLabel, + getAlertResourceIncidentTruncatedEventsLabel, + getAlertResourceIncidentViewTitle, +} from '@/utils/alertIncidentPresentation'; +import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation'; +import { getTypeColumnLabel } from '@/utils/typeColumnPresentation'; + +import { + alertTypeDisplayLabel, + unifiedTypeToAlertDisplayType, +} from '../helpers'; +import { + filterIncidentEvents, + INCIDENT_EVENT_TYPES, + summarizeIncidentEvents, +} from '../types'; + +const MS_PER_HOUR = 60 * 60 * 1000; + +export interface HistoryTabProps { + hasAIAlertsFeature: () => boolean; + licenseLoading: () => boolean; + getResource: (resourceId: string) => Resource | undefined; + allResources: () => Resource[]; +} + +export function HistoryTab(props: HistoryTabProps) { + const { activeAlerts } = useWebSocket(); + 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 [historyIncidentEventFilters, setHistoryIncidentEventFilters] = createSignal>( + new Set(INCIDENT_EVENT_TYPES), + ); + const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal>( + new Set(INCIDENT_EVENT_TYPES), + ); + const [incidentTimelines, setIncidentTimelines] = createSignal>( + {}, + ); + const [incidentLoading, setIncidentLoading] = createSignal>({}); + const [incidentErrors, setIncidentErrors] = createSignal>({}); + const [expandedIncidents, setExpandedIncidents] = createSignal>(new Set()); + const [incidentNoteDrafts, setIncidentNoteDrafts] = createSignal>({}); + const [incidentNoteSaving, setIncidentNoteSaving] = createSignal>(new Set()); + const { isMobile } = useBreakpoint(); + const [filtersOpen, setFiltersOpen] = createSignal(false); + + 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()); + setIncidentTimelines({}); + setIncidentLoading({}); + setIncidentErrors({}); + setExpandedIncidents(new Set()); + setIncidentNoteDrafts({}); + setIncidentNoteSaving(new Set()); + 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 loadIncidentTimeline = async ( + rowKey: string, + alertIdentifier: string, + startedAt?: string, + ) => { + setIncidentLoading((prev) => ({ ...prev, [rowKey]: true })); + try { + const timeline = await AlertsAPI.getIncidentTimeline(alertIdentifier, startedAt); + setIncidentTimelines((prev) => ({ ...prev, [rowKey]: timeline })); + setIncidentErrors((prev) => ({ ...prev, [rowKey]: false })); + } catch (error) { + logger.error(getAlertResourceIncidentTimelineFailure(), error); + notificationStore.error(getAlertResourceIncidentTimelineFailure()); + setIncidentErrors((prev) => ({ ...prev, [rowKey]: true })); + } finally { + setIncidentLoading((prev) => ({ ...prev, [rowKey]: false })); + } + }; + + const toggleIncidentTimeline = async ( + rowKey: string, + alertIdentifier: string, + startedAt?: string, + ) => { + const expanded = expandedIncidents(); + const next = new Set(expanded); + if (next.has(rowKey)) { + next.delete(rowKey); + setExpandedIncidents(next); + return; + } + next.add(rowKey); + setExpandedIncidents(next); + if (!(rowKey in incidentTimelines())) { + await loadIncidentTimeline(rowKey, alertIdentifier, startedAt); + } + }; + + const saveIncidentNote = async ( + rowKey: string, + alertIdentifier: string, + startedAt?: string, + ) => { + const note = (incidentNoteDrafts()[rowKey] || '').trim(); + if (!note) { + return; + } + setIncidentNoteSaving((prev) => new Set(prev).add(rowKey)); + try { + const incidentId = incidentTimelines()[rowKey]?.id; + await AlertsAPI.addIncidentNote({ alertIdentifier, incidentId, note }); + setIncidentNoteDrafts((prev) => ({ ...prev, [rowKey]: '' })); + await loadIncidentTimeline(rowKey, alertIdentifier, startedAt); + notificationStore.success(getAlertResourceIncidentNoteSavedLabel()); + } catch (error) { + logger.error(getAlertResourceIncidentNoteSaveFailure(), error); + notificationStore.error(getAlertResourceIncidentNoteSaveFailure()); + } finally { + setIncidentNoteSaving((prev) => { + const next = new Set(prev); + next.delete(rowKey); + return next; + }); + } + }; + + 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, + }; + }); + + return ( +
+ +
+ {alertData().length} alerts} + size="sm" + class="flex-1" + /> +
+ + {(selection) => ( +
+ + Filtered Range + + {selection().rangeLabel} +
+ )} +
+
+
+ Bar size: {bucketDurationLabel()} +
+ + {(summary) => ( +
+ Range: + {summary().startLabel} + + {summary().endLabel} +
+ )} +
+
+
+ + + +
+ +
+ {alertData().filter((alert) => alert.severity === 'warning').length} warnings +
+ +
+ {alertData().filter((alert) => alert.severity === 'critical').length} critical +
+
+
+
+
+ +
+ Showing {alertTrends().buckets.length} time periods ({bucketDurationLabel()} each) · + Total: {alertData().length} alerts +
+ + {(() => { + const trends = alertTrends(); + return ( +
+
+ {trends.buckets.map((value, index) => { + const scaledHeight = + value > 0 ? Math.min(100, Math.max(20, Math.log(value + 1) * 20)) : 0; + const pixelHeight = value > 0 ? Math.max(8, (scaledHeight / 100) * 40) : 0; + const isSelected = selectedBarIndex() === index; + const bucketStart = trends.bucketTimes[index]; + const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR; + const bucketRangeLabel = formatBucketRange(bucketStart, bucketEnd); + const bucketDurationText = + trends.bucketSize % 24 === 0 + ? `${trends.bucketSize / 24} day${trends.bucketSize / 24 === 1 ? '' : 's'}` + : `${trends.bucketSize} hour${trends.bucketSize === 1 ? '' : 's'}`; + const countLabel = getAlertBucketCountLabel(value); + const tooltipContent = [ + countLabel, + `${bucketDurationText} period`, + bucketRangeLabel, + ].join('\n'); + return ( +
setSelectedBarIndex(index === selectedBarIndex() ? null : index)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedBarIndex(index === selectedBarIndex() ? null : index); + } + }} + > +
+
0 ? (isSelected ? '#2563eb' : '#3b82f6') : 'transparent', + opacity: isSelected ? '1' : '0.8', + 'box-shadow': isSelected ? '0 0 0 2px rgba(37, 99, 235, 0.4)' : 'none', + }} + title={bucketRangeLabel} + onMouseEnter={(event) => { + if (value <= 0) { + hideTooltip(); + return; + } + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + showTooltip(tooltipContent, rect.left + rect.width / 2, rect.top, { + align: 'center', + direction: 'up', + }); + }} + onMouseLeave={() => hideTooltip()} + /> +
+ ); + })} +
+
+ ); + })()} + + 0}> +
+
+ + {(tick) => ( +
+
+
+ {tick.label} +
+
+ )} +
+
+
+ + + + + } + mobileFilters={{ + enabled: isMobile(), + onToggle: () => setFiltersOpen((open) => !open), + count: activeFilterCount(), + }} + showFilters={!isMobile() || filtersOpen()} + > + + setTimeFilter(event.currentTarget.value as '24h' | '7d' | '30d' | 'all') + } + selectClass="min-w-[7rem]" + > + + + + + + + setSeverityFilter(event.currentTarget.value as 'warning' | 'critical' | 'all') + } + selectClass="min-w-[7rem]" + > + + + + + + + + + {(selection) => { + const resourceId = selection().resourceId; + const incidents = () => resourceIncidents()[resourceId] || []; + const isLoading = () => resourceIncidentLoading()[resourceId]; + return ( + +
+
+

+ {getAlertResourceIncidentPanelTitle()} +

+

+ {selection().resourceName} + 0}> + + {' '} + · {getAlertResourceIncidentCountLabel(incidents().length)} + + +

+
+
+ + +
+
+ +

{getAlertResourceIncidentLoadingState().text}

+
+ + 0}> +
+ +
+
+ 0} + fallback={ +

+ {getAlertResourceIncidentEmptyState().text} +

+ } + > +
+ + {(incident) => { + const statusPresentation = getAlertIncidentStatusPresentation( + incident.status, + incident.acknowledged, + ); + const isExpanded = expandedResourceIncidentIds().has(incident.id); + const events = incident.events || []; + const filteredEvents = filterIncidentEvents( + events, + resourceIncidentEventFilters(), + ); + const eventSummary = summarizeIncidentEvents(filteredEvents); + const recentEvents = + filteredEvents.length > 6 + ? filteredEvents.slice(filteredEvents.length - 6) + : filteredEvents; + const lastEvent = + filteredEvents.length > 0 + ? filteredEvents[filteredEvents.length - 1] + : undefined; + const filteredLabel = + filteredEvents.length !== events.length + ? `${filteredEvents.length}/${events.length}` + : `${events.length}`; + return ( +
+
+ + {incident.alertType} + + + {incident.level} + + + {statusPresentation.label} + + opened {new Date(incident.openedAt).toLocaleString()} + + + closed {new Date(incident.closedAt as string).toLocaleString()} + + +
+ +

{incident.message}

+
+ +

+ {getAlertResourceIncidentAcknowledgedByLabel( + incident.ackUser ?? '', + )} +

+
+ 0}> +
+ 0} + fallback={ + + {getAlertResourceIncidentFilteredEventsEmptyState().text} + + } + > +
+ + Activity + + + {(summary) => ( + + {summary.label} {summary.count} + + )} + + + {filteredEvents.length !== events.length + ? `${filteredEvents.length}/${events.length} events` + : `${events.length} event${events.length === 1 ? '' : 's'}`} + + + Latest: {lastEvent?.summary} + +
+
+ +
+
+ +
+ 0} + fallback={ +

+ {getAlertResourceIncidentFilteredEventsEmptyState().text} +

+ } + > + + {(event) => ( + + )} + + 0}> +

+ {getAlertResourceIncidentTruncatedEventsLabel( + recentEvents.length, + filteredEvents.length, + )} +

+
+
+
+
+
+ ); + }} +
+
+
+
+
+ ); + }} +
+ + 0} + fallback={ +
+

{getAlertHistoryEmptyState().title}

+

{getAlertHistoryEmptyState().description}

+
+ } + > +
+
+ + + + + Timestamp + + + Source + + + Resource + + + {getTypeColumnLabel()} + + + Severity + + + Message + + + Duration + + + Status + + + Node + + + Actions + + + + + + {(group) => ( + <> + + +
+ + {group.label} + + + {(() => { + const alertCount = group.alerts.filter( + (alert) => alert.source === 'alert', + ).length; + const aiCount = group.alerts.filter( + (alert) => alert.source === 'ai', + ).length; + const parts = []; + if (alertCount > 0) { + parts.push( + `${alertCount} alert${alertCount === 1 ? '' : 's'}`, + ); + } + if (aiCount > 0) { + parts.push( + `${aiCount} patrol insight${aiCount === 1 ? '' : 's'}`, + ); + } + return ( + parts.join(', ') || + `${group.alerts.length} item${group.alerts.length === 1 ? '' : 's'}` + ); + })()} + +
+
+
+ + + {(alert) => { + const rowKey = getIncidentRowKey(alert); + const historyStatusPresentation = getAlertHistoryStatusPresentation( + alert.status, + ); + const sourcePresentation = getAlertHistorySourcePresentation( + alert.source, + ); + return ( + <> + + + {new Date(alert.startTime).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + })} + + + + + {sourcePresentation.label} + + + + + {alert.resourceName} + + + + + {alert.resourceType} + + + + + + {alert.severity} + + + + + {alert.description} + + + + {alert.duration} + + + + + {historyStatusPresentation.label} + + + + + {alert.nodeDisplayName || alert.node || '—'} + + + +
+ + + + + + + + + +
+
+
+ + + + + setIncidentNoteDrafts((prev) => ({ + ...prev, + [rowKey]: value, + })) + } + noteSaving={incidentNoteSaving().has(rowKey)} + onSaveNote={() => { + void saveIncidentNote(rowKey, alert.id, alert.startTime); + }} + onRetry={() => { + void loadIncidentTimeline( + rowKey, + alert.id, + alert.startTime, + ); + }} + /> + + + + + ); + }} +
+ + )} +
+
+
+
+
+
+ } + > +
+

{getAlertHistoryLoadingState().text}

+
+ + + 0}> +
+
+
+
+

+ {getAlertAdministrationSectionTitle()} +

+

{getAlertAdministrationSectionDescription()}

+
+ +
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index ae823e005..3b691aa86 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -1,6 +1,5 @@ import { createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js'; import { useBeforeLeave } from '@solidjs/router'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import type { JSX } from 'solid-js'; import type { Alert } from '@/types/api'; @@ -17,81 +16,23 @@ import { import { useLocation, useNavigate } from '@solidjs/router'; import { logger } from '@/utils/logger'; import { Card } from '@/components/shared/Card'; -import { - LabeledFilterSelect, -} from '@/components/shared/FilterToolbar'; -import { PageControls } from '@/components/shared/PageControls'; import { SectionHeader } from '@/components/shared/SectionHeader'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { notificationStore } from '@/stores/notifications'; import { eventBus } from '@/stores/events'; -import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; +import { notificationStore } from '@/stores/notifications'; import Calendar from 'lucide-solid/icons/calendar'; -import { SearchInput } from '@/components/shared/SearchInput'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters'; -import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel'; -import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard'; import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable'; -import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; import type { PMGThresholdDefaults } from '@/types/alerts'; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from '@/components/shared/Table'; import { useWebSocket } from '@/App'; import { useResources } from '@/hooks/useResources'; import { aiChatStore } from '@/stores/aiChat'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; -import type { Incident, PBSInstance, PMGInstance } from '@/types/api'; +import type { PBSInstance, PMGInstance } from '@/types/api'; import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import { pbsInstanceFromResource, pmgInstanceFromResource } from '@/utils/resourceStateAdapters'; import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget'; import { getActionableAgentIdFromResource, hasAgentFacet } from '@/utils/agentResources'; -import { - getAlertHistoryStatusPresentation, - getAlertIncidentLevelBadgeClass, - getAlertIncidentTimelineHeadingClass, - getAlertIncidentTimelineMetaRowClass, - getAlertIncidentTimelineOutputClass, - getAlertResourceIncidentActivityChipClass, - getAlertResourceIncidentActivitySummaryClass, - getAlertResourceIncidentAcknowledgedByLabel, - getAlertResourceIncidentCardClass, - getAlertResourceIncidentCountLabel, - getAlertResourceIncidentEmptyState, - getAlertResourceIncidentFilteredEventsEmptyState, - getAlertResourceIncidentLoadFailure, - getAlertResourceIncidentLoadingState, - getAlertResourceIncidentNoteSaveFailure, - getAlertResourceIncidentNoteSavedLabel, - getAlertResourceIncidentPanelTitle, - getAlertResourceIncidentRefreshLabel, - getAlertResourceIncidentSummaryRowClass, - getAlertResourceIncidentTimelineFailure, - getAlertResourceIncidentToggleLabel, - getAlertResourceIncidentToggleButtonClass, - getAlertResourceIncidentTruncatedEventsLabel, - getAlertResourceIncidentViewTitle, - getAlertIncidentStatusPresentation, -} from '@/utils/alertIncidentPresentation'; -import { - getAlertHistoryResourceTypeBadgeClass, - getAlertHistorySourcePresentation, -} from '@/utils/alertHistoryPresentation'; -import { - getAlertAdministrationClearHistoryConfirmation, - getAlertAdministrationClearHistoryError, - getAlertAdministrationClearHistoryLabel, - getAlertAdministrationSectionDescription, - getAlertAdministrationSectionTitle, -} from '@/utils/alertAdministrationPresentation'; import { getAlertActivationFailure, getAlertActivationPresentation, @@ -99,11 +40,6 @@ import { getAlertDeactivationFailure, getAlertDeactivationSuccess, } from '@/utils/alertActivationPresentation'; -import { - getAlertFrequencyClearFilterButtonClass, - getAlertFrequencySelectionPresentation, -} from '@/utils/alertFrequencyPresentation'; -import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation'; import { getAlertsTabGroups, getAlertsMobileTabClass, @@ -114,11 +50,7 @@ import { getAlertDestinationsConfigLoadError, } from '@/utils/alertDestinationsPresentation'; import { - getAlertBucketCountLabel, getAlertsPageHeaderMeta, - getAlertHistoryLoadingState, - getAlertHistoryEmptyState, - getAlertHistorySearchPlaceholder, } from '@/utils/alertOverviewPresentation'; import { getAlertConfigDiscardedSuccess, @@ -130,16 +62,14 @@ import { getAlertConfigUnsavedChangesLabel, getAlertConfigLeaveConfirmation, } from '@/utils/alertConfigPresentation'; -import { getTypeColumnLabel } from '@/utils/typeColumnPresentation'; - import { useAlertsActivation } from '@/stores/alertsActivation'; -import { filterIncidentEvents } from '@/features/alerts/types'; import LayoutDashboard from 'lucide-solid/icons/layout-dashboard'; import History from 'lucide-solid/icons/history'; import Gauge from 'lucide-solid/icons/gauge'; import Send from 'lucide-solid/icons/send'; import { OverviewTab } from '@/features/alerts/OverviewTab'; import { DestinationsTab } from '@/features/alerts/tabs/DestinationsTab'; +import { HistoryTab } from '@/features/alerts/tabs/HistoryTab'; import { ScheduleTab } from '@/features/alerts/tabs/ScheduleTab'; import { pathForTab, @@ -154,9 +84,7 @@ import { type GroupingConfig, type EscalationConfig, type EscalationNotifyTarget, - INCIDENT_EVENT_TYPES, GROUPING_WINDOW_DEFAULT_SECONDS, - summarizeIncidentEvents, clampCooldownMinutes, } from '@/features/alerts/types'; import { @@ -175,8 +103,6 @@ import { createDefaultEscalation, getTriggerValue, extractTriggerValues, - unifiedTypeToAlertDisplayType, - alertTypeDisplayLabel, platformData, guessNumericId, getAlertResourceDisplayLabel, @@ -2398,1581 +2324,3 @@ function ThresholdsTab(props: ThresholdsTabProps) { /> ); } - -// History Tab - Comprehensive alert table -function HistoryTab(props: { - hasAIAlertsFeature: () => boolean; - licenseLoading: () => boolean; - getResource: ReturnType['get']; - allResources: ReturnType['resources']; -}) { - const { activeAlerts } = useWebSocket(); - const alertFrequencySelectionPresentation = createMemo(() => - getAlertFrequencySelectionPresentation(), - ); - - // Filter states with localStorage persistence - 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 [historyIncidentEventFilters, setHistoryIncidentEventFilters] = createSignal>( - new Set(INCIDENT_EVENT_TYPES), - ); - const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal>( - new Set(INCIDENT_EVENT_TYPES), - ); - const [incidentTimelines, setIncidentTimelines] = createSignal>( - {}, - ); - const [incidentLoading, setIncidentLoading] = createSignal>({}); - const [incidentErrors, setIncidentErrors] = createSignal>({}); - const [expandedIncidents, setExpandedIncidents] = createSignal>(new Set()); - const [incidentNoteDrafts, setIncidentNoteDrafts] = createSignal>({}); - const [incidentNoteSaving, setIncidentNoteSaving] = createSignal>(new Set()); - const { isMobile } = useBreakpoint(); - const [filtersOpen, setFiltersOpen] = createSignal(false); - const activeFilterCount = createMemo(() => { - let count = 0; - if (timeFilter() !== '7d') count++; - if (severityFilter() !== 'all') count++; - return count; - }); - const MS_PER_HOUR = 60 * 60 * 1000; - const userLocale = - Intl.DateTimeFormat().resolvedOptions().locale || - (typeof navigator !== 'undefined' ? navigator.language : undefined) || - 'en-US'; - - onMount(() => { - // History data fetching - }); - - 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 { - // Fetch alert history - const params = buildHistoryParams(range); - const alertHistoryData = await AlertsAPI.getHistory(params); - - if (requestId === fetchRequestId) { - setAlertHistory(alertHistoryData); - } - } catch (err) { - if (requestId === fetchRequestId) { - logger.error('Failed to load history:', err); - } - } finally { - if (requestId === fetchRequestId) { - setLoading(false); - } - } - }; - - // Persist filter changes to localStorage - - // Clear chart selection when high-level filters change - 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; - }); - - // Load alert history on mount - onMount(() => { - fetchHistory(timeFilter()); - - const unsubscribeOrgSwitched = eventBus.on('org_switched', () => { - setAlertHistory([]); - setSelectedBarIndex(null); - setResourceIncidentPanel(null); - setResourceIncidents({}); - setResourceIncidentLoading({}); - setExpandedResourceIncidentIds(new Set()); - setIncidentTimelines({}); - setIncidentLoading({}); - setIncidentErrors({}); - setExpandedIncidents(new Set()); - setIncidentNoteDrafts({}); - setIncidentNoteSaving(new Set()); - void fetchHistory(timeFilter()); - }); - - onCleanup(() => { - unsubscribeOrgSwitched(); - // Prevent pending requests from updating state after unmount - fetchRequestId++; - }); - }); - - let skipInitialFetchEffect = true; - createEffect(() => { - const range = timeFilter(); - if (skipInitialFetchEffect) { - skipInitialFetchEffect = false; - return; - } - fetchHistory(range); - }); - - // Format duration for display - 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; - - // Handle negative durations (clock skew or timezone issues) - 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}`; - }; - - // Get resource type (VM, Container, Node, Storage, Docker, PBS, etc.) - const getResourceType = ( - resourceName: string, - metadata?: Record | undefined, - resourceId?: string, - ) => { - // 1. Canonical path: metadata.resourceType (set by checkMetric on the backend) - const metadataType = - typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined; - if (metadataType && metadataType.trim().length > 0) { - return metadataType; - } - - // 2. Unified resource lookup by ID (preferred fallback) - if (resourceId) { - const resource = props.getResource(resourceId); - if (resource) { - return unifiedTypeToAlertDisplayType(resource.type); - } - } - - // 3. Unified resource lookup by name (for old historical alerts without resourceId) - const resources = props.allResources(); - const byName = resources.find((r) => r.name === resourceName || r.displayName === resourceName); - if (byName) { - return unifiedTypeToAlertDisplayType(byName.type); - } - - return 'Unknown'; - }; - - // Unified history item type that can be either an alert or an AI finding - 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; // warning, critical for alerts; severity for findings - title: string; - rawAlertType?: string; // original backend alert type for InvestigateAlertButton - description?: string; - acknowledged?: boolean; - autoResolved?: boolean; - } - - // Prepare all history items from alerts - const allHistoryData = createMemo(() => { - const items: HistoryItem[] = []; - - // Add active alerts - 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, - }); - }); - - // Create a set of active alert IDs for quick lookup - const activeAlertIds = new Set(Object.keys(activeAlerts || {})); - - // Add historical alerts - 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; - }); - - // Apply severity & search filters (time filtering is layered separately) - const severityAndSearchFilteredItems = createMemo(() => { - let filtered = allHistoryData(); - - // Filter by severity - if (severityFilter() !== 'all') { - const sevFilter = severityFilter(); - filtered = filtered.filter((item) => item.severity === sevFilter); - } - - 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; - }); - - // Apply filters to get the final alert data - const alertData = createMemo(() => { - let filtered = severityAndSearchFilteredItems(); - const currentTimeFilter = timeFilter(); - - // Selected bar filter (takes precedence over time filter) - 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((a) => new Date(a.startTime).getTime() > cutoff); - } - } - - // Sort by start time (newest first) - 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}`; - - // Group alerts by day for display - 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()); - }); - - // Calculate alert trends for mini-chart - const alertTrends = createMemo(() => { - const now = Date.now(); - const msPerHour = MS_PER_HOUR; - const filteredAlerts = severityAndSearchFilteredItems(); - const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440]; // hours - 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 * msPerHour; - } else if (filter === '7d') { - bucketSizeHours = 6; - computedRangeHours = 7 * 24; - startTime = now - computedRangeHours * msPerHour; - } else if (filter === '30d') { - bucketSizeHours = 24; - computedRangeHours = 30 * 24; - startTime = now - computedRangeHours * msPerHour; - } else { - if (!filteredAlerts.length) { - bucketSizeHours = 24; - computedRangeHours = 24; - startTime = now - computedRangeHours * msPerHour; - } 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) / msPerHour)); - 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 * msPerHour; - } - } - - const bucketCount = Math.min( - Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)), - maxBuckets, - ); - startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * msPerHour); - - const buckets = new Array(bucketCount).fill(0); - const bucketTimes = new Array(bucketCount) - .fill(0) - .map((_, i) => startTime + i * bucketSizeHours * msPerHour); - - 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 * msPerHour)); - 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 loadIncidentTimeline = async ( - rowKey: string, - alertIdentifier: string, - startedAt?: string, - ) => { - setIncidentLoading((prev) => ({ ...prev, [rowKey]: true })); - try { - const timeline = await AlertsAPI.getIncidentTimeline(alertIdentifier, startedAt); - setIncidentTimelines((prev) => ({ ...prev, [rowKey]: timeline })); - setIncidentErrors((prev) => ({ ...prev, [rowKey]: false })); - } catch (error) { - logger.error(getAlertResourceIncidentTimelineFailure(), error); - notificationStore.error(getAlertResourceIncidentTimelineFailure()); - setIncidentErrors((prev) => ({ ...prev, [rowKey]: true })); - } finally { - setIncidentLoading((prev) => ({ ...prev, [rowKey]: false })); - } - }; - - const toggleIncidentTimeline = async ( - rowKey: string, - alertIdentifier: string, - startedAt?: string, - ) => { - const expanded = expandedIncidents(); - const next = new Set(expanded); - if (next.has(rowKey)) { - next.delete(rowKey); - setExpandedIncidents(next); - return; - } - next.add(rowKey); - setExpandedIncidents(next); - if (!(rowKey in incidentTimelines())) { - await loadIncidentTimeline(rowKey, alertIdentifier, startedAt); - } - }; - - const saveIncidentNote = async ( - rowKey: string, - alertIdentifier: string, - startedAt?: string, - ) => { - const note = (incidentNoteDrafts()[rowKey] || '').trim(); - if (!note) { - return; - } - setIncidentNoteSaving((prev) => new Set(prev).add(rowKey)); - try { - const incidentId = incidentTimelines()[rowKey]?.id; - await AlertsAPI.addIncidentNote({ alertIdentifier, incidentId, note }); - setIncidentNoteDrafts((prev) => ({ ...prev, [rowKey]: '' })); - await loadIncidentTimeline(rowKey, alertIdentifier, startedAt); - notificationStore.success(getAlertResourceIncidentNoteSavedLabel()); - } catch (error) { - logger.error(getAlertResourceIncidentNoteSaveFailure(), error); - notificationStore.error(getAlertResourceIncidentNoteSaveFailure()); - } finally { - setIncidentNoteSaving((prev) => { - const next = new Set(prev); - next.delete(rowKey); - return next; - }); - } - }; - - 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, - }; - }); - - return ( -
- {/* Alert Trends Mini-Chart */} - -
- {alertData().length} alerts} - size="sm" - class="flex-1" - /> -
- - {(selection) => ( -
- - Filtered Range - - {selection().rangeLabel} -
- )} -
-
-
- Bar size: {bucketDurationLabel()} -
- - {(summary) => ( -
- Range: - {summary().startLabel} - - {summary().endLabel} -
- )} -
-
-
- - - -
- -
- {alertData().filter((a) => a.severity === 'warning').length} warnings -
- -
- {alertData().filter((a) => a.severity === 'critical').length} critical -
-
-
-
-
- - {/* Mini sparkline chart */} -
- Showing {alertTrends().buckets.length} time periods ({bucketDurationLabel()} each) · - Total: {alertData().length} alerts -
- - {/* Alert frequency chart */} - {(() => { - const trends = alertTrends(); - return ( -
-
- {trends.buckets.map((val, i) => { - const scaledHeight = - val > 0 ? Math.min(100, Math.max(20, Math.log(val + 1) * 20)) : 0; - const pixelHeight = val > 0 ? Math.max(8, (scaledHeight / 100) * 40) : 0; // 40px is roughly the inner height - const isSelected = selectedBarIndex() === i; - const bucketStart = trends.bucketTimes[i]; - const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR; - const bucketRangeLabel = formatBucketRange(bucketStart, bucketEnd); - const bucketDurationText = - trends.bucketSize % 24 === 0 - ? `${trends.bucketSize / 24} day${trends.bucketSize / 24 === 1 ? '' : 's'}` - : `${trends.bucketSize} hour${trends.bucketSize === 1 ? '' : 's'}`; - const countLabel = getAlertBucketCountLabel(val); - const tooltipContent = [ - countLabel, - `${bucketDurationText} period`, - bucketRangeLabel, - ].join('\n'); - return ( -
setSelectedBarIndex(i === selectedBarIndex() ? null : i)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedBarIndex(i === selectedBarIndex() ? null : i); - } - }} - > - {/* Background track for all slots */} -
- {/* Actual bar */} -
0 ? (isSelected ? '#2563eb' : '#3b82f6') : 'transparent', - opacity: isSelected ? '1' : '0.8', - 'box-shadow': isSelected ? '0 0 0 2px rgba(37, 99, 235, 0.4)' : 'none', - }} - title={bucketRangeLabel} - onMouseEnter={(e) => { - if (val <= 0) { - hideTooltip(); - return; - } - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - showTooltip(tooltipContent, rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up', - }); - }} - onMouseLeave={() => hideTooltip()} - /> -
- ); - })} -
-
- ); - })()} - - 0}> -
-
- - {(tick) => ( -
-
-
- {tick.label} -
-
- )} -
-
-
- - - {/* Filters */} - - - } - mobileFilters={{ - enabled: isMobile(), - onToggle: () => setFiltersOpen((o) => !o), - count: activeFilterCount(), - }} - showFilters={!isMobile() || filtersOpen()} - > - setTimeFilter(e.currentTarget.value as '24h' | '7d' | '30d' | 'all')} - selectClass="min-w-[7rem]" - > - - - - - - - setSeverityFilter(e.currentTarget.value as 'warning' | 'critical' | 'all') - } - selectClass="min-w-[7rem]" - > - - - - - - - - - {(selection) => { - const resourceId = selection().resourceId; - const incidents = () => resourceIncidents()[resourceId] || []; - const isLoading = () => resourceIncidentLoading()[resourceId]; - return ( - -
-
-

- {getAlertResourceIncidentPanelTitle()} -

-

- {selection().resourceName} - 0}> - - {' '} - · {getAlertResourceIncidentCountLabel(incidents().length)} - - -

-
-
- - -
-
- -

{getAlertResourceIncidentLoadingState().text}

-
- - 0}> -
- -
-
- 0} - fallback={ -

- {getAlertResourceIncidentEmptyState().text} -

- } - > -
- - {(incident) => { - const statusPresentation = getAlertIncidentStatusPresentation( - incident.status, - incident.acknowledged, - ); - const isExpanded = expandedResourceIncidentIds().has(incident.id); - const events = incident.events || []; - const filteredEvents = filterIncidentEvents( - events, - resourceIncidentEventFilters(), - ); - const eventSummary = summarizeIncidentEvents(filteredEvents); - const recentEvents = - filteredEvents.length > 6 - ? filteredEvents.slice(filteredEvents.length - 6) - : filteredEvents; - const lastEvent = - filteredEvents.length > 0 - ? filteredEvents[filteredEvents.length - 1] - : undefined; - const filteredLabel = - filteredEvents.length !== events.length - ? `${filteredEvents.length}/${events.length}` - : `${events.length}`; - return ( -
-
- - {incident.alertType} - - - {incident.level} - - - {statusPresentation.label} - - opened {new Date(incident.openedAt).toLocaleString()} - - - closed {new Date(incident.closedAt as string).toLocaleString()} - - -
- -

{incident.message}

-
- -

- {getAlertResourceIncidentAcknowledgedByLabel( - incident.ackUser ?? '', - )} -

-
- 0}> -
- 0} - fallback={ - - {getAlertResourceIncidentFilteredEventsEmptyState().text} - - } - > -
- - Activity - - - {(summary) => ( - - {summary.label} {summary.count} - - )} - - - {filteredEvents.length !== events.length - ? `${filteredEvents.length}/${events.length} events` - : `${events.length} event${events.length === 1 ? '' : 's'}`} - - - Latest: {lastEvent?.summary} - -
-
- -
-
- -
- 0} - fallback={ -

- {getAlertResourceIncidentFilteredEventsEmptyState().text} -

- } - > - - {(event) => ( - - )} - - 0}> -

- {getAlertResourceIncidentTruncatedEventsLabel( - recentEvents.length, - filteredEvents.length, - )} -

-
-
-
-
-
- ); - }} -
-
-
-
-
- ); - }} -
- - {/* Alert History Table */} - 0} - fallback={ -
-

{getAlertHistoryEmptyState().title}

-

{getAlertHistoryEmptyState().description}

-
- } - > - {/* Table */} -
-
- - - - - Timestamp - - - Source - - - Resource - - - {getTypeColumnLabel()} - - - Severity - - - Message - - - Duration - - - Status - - - Node - - - Actions - - - - - - {(group) => ( - <> - {/* Date divider */} - - -
- - {group.label} - - - {(() => { - const alertCount = group.alerts.filter( - (a) => a.source === 'alert', - ).length; - const aiCount = group.alerts.filter( - (a) => a.source === 'ai', - ).length; - const parts = []; - if (alertCount > 0) - parts.push( - `${alertCount} alert${alertCount === 1 ? '' : 's'}`, - ); - if (aiCount > 0) - parts.push( - `${aiCount} patrol insight${aiCount === 1 ? '' : 's'}`, - ); - return ( - parts.join(', ') || - `${group.alerts.length} item${group.alerts.length === 1 ? '' : 's'}` - ); - })()} - -
-
-
- - {/* Alerts for this day */} - - {(alert) => { - const rowKey = getIncidentRowKey(alert); - const historyStatusPresentation = getAlertHistoryStatusPresentation( - alert.status, - ); - const sourcePresentation = getAlertHistorySourcePresentation( - alert.source, - ); - return ( - <> - - {/* Timestamp */} - - {new Date(alert.startTime).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - })} - - - {/* Source */} - - - {sourcePresentation.label} - - - - {/* Resource */} - - {alert.resourceName} - - - {/* Type */} - - - {alert.resourceType} - - - - {/* Severity */} - - - {alert.severity} - - - - {/* Message */} - - {alert.description} - - - {/* Duration */} - - {alert.duration} - - - {/* Status */} - - - {historyStatusPresentation.label} - - - - {/* Node */} - - {alert.nodeDisplayName || alert.node || '—'} - - - {/* Actions */} - -
- - - - - - - - - -
-
-
- - - - - setIncidentNoteDrafts((prev) => ({ - ...prev, - [rowKey]: value, - })) - } - noteSaving={incidentNoteSaving().has(rowKey)} - onSaveNote={() => { - void saveIncidentNote(rowKey, alert.id, alert.startTime); - }} - onRetry={() => { - void loadIncidentTimeline( - rowKey, - alert.id, - alert.startTime, - ); - }} - /> - - - - - ); - }} -
- - )} -
-
-
-
-
-
- } - > -
-

{getAlertHistoryLoadingState().text}

-
- - - {/* History actions */} - 0}> -
-
-
-
-

- {getAlertAdministrationSectionTitle()} -

-

{getAlertAdministrationSectionDescription()}

-
- -
-
-
-
-
- ); -} diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 3b3f3a362..dda7759d0 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import alertsPageSource from '@/pages/Alerts.tsx?raw'; import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw'; +import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw'; import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw'; import { @@ -152,16 +153,22 @@ describe('tab path helpers', () => { expect(tabFromPath('/alerts/summary', custom)).toBe('overview'); }); - it('keeps destinations and schedule tabs feature-owned', () => { + it('keeps destinations, history, and schedule tabs feature-owned', () => { expect(alertsPageSource).toContain( "import { DestinationsTab } from '@/features/alerts/tabs/DestinationsTab';", ); + expect(alertsPageSource).toContain( + "import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';", + ); expect(alertsPageSource).toContain( "import { ScheduleTab } from '@/features/alerts/tabs/ScheduleTab';", ); expect(alertsPageSource).not.toContain('function DestinationsTab('); + expect(alertsPageSource).not.toContain('function HistoryTab('); expect(alertsPageSource).not.toContain('function ScheduleTab('); expect(alertDestinationsTabSource).toContain('NotificationsAPI.getWebhooks'); + expect(alertHistoryTabSource).toContain('AlertsAPI.getHistory'); + expect(alertHistoryTabSource).toContain('IncidentTimelinePanel'); expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions'); }); }); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 38e45cf58..b9a7fcc9b 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -224,6 +224,7 @@ import alertResourceTablePresentationSource from '@/utils/alertResourceTablePres import alertWebhookPresentationSource from '@/utils/alertWebhookPresentation.ts?raw'; import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw'; import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw'; +import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw'; import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw'; import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw'; import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw'; @@ -2184,8 +2185,8 @@ describe('frontend resource type boundaries', () => { 'export function getDeployInstallCommandLoadingState', ); expect(deployStatusPresentationSource).toContain('export const getDeployStatusPresentation'); - expect(alertsPageSource).toContain('getAlertIncidentStatusPresentation'); - expect(alertsPageSource).toContain('getAlertIncidentLevelBadgeClass'); + expect(alertHistoryTabSource).toContain('getAlertIncidentStatusPresentation'); + expect(alertHistoryTabSource).toContain('getAlertIncidentLevelBadgeClass'); expect(alertsPageSource).toContain('getAlertDestinationsConfigLoadError'); expect(alertDestinationsTabSource).toContain('getAlertDestinationsWebhookLoadError'); expect(alertDestinationsTabSource).toContain('getAlertDestinationsLoadErrorBanner'); @@ -2200,47 +2201,49 @@ describe('frontend resource type boundaries', () => { expect(alertDestinationsTabSource).toContain('getAlertDestinationsStatusLabel'); expect(alertDestinationsTabSource).toContain('getAlertWebhookTestSuccess'); expect(alertDestinationsTabSource).toContain('getAlertWebhookTestFailure'); - expect(alertsPageSource).toContain('getAlertHistoryStatusPresentation'); - expect(alertsPageSource).toContain('getAlertHistorySourcePresentation'); - expect(alertsPageSource).toContain('getAlertHistoryResourceTypeBadgeClass'); - expect(alertsPageSource).toContain('getAlertResourceIncidentPanelTitle'); - expect(alertsPageSource).toContain('getAlertResourceIncidentCountLabel'); - expect(alertsPageSource).toContain('getAlertResourceIncidentLoadingState'); - expect(alertsPageSource).toContain('getAlertResourceIncidentEmptyState'); - expect(alertsPageSource).toContain('getAlertResourceIncidentRefreshLabel'); - expect(alertsPageSource).toContain('getAlertResourceIncidentAcknowledgedByLabel'); - expect(alertsPageSource).toContain('getAlertResourceIncidentToggleLabel'); - expect(alertsPageSource).toContain('getAlertResourceIncidentFilteredEventsEmptyState'); - expect(alertsPageSource).toContain('IncidentEventFilters'); - expect(alertsPageSource).toContain('IncidentTimelinePanel'); - expect(alertsPageSource).toContain('getAlertIncidentTimelineMetaRowClass'); - expect(alertsPageSource).toContain('getAlertIncidentTimelineHeadingClass'); - expect(alertsPageSource).toContain('IncidentTimelineEventCard'); - expect(alertsPageSource).not.toContain('getAlertIncidentTimelineEventCardClass'); - expect(alertsPageSource).not.toContain('getAlertIncidentTimelineDetailClass'); - expect(alertsPageSource).not.toContain('getAlertIncidentTimelineCommandClass'); - expect(alertsPageSource).toContain('getAlertResourceIncidentCardClass'); - expect(alertsPageSource).toContain('getAlertResourceIncidentSummaryRowClass'); - expect(alertsPageSource).toContain('getAlertResourceIncidentToggleButtonClass'); - expect(alertsPageSource).toContain('getAlertResourceIncidentTruncatedEventsLabel'); - expect(alertsPageSource).toContain('getAlertBucketCountLabel'); - expect(alertsPageSource).toContain('getAlertHistorySearchPlaceholder'); + expect(alertsPageSource).toContain("import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';"); + expect(alertsPageSource).not.toContain('function HistoryTab('); + expect(alertHistoryTabSource).toContain('getAlertHistoryStatusPresentation'); + expect(alertHistoryTabSource).toContain('getAlertHistorySourcePresentation'); + expect(alertHistoryTabSource).toContain('getAlertHistoryResourceTypeBadgeClass'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentPanelTitle'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentCountLabel'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentLoadingState'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentEmptyState'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentRefreshLabel'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentAcknowledgedByLabel'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentToggleLabel'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentFilteredEventsEmptyState'); + expect(alertHistoryTabSource).toContain('IncidentEventFilters'); + expect(alertHistoryTabSource).toContain('IncidentTimelinePanel'); + expect(alertHistoryTabSource).toContain('getAlertIncidentTimelineMetaRowClass'); + expect(alertHistoryTabSource).toContain('getAlertIncidentTimelineHeadingClass'); + expect(alertHistoryTabSource).toContain('IncidentTimelineEventCard'); + expect(alertHistoryTabSource).not.toContain('getAlertIncidentTimelineEventCardClass'); + expect(alertHistoryTabSource).not.toContain('getAlertIncidentTimelineDetailClass'); + expect(alertHistoryTabSource).not.toContain('getAlertIncidentTimelineCommandClass'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentCardClass'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentSummaryRowClass'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentToggleButtonClass'); + expect(alertHistoryTabSource).toContain('getAlertResourceIncidentTruncatedEventsLabel'); + expect(alertHistoryTabSource).toContain('getAlertBucketCountLabel'); + expect(alertHistoryTabSource).toContain('getAlertHistorySearchPlaceholder'); expect(alertsPageSource).toContain('getAlertsPageHeaderMeta'); - expect(alertsPageSource).toContain('getAlertHistoryEmptyState'); - expect(alertsPageSource).toContain('getAlertHistoryLoadingState'); - expect(alertsPageSource).toContain('getAlertAdministrationSectionTitle'); - expect(alertsPageSource).toContain('getAlertAdministrationSectionDescription'); - expect(alertsPageSource).toContain('getAlertAdministrationClearHistoryLabel'); - expect(alertsPageSource).toContain('getAlertAdministrationClearHistoryError'); - expect(alertsPageSource).toContain('getAlertAdministrationClearHistoryConfirmation'); + expect(alertHistoryTabSource).toContain('getAlertHistoryEmptyState'); + expect(alertHistoryTabSource).toContain('getAlertHistoryLoadingState'); + expect(alertHistoryTabSource).toContain('getAlertAdministrationSectionTitle'); + expect(alertHistoryTabSource).toContain('getAlertAdministrationSectionDescription'); + expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryLabel'); + expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryError'); + expect(alertHistoryTabSource).toContain('getAlertAdministrationClearHistoryConfirmation'); expect(alertsPageSource).toContain('getAlertActivationPresentation'); expect(alertsPageSource).toContain('getAlertActivationSuccess'); expect(alertsPageSource).toContain('getAlertActivationFailure'); expect(alertsPageSource).toContain('getAlertDeactivationSuccess'); expect(alertsPageSource).toContain('getAlertDeactivationFailure'); - expect(alertsPageSource).toContain('getAlertFrequencySelectionPresentation'); - expect(alertsPageSource).toContain('getAlertFrequencyClearFilterButtonClass'); - expect(alertsPageSource).toContain('getAlertSeverityDotClass'); + expect(alertHistoryTabSource).toContain('getAlertFrequencySelectionPresentation'); + expect(alertHistoryTabSource).toContain('getAlertFrequencyClearFilterButtonClass'); + expect(alertHistoryTabSource).toContain('getAlertSeverityDotClass'); expect(alertsPageSource).toContain('getAlertsSidebarTabClass'); expect(alertsPageSource).toContain('getAlertsMobileTabClass'); expect(alertsPageSource).toContain('getAlertsTabTitle');