Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions Lite/Controls/ServerTab.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public partial class ServerTab : UserControl
/// <summary>
/// Raised after each data refresh with alert counts for tab badge display.
/// </summary>
public event Action<int, int>? AlertCountsChanged; /* blockingCount, deadlockCount */
public event Action<int, int, DateTime?>? AlertCountsChanged; /* blockingCount, deadlockCount, latestEventTimeUtc */
public event Action<int>? ApplyTimeRangeRequested; /* selectedIndex */
public event Func<Task>? ManualRefreshRequested;

Expand Down Expand Up @@ -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)
{
Expand Down
27 changes: 7 additions & 20 deletions Lite/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ public partial class MainWindow : Window
private readonly Dictionary<string, bool> _activeBlockingAlert = new();
private readonly Dictionary<string, bool> _activeDeadlockAlert = new();

/* Track previous alert counts to detect new alerts (for clearing acknowledgements) */
private readonly Dictionary<string, (int Blocking, int Deadlock)> _previousAlertCounts = new();

public MainWindow()
{
InitializeComponent();
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
Expand Down
116 changes: 99 additions & 17 deletions Lite/Services/AlertStateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,36 @@

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

namespace PerformanceMonitorLite.Services
{
/// <summary>
/// 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.
/// </summary>
public class AlertStateService
{
private readonly object _lock = new object();
private readonly string _stateFilePath;

// Suppression state (session-only for Lite - not persisted)
private readonly HashSet<string> _silencedServers;

// Acknowledged alerts (session-only, clears on next refresh with new data)
private readonly HashSet<string> _acknowledgedAlerts;
/* Acknowledged alerts: serverId → UTC time of acknowledgement.
Alerts older than the ack time stay suppressed; new events clear it. */
private readonly Dictionary<string, DateTime> _acknowledgedAlerts;

// Event for when suppression state changes
public event EventHandler? SuppressionStateChanged;

public AlertStateService()
{
_stateFilePath = Path.Combine(AppContext.BaseDirectory, "alert_state.json");
_silencedServers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_acknowledgedAlerts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_acknowledgedAlerts = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
Load();
}

/// <summary>
Expand All @@ -42,49 +47,62 @@ 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;
}
}

/// <summary>
/// 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).
/// </summary>
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);
}

/// <summary>
/// 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).
/// </summary>
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);
}

/// <summary>
/// Silences a server entirely (no badges until unsilenced).
/// Silences a server entirely (no badges until unsilenced). Persisted across restarts.
/// </summary>
public void SilenceServer(string serverId)
{
lock (_lock)
{
_silencedServers.Add(serverId);
Save();
}
SuppressionStateChanged?.Invoke(this, EventArgs.Empty);
}
Expand All @@ -97,6 +115,7 @@ public void UnsilenceServer(string serverId)
lock (_lock)
{
_silencedServers.Remove(serverId);
Save();
}
SuppressionStateChanged?.Invoke(this, EventArgs.Empty);
}
Expand All @@ -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<AlertStatePersisted>(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<string>? SilencedServers { get; set; }
public Dictionary<string, DateTime>? AcknowledgedAlerts { get; set; }
}
}
}