diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 0bc4c36e..4e046652 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -83,7 +83,7 @@ public partial class ServerTab : UserControl /// /// Raised after each data refresh with alert counts for tab badge display. /// - public event Action? AlertCountsChanged; /* blockingCount, deadlockCount */ + public event Action? AlertCountsChanged; /* blockingCount, deadlockCount, latestEventTimeUtc */ public event Action? ApplyTimeRangeRequested; /* selectedIndex */ public event Func? ManualRefreshRequested; @@ -522,10 +522,19 @@ await System.Threading.Tasks.Task.WhenAll( ConnectionStatusText.Text = $"{_server.ServerName} - Last refresh: {DateTime.Now:HH:mm:ss}"; - /* Notify parent of alert counts for tab badge */ + /* Notify parent of alert counts for tab badge. + Include the latest event timestamp so acknowledgement is only + cleared when genuinely new events arrive, not when the time range changes. */ var blockingCount = blockedProcessTask.Result.Count; var deadlockCount = deadlockTask.Result.Count; - AlertCountsChanged?.Invoke(blockingCount, deadlockCount); + DateTime? latestEventTime = null; + if (blockingCount > 0 || deadlockCount > 0) + { + var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.CollectionTime); + var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.CollectionTime); + latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; + } + AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); } catch (Exception ex) { diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 0707ce4e..c0967a7a 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -50,9 +50,6 @@ public partial class MainWindow : Window private readonly Dictionary _activeBlockingAlert = new(); private readonly Dictionary _activeDeadlockAlert = new(); - /* Track previous alert counts to detect new alerts (for clearing acknowledgements) */ - private readonly Dictionary _previousAlertCounts = new(); - public MainWindow() { InitializeComponent(); @@ -432,9 +429,9 @@ private async void ConnectToServer(ServerConnection server) /* Subscribe to alert counts for badge updates */ var serverId = server.Id; - serverTab.AlertCountsChanged += (blockingCount, deadlockCount) => + serverTab.AlertCountsChanged += (blockingCount, deadlockCount, latestEventTime) => { - Dispatcher.Invoke(() => UpdateTabBadge(tabHeader, serverId, blockingCount, deadlockCount)); + Dispatcher.Invoke(() => UpdateTabBadge(tabHeader, serverId, blockingCount, deadlockCount, latestEventTime)); }; /* Subscribe to "Apply to All" time range propagation */ @@ -599,23 +596,14 @@ private StackPanel CreateTabHeader(ServerConnection server) return panel; } - private void UpdateTabBadge(StackPanel tabHeader, string serverId, int blockingCount, int deadlockCount) + private void UpdateTabBadge(StackPanel tabHeader, string serverId, int blockingCount, int deadlockCount, DateTime? latestEventTime) { var totalAlerts = blockingCount + deadlockCount; - /* Check if new alerts arrived - if so, clear any acknowledgement */ - if (_previousAlertCounts.TryGetValue(serverId, out var previous)) - { - if (blockingCount > previous.Blocking || deadlockCount > previous.Deadlock) - { - /* New alerts - clear acknowledgement so badge shows again */ - _alertStateService.ClearAcknowledgement(serverId); - } - } - _previousAlertCounts[serverId] = (blockingCount, deadlockCount); - - /* Check suppression state */ - bool shouldShow = totalAlerts > 0 && _alertStateService.ShouldShowAlerts(serverId); + /* Delegate count tracking and acknowledgement clearing to AlertStateService. + Uses latestEventTime to only clear ack when genuinely new events arrive, + not when the user just switches time ranges. */ + bool shouldShow = _alertStateService.UpdateAlertCounts(serverId, blockingCount, deadlockCount, latestEventTime); foreach (var child in tabHeader.Children) { @@ -709,7 +697,6 @@ private void CloseServerTab(string serverId) /* Clean up alert state for this server */ _alertStateService.RemoveServerState(serverId); - _previousAlertCounts.Remove(serverId); // Show empty state if no tabs open if (_openServerTabs.Count == 0) diff --git a/Lite/Services/AlertStateService.cs b/Lite/Services/AlertStateService.cs index b4bb2e59..02b8d5e3 100644 --- a/Lite/Services/AlertStateService.cs +++ b/Lite/Services/AlertStateService.cs @@ -8,31 +8,36 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json; namespace PerformanceMonitorLite.Services { /// /// Manages alert state including suppression and acknowledgement for server tab badges. - /// Thread-safe: All HashSet operations are protected by _lock. + /// State is persisted to a JSON file so it survives app restarts. + /// Thread-safe: All operations are protected by _lock. /// public class AlertStateService { private readonly object _lock = new object(); + private readonly string _stateFilePath; - // Suppression state (session-only for Lite - not persisted) private readonly HashSet _silencedServers; - // Acknowledged alerts (session-only, clears on next refresh with new data) - private readonly HashSet _acknowledgedAlerts; + /* Acknowledged alerts: serverId → UTC time of acknowledgement. + Alerts older than the ack time stay suppressed; new events clear it. */ + private readonly Dictionary _acknowledgedAlerts; - // Event for when suppression state changes public event EventHandler? SuppressionStateChanged; public AlertStateService() { + _stateFilePath = Path.Combine(AppContext.BaseDirectory, "alert_state.json"); _silencedServers = new HashSet(StringComparer.OrdinalIgnoreCase); - _acknowledgedAlerts = new HashSet(StringComparer.OrdinalIgnoreCase); + _acknowledgedAlerts = new Dictionary(StringComparer.OrdinalIgnoreCase); + Load(); } /// @@ -42,12 +47,10 @@ public bool ShouldShowAlerts(string serverId) { lock (_lock) { - // Check if server is silenced if (_silencedServers.Contains(serverId)) return false; - // Check if acknowledged this refresh cycle - if (_acknowledgedAlerts.Contains(serverId)) + if (_acknowledgedAlerts.ContainsKey(serverId)) return false; return true; @@ -55,36 +58,51 @@ public bool ShouldShowAlerts(string serverId) } /// - /// Acknowledges alerts for a specific server (hides badge until next refresh with new data). + /// Updates alert counts and returns whether the badge should be shown. + /// Clears acknowledgement only if latestEventTime is newer than the ack timestamp, + /// meaning genuinely new events arrived (not just a time-range change). /// - public void AcknowledgeAlert(string serverId) + public bool UpdateAlertCounts(string serverId, int blockingCount, int deadlockCount, DateTime? latestEventTimeUtc) { lock (_lock) { - _acknowledgedAlerts.Add(serverId); + if (latestEventTimeUtc.HasValue + && _acknowledgedAlerts.TryGetValue(serverId, out var ackTime) + && latestEventTimeUtc.Value > ackTime) + { + /* Event newer than acknowledgement — clear it */ + _acknowledgedAlerts.Remove(serverId); + Save(); + } + + int totalAlerts = blockingCount + deadlockCount; + return totalAlerts > 0 && ShouldShowAlertsInternal(serverId); } - SuppressionStateChanged?.Invoke(this, EventArgs.Empty); } /// - /// Clears acknowledgement for a server (called when new alert data arrives). + /// Acknowledges alerts for a specific server. Persisted across restarts. + /// Alerts stay suppressed until new events arrive (counts increase). /// - public void ClearAcknowledgement(string serverId) + public void AcknowledgeAlert(string serverId) { lock (_lock) { - _acknowledgedAlerts.Remove(serverId); + _acknowledgedAlerts[serverId] = DateTime.UtcNow; + Save(); } + SuppressionStateChanged?.Invoke(this, EventArgs.Empty); } /// - /// Silences a server entirely (no badges until unsilenced). + /// Silences a server entirely (no badges until unsilenced). Persisted across restarts. /// public void SilenceServer(string serverId) { lock (_lock) { _silencedServers.Add(serverId); + Save(); } SuppressionStateChanged?.Invoke(this, EventArgs.Empty); } @@ -97,6 +115,7 @@ public void UnsilenceServer(string serverId) lock (_lock) { _silencedServers.Remove(serverId); + Save(); } SuppressionStateChanged?.Invoke(this, EventArgs.Empty); } @@ -121,7 +140,70 @@ public void RemoveServerState(string serverId) { _silencedServers.Remove(serverId); _acknowledgedAlerts.Remove(serverId); + Save(); } } + + /* Internal check without re-acquiring lock */ + private bool ShouldShowAlertsInternal(string serverId) + { + if (_silencedServers.Contains(serverId)) + return false; + if (_acknowledgedAlerts.ContainsKey(serverId)) + return false; + return true; + } + + private void Save() + { + try + { + var state = new AlertStatePersisted + { + SilencedServers = _silencedServers.ToList(), + AcknowledgedAlerts = _acknowledgedAlerts.ToDictionary(kv => kv.Key, kv => kv.Value) + }; + var json = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_stateFilePath, json); + } + catch + { + /* Best effort — don't crash if file write fails */ + } + } + + private void Load() + { + try + { + if (!File.Exists(_stateFilePath)) return; + + var json = File.ReadAllText(_stateFilePath); + var state = JsonSerializer.Deserialize(json); + if (state == null) return; + + if (state.SilencedServers != null) + { + foreach (var s in state.SilencedServers) + _silencedServers.Add(s); + } + + if (state.AcknowledgedAlerts != null) + { + foreach (var kv in state.AcknowledgedAlerts) + _acknowledgedAlerts[kv.Key] = kv.Value; + } + } + catch + { + /* Best effort — start fresh if file is corrupted */ + } + } + + private class AlertStatePersisted + { + public List? SilencedServers { get; set; } + public Dictionary? AcknowledgedAlerts { get; set; } + } } }