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