diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml b/Dashboard/Controls/AlertsHistoryContent.xaml
index f7dfc2b..e4a332c 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml
@@ -92,6 +92,8 @@
Margin="4,0,0,0" Padding="10,4" MinWidth="70"/>
+
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
index a85d2f6..761b6b4 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
@@ -14,6 +14,7 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Threading;
using Microsoft.Win32;
using PerformanceMonitorDashboard.Helpers;
using PerformanceMonitorDashboard.Models;
@@ -28,6 +29,8 @@ public partial class AlertsHistoryContent : UserControl
public MuteRuleService? MuteRuleService { get; set; }
private List _allAlerts = new();
+ private DateTime? _lastRefreshed;
+ private readonly DispatcherTimer _staleDataTimer;
/* Column filter state */
private readonly Dictionary _columnFilters = new();
@@ -37,6 +40,9 @@ public partial class AlertsHistoryContent : UserControl
public AlertsHistoryContent()
{
InitializeComponent();
+ _staleDataTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) };
+ _staleDataTimer.Tick += StaleDataTimer_Tick;
+ _staleDataTimer.Start();
}
///
@@ -78,6 +84,7 @@ private void LoadAlerts()
DetailText = e.DetailText
}).ToList();
+ _lastRefreshed = DateTime.UtcNow;
ApplyFilters();
}
@@ -112,6 +119,7 @@ private void ApplyFilters()
AlertsDataGrid.ItemsSource = list;
NoAlertsMessage.Visibility = list.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
AlertCountIndicator.Text = list.Count > 0 ? $"{list.Count} alert(s)" : "";
+ UpdateStaleDataIndicator();
/* Populate server filter if needed */
PopulateServerFilter();
@@ -184,6 +192,28 @@ private static string GetStatusDisplay(AlertLogEntry entry)
return entry.AlertSent ? "Delivered" : "Shown";
}
+ #region Stale Data Indicator
+
+ private void StaleDataTimer_Tick(object? sender, EventArgs e)
+ {
+ UpdateStaleDataIndicator();
+ }
+
+ private void UpdateStaleDataIndicator()
+ {
+ if (_lastRefreshed.HasValue)
+ {
+ var elapsed = DateTime.UtcNow - _lastRefreshed.Value;
+ LastRefreshedIndicator.Text = elapsed.TotalSeconds < 5
+ ? "Refreshed just now"
+ : elapsed.TotalMinutes < 1
+ ? $"Refreshed {(int)elapsed.TotalSeconds}s ago"
+ : $"Refreshed {(int)elapsed.TotalMinutes}m ago";
+ }
+ }
+
+ #endregion
+
#region Event Handlers
private void TimeRangeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
@@ -223,7 +253,9 @@ private void DismissSelected_Click(object sender, RoutedEventArgs e)
.Select(s => (s.AlertTime, s.ServerName, s.MetricName))
.ToList();
+ Logger.Info($"[AlertDismiss] Action=DismissSelected, Requested={selected.Count}");
service.HideAlerts(keys);
+ Logger.Info($"[AlertDismiss] Action=DismissSelected, Result=Complete, Hidden={selected.Count}");
LoadAlerts();
AlertsDismissed?.Invoke(this, EventArgs.Empty);
}
@@ -252,7 +284,9 @@ private void DismissAll_Click(object sender, RoutedEventArgs e)
serverName = serverItem.Content?.ToString();
}
+ Logger.Info($"[AlertDismiss] Action=DismissAll, HoursBack={hoursBack}, Server={serverName ?? "all"}");
service.HideAllAlerts(hoursBack > 0 ? hoursBack : 8760, serverName);
+ Logger.Info($"[AlertDismiss] Action=DismissAll, Result=Complete");
LoadAlerts();
AlertsDismissed?.Invoke(this, EventArgs.Empty);
}
diff --git a/Lite/Controls/AlertsHistoryTab.xaml b/Lite/Controls/AlertsHistoryTab.xaml
index c27490f..c35baf8 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml
+++ b/Lite/Controls/AlertsHistoryTab.xaml
@@ -94,6 +94,10 @@
Margin="4,0,0,0" Padding="10,4" MinWidth="70"/>
+
+
diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs
index 2c55df3..e0cf06e 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml.cs
+++ b/Lite/Controls/AlertsHistoryTab.xaml.cs
@@ -15,6 +15,7 @@
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
+using System.Windows.Threading;
using Microsoft.Win32;
using PerformanceMonitorLite.Controls;
using PerformanceMonitorLite.Models;
@@ -28,12 +29,16 @@ public partial class AlertsHistoryTab : UserControl
private DataGridFilterManager? _filterManager;
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
+ private DateTime? _lastRefreshed;
+ private readonly DispatcherTimer _staleDataTimer;
public MuteRuleService? MuteRuleService { get; set; }
public AlertsHistoryTab()
{
InitializeComponent();
+ _staleDataTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) };
+ _staleDataTimer.Tick += StaleDataTimer_Tick;
}
///
@@ -43,6 +48,7 @@ public void Initialize(LocalDataService dataService)
{
_dataService = dataService;
_filterManager = new DataGridFilterManager(AlertsDataGrid);
+ _staleDataTimer.Start();
}
///
@@ -74,6 +80,9 @@ private async System.Threading.Tasks.Task LoadAlertsAsync()
AlertCountIndicator.Text = displayCount > 0 ? $"{displayCount} alert(s)" : "";
AppLogger.Debug("AlertsHistory", $"Loaded {displayCount} alert(s) (query returned {alerts.Count}, hoursBack={hoursBack}, serverId={serverId?.ToString() ?? "all"})");
+ _lastRefreshed = DateTime.UtcNow;
+ UpdateStaleDataIndicator();
+
PopulateServerFilter(alerts);
}
catch (Exception ex)
@@ -197,6 +206,39 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
#endregion
+ #region Stale Data Indicator
+
+ private void StaleDataTimer_Tick(object? sender, EventArgs e)
+ {
+ UpdateStaleDataIndicator();
+ }
+
+ private void UpdateStaleDataIndicator()
+ {
+ if (_lastRefreshed.HasValue)
+ {
+ var elapsed = DateTime.UtcNow - _lastRefreshed.Value;
+ LastRefreshedIndicator.Text = elapsed.TotalSeconds < 5
+ ? "Refreshed just now"
+ : elapsed.TotalMinutes < 1
+ ? $"Refreshed {(int)elapsed.TotalSeconds}s ago"
+ : $"Refreshed {(int)elapsed.TotalMinutes}m ago";
+ }
+
+ // Show archival warning if ArchiveService is currently running
+ if (ArchiveService.IsArchiving)
+ {
+ ArchivalWarning.Text = "⚠ Archival in progress";
+ ArchivalWarning.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ ArchivalWarning.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ #endregion
+
#region Event Handlers
private async void TimeRangeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs
index 7692513..31d2d5d 100644
--- a/Lite/Services/ArchiveService.cs
+++ b/Lite/Services/ArchiveService.cs
@@ -30,6 +30,12 @@ public class ArchiveService
private readonly ILogger? _logger;
private static readonly SemaphoreSlim s_archiveLock = new(1, 1);
+ ///
+ /// Indicates whether an archival operation is currently in progress.
+ /// UI code can check this to warn users before dismiss or show a status indicator.
+ ///
+ public static bool IsArchiving { get; private set; }
+
/* Tables eligible for archival with their time column.
IMPORTANT: Every table with time-series data must be listed here,
or it will grow unbounded and push the DB past the 512 MB reset threshold. */
@@ -88,6 +94,7 @@ public async Task ArchiveOldDataAsync(int hotDataDays = 7, int? hotDataHours = n
return;
}
+ IsArchiving = true;
try
{
var cutoffDate = hotDataHours.HasValue
@@ -146,6 +153,7 @@ Archive views use glob (*_table.parquet) to pick up all files. */
}
finally
{
+ IsArchiving = false;
s_archiveLock.Release();
}
}
@@ -416,6 +424,7 @@ public async Task ArchiveAllAndResetAsync()
return;
}
+ IsArchiving = true;
try
{
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmm");
@@ -475,6 +484,7 @@ and only touches filesystem files — no contention with collectors. */
}
finally
{
+ IsArchiving = false;
s_archiveLock.Release();
}
}
diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs
index 75d7deb..764e77f 100644
--- a/Lite/Services/LocalDataService.AlertHistory.cs
+++ b/Lite/Services/LocalDataService.AlertHistory.cs
@@ -10,6 +10,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DuckDB.NET.Data;
+using PerformanceMonitorLite.Database;
namespace PerformanceMonitorLite.Services;
@@ -104,13 +105,16 @@ ORDER BY alert_time DESC
/// Dismisses specific alerts by marking them as dismissed in DuckDB.
/// Identifies rows by (alert_time, server_id, metric_name) composite key.
/// If an alert only exists in archived parquet, inserts into dismissed_archive_alerts instead.
+ /// Logs structured telemetry and verifies dismissal success.
///
public async Task DismissAlertsAsync(List alerts)
{
if (alerts.Count == 0) return 0;
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
if (App.LogAlertDismissals)
- AppLogger.Info("AlertDismiss", $"Dismissing {alerts.Count} selected alert(s)");
+ AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Requested={alerts.Count}");
using var connection = await OpenConnectionAsync();
int totalAffected = 0;
@@ -151,23 +155,35 @@ SELECT 1 FROM dismissed_archive_alerts
archivedDismissed += sidecarAffected;
if (App.LogAlertDismissals)
- AppLogger.Info("AlertDismiss", $"Archived alert dismissed via sidecar: time={alert.AlertTime:O}, server_id={alert.ServerId}, metric={alert.MetricName}");
+ AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Result=SidecarInsert, AlertTime={alert.AlertTime:O}, ServerId={alert.ServerId}, Metric={alert.MetricName}");
}
}
+ sw.Stop();
+
if (App.LogAlertDismissals)
- AppLogger.Info("AlertDismiss", $"Dismiss complete: {totalAffected} live + {archivedDismissed} archived out of {alerts.Count} selected");
+ AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Result=Complete, Requested={alerts.Count}, LiveUpdated={totalAffected}, ArchivedDismissed={archivedDismissed}, Duration={sw.ElapsedMilliseconds}ms");
+
+ // Post-dismiss verification: confirm the dismissed rows are no longer visible
+ if (totalAffected > 0)
+ {
+ await VerifyDismissAsync(connection, alerts, totalAffected);
+ }
+
return totalAffected + archivedDismissed;
}
///
/// Dismisses all visible (non-dismissed) alerts matching the current filter criteria.
/// Updates the live table, then inserts any remaining archived alerts into the sidecar table.
+ /// Logs structured telemetry and verifies dismissal success.
///
public async Task DismissAllVisibleAlertsAsync(int hoursBack, int? serverId = null)
{
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
if (App.LogAlertDismissals)
- AppLogger.Info("AlertDismiss", $"Dismissing all visible alerts: hoursBack={hoursBack}, serverId={serverId?.ToString() ?? "all"}");
+ AppLogger.Info("AlertDismiss", $"Action=DismissAll, HoursBack={hoursBack}, ServerId={serverId?.ToString() ?? "all"}");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -247,11 +263,94 @@ SELECT 1 FROM dismissed_archive_alerts d
}
var archivedAffected = await sidecarCmd.ExecuteNonQueryAsync();
+ sw.Stop();
if (App.LogAlertDismissals)
- AppLogger.Info("AlertDismiss", $"Dismiss all complete: {liveAffected} live + {archivedAffected} archived (cutoff={cutoff:O})");
+ AppLogger.Info("AlertDismiss", $"Action=DismissAll, Result=Complete, LiveUpdated={liveAffected}, ArchivedDismissed={archivedAffected}, Cutoff={cutoff:O}, Duration={sw.ElapsedMilliseconds}ms");
+
+ // Post-dismiss verification: confirm no undismissed live rows remain
+ if (liveAffected > 0)
+ {
+ await VerifyDismissAllAsync(connection, cutoff, serverId, liveAffected);
+ }
+
return liveAffected + archivedAffected;
}
+
+ ///
+ /// Verifies that specific dismissed alerts are no longer in undismissed state.
+ ///
+ private static async System.Threading.Tasks.Task VerifyDismissAsync(LockedConnection connection, List alerts, int expectedDismissed)
+ {
+ try
+ {
+ // Check how many of the targeted alerts are still undismissed
+ int stillUndismissed = 0;
+ foreach (var alert in alerts)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT COUNT(1) FROM config_alert_log
+WHERE alert_time = $1
+AND server_id = $2
+AND metric_name = $3
+AND dismissed = FALSE";
+ cmd.Parameters.Add(new DuckDBParameter { Value = alert.AlertTime });
+ cmd.Parameters.Add(new DuckDBParameter { Value = alert.ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = alert.MetricName });
+ var count = Convert.ToInt64(await cmd.ExecuteScalarAsync());
+ if (count > 0) stillUndismissed++;
+ }
+
+ if (stillUndismissed > 0)
+ AppLogger.Warn("AlertDismiss", $"Action=DismissVerify, Result=Mismatch, StillUndismissed={stillUndismissed}, ExpectedDismissed={expectedDismissed}");
+ else if (App.LogAlertDismissals)
+ AppLogger.Info("AlertDismiss", $"Action=DismissVerify, Result=Verified, Confirmed={expectedDismissed}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("AlertDismiss", $"Action=DismissVerify, Result=Error, Message={ex.Message}");
+ }
+ }
+
+ ///
+ /// Verifies that no undismissed alerts remain in the dismissed time range.
+ ///
+ private static async System.Threading.Tasks.Task VerifyDismissAllAsync(LockedConnection connection, DateTime cutoff, int? serverId, int expectedDismissed)
+ {
+ try
+ {
+ using var cmd = connection.CreateCommand();
+ if (serverId.HasValue)
+ {
+ cmd.CommandText = @"
+SELECT COUNT(1) FROM config_alert_log
+WHERE alert_time >= $1
+AND server_id = $2
+AND dismissed = FALSE";
+ cmd.Parameters.Add(new DuckDBParameter { Value = cutoff });
+ cmd.Parameters.Add(new DuckDBParameter { Value = serverId.Value });
+ }
+ else
+ {
+ cmd.CommandText = @"
+SELECT COUNT(1) FROM config_alert_log
+WHERE alert_time >= $1
+AND dismissed = FALSE";
+ cmd.Parameters.Add(new DuckDBParameter { Value = cutoff });
+ }
+
+ var remaining = Convert.ToInt64(await cmd.ExecuteScalarAsync());
+ if (remaining > 0)
+ AppLogger.Warn("AlertDismiss", $"Action=DismissAllVerify, Result=Mismatch, StillUndismissed={remaining}, ExpectedDismissed={expectedDismissed}");
+ else if (App.LogAlertDismissals)
+ AppLogger.Info("AlertDismiss", $"Action=DismissAllVerify, Result=Verified, Confirmed={expectedDismissed}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("AlertDismiss", $"Action=DismissAllVerify, Result=Error, Message={ex.Message}");
+ }
+ }
}
public class AlertHistoryRow