From 5927535110df116639769e82e221bf0a9d9d255e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 15 Oct 2025 18:41:06 +0000 Subject: [PATCH] Ref #556: adjust alert history range handling --- frontend-modern/src/pages/Alerts.tsx | 78 +++++++++++--- internal/alerts/alerts.go | 9 ++ internal/api/alerts.go | 156 ++++++++++++++++++++++++--- 3 files changed, 218 insertions(+), 25 deletions(-) diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 70ca631..7554170 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js'; +import { createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js'; import type { JSX } from 'solid-js'; import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect'; import { WebhookConfig } from '@/components/Alerts/WebhookConfig'; @@ -2936,6 +2936,54 @@ function HistoryTab() { (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 fetchAlertHistory = async (range: string) => { + const requestId = ++fetchRequestId; + setLoading(true); + + try { + const history = await AlertsAPI.getHistory(buildHistoryParams(range)); + if (requestId === fetchRequestId) { + setAlertHistory(history); + } + } catch (err) { + if (requestId === fetchRequestId) { + console.error('Failed to load alert history:', err); + } + } finally { + if (requestId === fetchRequestId) { + setLoading(false); + } + } + }; + // Ref for search input let searchInputRef: HTMLInputElement | undefined; @@ -2968,15 +3016,8 @@ function HistoryTab() { }); // Load alert history on mount - onMount(async () => { - try { - const history = await AlertsAPI.getHistory({ limit: 1000 }); - setAlertHistory(history); - } catch (err) { - console.error('Failed to load alert history:', err); - } finally { - setLoading(false); - } + onMount(() => { + fetchAlertHistory(timeFilter()); // Add keyboard event listeners const handleKeydown = (e: KeyboardEvent) => { @@ -3004,10 +3045,21 @@ function HistoryTab() { document.addEventListener('keydown', handleKeydown); - // Cleanup on unmount - return () => { + onCleanup(() => { document.removeEventListener('keydown', handleKeydown); - }; + // Prevent pending requests from updating state after unmount + fetchRequestId++; + }); + }); + + let skipInitialFetchEffect = true; + createEffect(() => { + const range = timeFilter(); + if (skipInitialFetchEffect) { + skipInitialFetchEffect = false; + return; + } + fetchAlertHistory(range); }); // Format duration for display diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index a84b868..26f3fd4 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -3540,6 +3540,15 @@ func (m *Manager) GetAlertHistory(limit int) []Alert { return m.historyManager.GetAllHistory(limit) } +// GetAlertHistorySince returns alert history entries created after the provided time. +func (m *Manager) GetAlertHistorySince(since time.Time, limit int) []Alert { + if since.IsZero() { + return m.GetAlertHistory(limit) + } + + return m.historyManager.GetHistory(since, limit) +} + // ClearAlertHistory clears all alert history func (m *Manager) ClearAlertHistory() error { return m.historyManager.ClearAllHistory() diff --git a/internal/api/alerts.go b/internal/api/alerts.go index ea1704c..e59ba7e 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -6,9 +6,11 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -112,35 +114,165 @@ func (h *AlertHandlers) GetActiveAlerts(w http.ResponseWriter, r *http.Request) // GetAlertHistory returns alert history func (h *AlertHandlers) GetAlertHistory(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + limit := 100 - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 10000 { - limit = l - } else if err != nil { + if limitStr := query.Get("limit"); limitStr != "" { + l, err := strconv.Atoi(limitStr) + switch { + case err != nil: log.Warn().Str("limit", limitStr).Msg("Invalid limit parameter, using default") - } else { + case l < 0: + http.Error(w, "limit must be non-negative", http.StatusBadRequest) + return + case l == 0: + limit = 0 + case l > 10000: log.Warn().Int("limit", l).Msg("Limit exceeds maximum, capping at 10000") limit = 10000 + default: + limit = l } } + offset := 0 + if offsetStr := query.Get("offset"); offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil { + if o < 0 { + http.Error(w, "offset must be non-negative", http.StatusBadRequest) + return + } + offset = o + } else { + log.Warn().Str("offset", offsetStr).Msg("Invalid offset parameter, ignoring") + } + } + + var startTime *time.Time + if startStr := query.Get("startTime"); startStr != "" { + parsed, err := time.Parse(time.RFC3339, startStr) + if err != nil { + http.Error(w, "invalid startTime parameter", http.StatusBadRequest) + return + } + startTime = &parsed + } + + var endTime *time.Time + if endStr := query.Get("endTime"); endStr != "" { + parsed, err := time.Parse(time.RFC3339, endStr) + if err != nil { + http.Error(w, "invalid endTime parameter", http.StatusBadRequest) + return + } + endTime = &parsed + } + + if startTime != nil && endTime != nil && endTime.Before(*startTime) { + http.Error(w, "endTime must be after startTime", http.StatusBadRequest) + return + } + + severity := strings.ToLower(strings.TrimSpace(query.Get("severity"))) + switch severity { + case "", "all": + severity = "" + case "warning", "critical": + default: + log.Warn().Str("severity", severity).Msg("Invalid severity filter, ignoring") + severity = "" + } + + resourceID := strings.TrimSpace(query.Get("resourceId")) + + matchesFilters := func(alertTime time.Time, alertLevel string, alertResourceID string) bool { + if startTime != nil && alertTime.Before(*startTime) { + return false + } + if endTime != nil && alertTime.After(*endTime) { + return false + } + if severity != "" && !strings.EqualFold(alertLevel, severity) { + return false + } + if resourceID != "" && alertResourceID != resourceID { + return false + } + return true + } + + trimAlerts := func(alerts []alerts.Alert) []alerts.Alert { + if offset > 0 { + if offset >= len(alerts) { + return alerts[:0] + } + alerts = alerts[offset:] + } + if limit > 0 && len(alerts) > limit { + alerts = alerts[:limit] + } + return alerts + } + + trimMockAlerts := func(alerts []models.Alert) []models.Alert { + if offset > 0 { + if offset >= len(alerts) { + return alerts[:0] + } + alerts = alerts[offset:] + } + if limit > 0 && len(alerts) > limit { + alerts = alerts[:limit] + } + return alerts + } + // Check if mock mode is enabled mockEnabled := mock.IsMockEnabled() - log.Info().Bool("mockEnabled", mockEnabled).Msg("GetAlertHistory: checking mock mode") + log.Debug().Bool("mockEnabled", mockEnabled).Msg("GetAlertHistory: mock mode status") + + fetchLimit := limit + if fetchLimit > 0 && offset > 0 { + fetchLimit += offset + } if mockEnabled { - history := mock.GetMockAlertHistory(limit) - log.Info().Int("mockHistoryCount", len(history)).Msg("Returning mock alert history") - if err := utils.WriteJSONResponse(w, history); err != nil { + mockHistory := mock.GetMockAlertHistory(fetchLimit) + filtered := make([]models.Alert, 0, len(mockHistory)) + for _, alert := range mockHistory { + if matchesFilters(alert.StartTime, alert.Level, alert.ResourceID) { + filtered = append(filtered, alert) + } + } + filtered = trimMockAlerts(filtered) + if err := utils.WriteJSONResponse(w, filtered); err != nil { log.Error().Err(err).Msg("Failed to write mock alert history response") } return } - // Get real alert history - alertHistory := h.monitor.GetAlertManager().GetAlertHistory(limit) + if h.monitor == nil { + http.Error(w, "monitor is not initialized", http.StatusServiceUnavailable) + return + } - if err := utils.WriteJSONResponse(w, alertHistory); err != nil { + manager := h.monitor.GetAlertManager() + var history []alerts.Alert + if startTime != nil { + history = manager.GetAlertHistorySince(*startTime, fetchLimit) + } else { + history = manager.GetAlertHistory(fetchLimit) + } + + filtered := make([]alerts.Alert, 0, len(history)) + for _, alert := range history { + if matchesFilters(alert.StartTime, string(alert.Level), alert.ResourceID) { + filtered = append(filtered, alert) + } + } + filtered = trimAlerts(filtered) + + if err := utils.WriteJSONResponse(w, filtered); err != nil { log.Error().Err(err).Msg("Failed to write alert history response") } }