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; }
+ }
}
}