diff --git a/Lite.Tests/AlertHistorySourceTests.cs b/Lite.Tests/AlertHistorySourceTests.cs new file mode 100644 index 0000000..4e57790 --- /dev/null +++ b/Lite.Tests/AlertHistorySourceTests.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using PerformanceMonitorLite.Tests.Helpers; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Tests that the archive view source column correctly distinguishes live vs archived alerts, +/// and that dismiss operations only target live alerts. +/// +public class AlertHistorySourceTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + private readonly TestAlertDataHelper _helper; + + public AlertHistorySourceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "test.duckdb"); + _helper = new TestAlertDataHelper(_dbPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + /* Best-effort cleanup */ + } + } + + private async Task InitializeDatabaseAsync() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + var connection = new DuckDBConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + return connection; + } + + [Fact] + public async Task LiveAlerts_HaveSourceLive() + { + using var connection = await InitializeDatabaseAsync(); + + var recentTime = DateTime.UtcNow.AddHours(-1); + await _helper.InsertLiveAlertAsync(connection, recentTime, 1, "Server1", "High CPU"); + + await _helper.RefreshArchiveViewsAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT source FROM v_config_alert_log WHERE metric_name = 'High CPU'"; + var source = (string)(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!; + + Assert.Equal("live", source); + } + + [Fact] + public async Task ArchivedAlerts_HaveSourceArchive() + { + using var connection = await InitializeDatabaseAsync(); + + var oldAlerts = new List + { + TestAlertDataHelper.CreateAlert( + alertTime: DateTime.UtcNow.AddDays(-14), + metricName: "Blocking", + serverName: "Server1") + }; + + await _helper.CreateArchivedAlertsParquetAsync(connection, oldAlerts); + await _helper.RefreshArchiveViewsAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT source FROM v_config_alert_log WHERE metric_name = 'Blocking'"; + var source = (string)(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!; + + Assert.Equal("archive", source); + } + + [Fact] + public async Task MixedAlerts_CorrectSourcePerRow() + { + using var connection = await InitializeDatabaseAsync(); + + // Insert a live alert (recent) + await _helper.InsertLiveAlertAsync( + connection, + DateTime.UtcNow.AddHours(-2), + 1, "Server1", "High CPU"); + + // Create an archived alert (old) + var archivedAlerts = new List + { + TestAlertDataHelper.CreateAlert( + alertTime: DateTime.UtcNow.AddDays(-14), + metricName: "Deadlock Detected", + serverName: "Server1") + }; + await _helper.CreateArchivedAlertsParquetAsync(connection, archivedAlerts); + await _helper.RefreshArchiveViewsAsync(); + + // Query both and verify sources + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT metric_name, source +FROM v_config_alert_log +ORDER BY alert_time DESC"; + + var results = new List<(string MetricName, string Source)>(); + using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken); + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + { + results.Add((reader.GetString(0), reader.GetString(1))); + } + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.MetricName == "High CPU" && r.Source == "live"); + Assert.Contains(results, r => r.MetricName == "Deadlock Detected" && r.Source == "archive"); + } + + [Fact] + public async Task DismissUpdate_OnlyAffectsLiveTable() + { + using var connection = await InitializeDatabaseAsync(); + + // Insert a live alert + var liveTime = DateTime.UtcNow.AddHours(-1); + await _helper.InsertLiveAlertAsync( + connection, liveTime, 1, "Server1", "High CPU"); + + // Create an archived alert + var archiveTime = DateTime.UtcNow.AddDays(-14); + var archivedAlerts = new List + { + TestAlertDataHelper.CreateAlert( + alertTime: archiveTime, + metricName: "Blocking", + serverName: "Server1") + }; + await _helper.CreateArchivedAlertsParquetAsync(connection, archivedAlerts); + await _helper.RefreshArchiveViewsAsync(); + + // Dismiss the live alert + using var dismissCmd = connection.CreateCommand(); + dismissCmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE metric_name = 'High CPU' +AND dismissed = FALSE"; + var affected = await dismissCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(1, affected); + + // Attempt to dismiss the archived alert — should affect 0 rows + using var dismissArchiveCmd = connection.CreateCommand(); + dismissArchiveCmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE metric_name = 'Blocking' +AND dismissed = FALSE"; + var archivedAffected = await dismissArchiveCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(0, archivedAffected); + + // The archived alert should still be visible in the view (undismissed) + using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = @" +SELECT COUNT(1) +FROM v_config_alert_log +WHERE metric_name = 'Blocking' +AND dismissed = FALSE"; + var stillVisible = Convert.ToInt64(await checkCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, stillVisible); + } + + [Fact] + public async Task ViewWithNoParquet_AllRowsAreLive() + { + using var connection = await InitializeDatabaseAsync(); + + // No parquet files — just live data + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-1), 1, "Server1", "High CPU"); + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-2), 2, "Server2", "TempDB Space"); + + await _helper.RefreshArchiveViewsAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(1) FROM v_config_alert_log WHERE source = 'live'"; + var liveCount = Convert.ToInt64(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(2, liveCount); + + using var archiveCmd = connection.CreateCommand(); + archiveCmd.CommandText = "SELECT COUNT(1) FROM v_config_alert_log WHERE source = 'archive'"; + var archiveCount = Convert.ToInt64(await archiveCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, archiveCount); + } + + [Fact] + public async Task AlertHistoryRow_IsArchived_ReflectsSource() + { + var liveRow = new AlertHistoryRow { Source = "live" }; + var archiveRow = new AlertHistoryRow { Source = "archive" }; + var defaultRow = new AlertHistoryRow(); + + Assert.False(liveRow.IsArchived); + Assert.True(archiveRow.IsArchived); + Assert.False(defaultRow.IsArchived); + + await Task.CompletedTask; + } + + [Fact] + public async Task MultipleParquetFiles_AllMarkedAsArchive() + { + using var connection = await InitializeDatabaseAsync(); + + // Create two separate parquet files (simulating multiple archive cycles) + var batch1 = new List + { + TestAlertDataHelper.CreateAlert( + alertTime: DateTime.UtcNow.AddDays(-14), + metricName: "High CPU", + serverName: "Server1"), + TestAlertDataHelper.CreateAlert( + alertTime: DateTime.UtcNow.AddDays(-13), + metricName: "Blocking", + serverName: "Server1") + }; + + var batch2 = new List + { + TestAlertDataHelper.CreateAlert( + alertTime: DateTime.UtcNow.AddDays(-21), + metricName: "Deadlock Detected", + serverName: "Server2") + }; + + await _helper.CreateArchivedAlertsParquetAsync( + connection, batch1, "20260301_0000_config_alert_log.parquet"); + await _helper.CreateArchivedAlertsParquetAsync( + connection, batch2, "20260215_0000_config_alert_log.parquet"); + + await _helper.RefreshArchiveViewsAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT COUNT(1) FROM v_config_alert_log WHERE source = 'archive'"; + var archiveCount = Convert.ToInt64(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(3, archiveCount); + } +} diff --git a/Lite.Tests/Helpers/TestAlertDataHelper.cs b/Lite.Tests/Helpers/TestAlertDataHelper.cs new file mode 100644 index 0000000..0a44ff5 --- /dev/null +++ b/Lite.Tests/Helpers/TestAlertDataHelper.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; + +namespace PerformanceMonitorLite.Tests.Helpers; + +/// +/// Helper for inserting test alert data into DuckDB live tables and archived parquet files. +/// +public class TestAlertDataHelper +{ + private readonly string _dbPath; + private readonly string _archivePath; + + public TestAlertDataHelper(string dbPath) + { + _dbPath = dbPath; + _archivePath = Path.Combine(Path.GetDirectoryName(dbPath) ?? ".", "archive"); + Directory.CreateDirectory(_archivePath); + } + + public string ArchivePath => _archivePath; + + /// + /// Inserts a single alert row into the live config_alert_log table. + /// + public async Task InsertLiveAlertAsync( + DuckDBConnection connection, + DateTime alertTime, + int serverId, + string serverName, + string metricName, + double currentValue = 95.0, + double thresholdValue = 80.0, + bool alertSent = true, + string notificationType = "tray", + string? sendError = null, + bool dismissed = false, + bool muted = false, + string? detailText = null) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO config_alert_log + (alert_time, server_id, server_name, metric_name, current_value, + threshold_value, alert_sent, notification_type, send_error, + dismissed, muted, detail_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; + cmd.Parameters.Add(new DuckDBParameter { Value = alertTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = serverName }); + cmd.Parameters.Add(new DuckDBParameter { Value = metricName }); + cmd.Parameters.Add(new DuckDBParameter { Value = currentValue }); + cmd.Parameters.Add(new DuckDBParameter { Value = thresholdValue }); + cmd.Parameters.Add(new DuckDBParameter { Value = alertSent }); + cmd.Parameters.Add(new DuckDBParameter { Value = notificationType }); + cmd.Parameters.Add(new DuckDBParameter { Value = sendError ?? (object)DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = dismissed }); + cmd.Parameters.Add(new DuckDBParameter { Value = muted }); + cmd.Parameters.Add(new DuckDBParameter { Value = detailText ?? (object)DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + /// + /// Creates a parquet file in the archive directory containing the specified alert rows. + /// Uses a staging table to avoid touching the live config_alert_log table. + /// The parquet file is named to match the ArchiveService naming convention. + /// + public async Task CreateArchivedAlertsParquetAsync( + DuckDBConnection connection, + List alerts, + string? parquetFileName = null) + { + parquetFileName ??= $"20260101_0000_config_alert_log.parquet"; + var parquetPath = Path.Combine(_archivePath, parquetFileName).Replace("\\", "/"); + + using var createCmd = connection.CreateCommand(); + createCmd.CommandText = @" +CREATE TEMP TABLE _staging_alerts ( + alert_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + metric_name VARCHAR NOT NULL, + current_value DOUBLE NOT NULL, + threshold_value DOUBLE NOT NULL, + alert_sent BOOLEAN NOT NULL DEFAULT false, + notification_type VARCHAR NOT NULL DEFAULT 'tray', + send_error VARCHAR, + dismissed BOOLEAN NOT NULL DEFAULT false, + muted BOOLEAN NOT NULL DEFAULT false, + detail_text VARCHAR +)"; + await createCmd.ExecuteNonQueryAsync(); + + foreach (var alert in alerts) + { + using var insertCmd = connection.CreateCommand(); + insertCmd.CommandText = @" +INSERT INTO _staging_alerts + (alert_time, server_id, server_name, metric_name, current_value, + threshold_value, alert_sent, notification_type, send_error, + dismissed, muted, detail_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.AlertTime }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.ServerId }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.ServerName }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.MetricName }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.CurrentValue }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.ThresholdValue }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.AlertSent }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.NotificationType }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.SendError ?? (object)DBNull.Value }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.Dismissed }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.Muted }); + insertCmd.Parameters.Add(new DuckDBParameter { Value = alert.DetailText ?? (object)DBNull.Value }); + await insertCmd.ExecuteNonQueryAsync(); + } + + using var copyCmd = connection.CreateCommand(); + copyCmd.CommandText = $"COPY _staging_alerts TO '{parquetPath}' (FORMAT PARQUET)"; + await copyCmd.ExecuteNonQueryAsync(); + + using var dropCmd = connection.CreateCommand(); + dropCmd.CommandText = "DROP TABLE _staging_alerts"; + await dropCmd.ExecuteNonQueryAsync(); + } + + /// + /// Recreates archive views so they pick up newly created parquet files. + /// + public async Task RefreshArchiveViewsAsync() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.CreateArchiveViewsAsync(); + } + + /// + /// Creates a test alert record with sensible defaults. Adjust properties as needed. + /// + public static TestAlertRecord CreateAlert( + DateTime? alertTime = null, + int serverId = 1, + string serverName = "TestServer", + string metricName = "High CPU", + double currentValue = 95.0, + double thresholdValue = 80.0, + bool dismissed = false, + bool muted = false, + string? detailText = null) + { + return new TestAlertRecord + { + AlertTime = alertTime ?? DateTime.UtcNow.AddDays(-10), + ServerId = serverId, + ServerName = serverName, + MetricName = metricName, + CurrentValue = currentValue, + ThresholdValue = thresholdValue, + AlertSent = true, + NotificationType = "tray", + SendError = null, + Dismissed = dismissed, + Muted = muted, + DetailText = detailText + }; + } +} + +/// +/// Plain record for constructing test alert data. +/// +public class TestAlertRecord +{ + public DateTime AlertTime { get; set; } + public int ServerId { get; set; } + public string ServerName { get; set; } = "TestServer"; + public string MetricName { get; set; } = "High CPU"; + public double CurrentValue { get; set; } = 95.0; + public double ThresholdValue { get; set; } = 80.0; + public bool AlertSent { get; set; } = true; + public string NotificationType { get; set; } = "tray"; + public string? SendError { get; set; } + public bool Dismissed { get; set; } + public bool Muted { get; set; } + public string? DetailText { get; set; } +} diff --git a/Lite/Controls/AlertsHistoryTab.xaml b/Lite/Controls/AlertsHistoryTab.xaml index 1bdfda7..c27490f 100644 --- a/Lite/Controls/AlertsHistoryTab.xaml +++ b/Lite/Controls/AlertsHistoryTab.xaml @@ -53,6 +53,10 @@ + + + + diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs index f8721c6..2c55df3 100644 --- a/Lite/Controls/AlertsHistoryTab.xaml.cs +++ b/Lite/Controls/AlertsHistoryTab.xaml.cs @@ -218,7 +218,9 @@ private async void RefreshButton_Click(object sender, RoutedEventArgs e) private void AlertsDataGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) { - DismissSelectedButton.IsEnabled = AlertsDataGrid.SelectedItems.Count > 0; + var selected = AlertsDataGrid.SelectedItems.OfType().ToList(); + var liveCount = selected.Count(a => !a.IsArchived); + DismissSelectedButton.IsEnabled = liveCount > 0; } private async void DismissSelected_Click(object sender, RoutedEventArgs e) @@ -231,12 +233,36 @@ private async void DismissSelected_Click(object sender, RoutedEventArgs e) if (selected.Count == 0) return; + var liveAlerts = selected.Where(a => !a.IsArchived).ToList(); + var archivedCount = selected.Count - liveAlerts.Count; + + if (liveAlerts.Count == 0) + { + MessageBox.Show( + "The selected alert(s) have been archived and cannot be dismissed.", + "Cannot Dismiss", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (archivedCount > 0) + { + var confirmResult = MessageBox.Show( + $"{archivedCount} of {selected.Count} selected alert(s) have been archived and cannot be dismissed.\n\n" + + $"Dismiss the remaining {liveAlerts.Count} alert(s)?", + "Partial Dismiss", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + if (confirmResult != MessageBoxResult.Yes) return; + } + try { - var affected = await _dataService.DismissAlertsAsync(selected); - if (affected < selected.Count && App.LogAlertDismissals) + var affected = await _dataService.DismissAlertsAsync(liveAlerts); + if (affected < liveAlerts.Count && App.LogAlertDismissals) { - AppLogger.Warn("AlertsHistory", $"Dismiss selected: only {affected} of {selected.Count} alert(s) were updated — remaining alerts may have been archived to parquet"); + AppLogger.Warn("AlertsHistory", $"Dismiss selected: only {affected} of {liveAlerts.Count} live alert(s) were updated"); } await LoadAlertsAsync(); } @@ -250,11 +276,29 @@ private async void DismissAll_Click(object sender, RoutedEventArgs e) { if (_dataService == null) return; - var displayCount = AlertsDataGrid.ItemsSource is System.Collections.ICollection coll ? coll.Count : 0; - if (displayCount == 0) return; + var allAlerts = (AlertsDataGrid.ItemsSource as IEnumerable)?.ToList(); + if (allAlerts == null || allAlerts.Count == 0) return; + + var liveCount = allAlerts.Count(a => !a.IsArchived); + var archivedCount = allAlerts.Count - liveCount; + + if (liveCount == 0) + { + MessageBox.Show( + "All visible alerts have been archived and cannot be dismissed.", + "Cannot Dismiss", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var message = $"Dismiss {liveCount} alert(s)?"; + if (archivedCount > 0) + message += $"\n\n{archivedCount} archived alert(s) will remain visible."; + message += "\n\nDismissed alerts are hidden from this view but remain in the database."; var result = MessageBox.Show( - $"Dismiss all {displayCount} visible alert(s)?\n\nDismissed alerts are hidden from this view but remain in the database.", + message, "Dismiss All Alerts", MessageBoxButton.YesNo, MessageBoxImage.Question); @@ -266,9 +310,9 @@ private async void DismissAll_Click(object sender, RoutedEventArgs e) var hoursBack = GetSelectedHoursBack(); int? serverId = GetSelectedServerId(); var affected = await _dataService.DismissAllVisibleAlertsAsync(hoursBack, serverId); - if (affected < displayCount && App.LogAlertDismissals) + if (affected < liveCount && App.LogAlertDismissals) { - AppLogger.Warn("AlertsHistory", $"Dismiss all: only {affected} of {displayCount} displayed alert(s) were updated — remaining alerts may have been archived to parquet"); + AppLogger.Warn("AlertsHistory", $"Dismiss all: only {affected} of {liveCount} live alert(s) were updated"); } await LoadAlertsAsync(); } diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 1a2c987..5bbca8d 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -685,11 +685,17 @@ public async Task CreateArchiveViewsAsync() if (hasParquetFiles) { var globPath = parquetGlob.Replace("\\", "/"); - viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table} UNION ALL BY NAME SELECT * FROM read_parquet('{globPath}', union_by_name=true)"; + if (table == "config_alert_log") + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT *, 'live' AS source FROM {table} UNION ALL BY NAME SELECT *, 'archive' AS source FROM read_parquet('{globPath}', union_by_name=true)"; + else + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table} UNION ALL BY NAME SELECT * FROM read_parquet('{globPath}', union_by_name=true)"; } else { - viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; + if (table == "config_alert_log") + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT *, 'live' AS source FROM {table}"; + else + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; } using var cmd = connection.CreateCommand(); @@ -703,7 +709,10 @@ public async Task CreateArchiveViewsAsync() try { using var fallbackCmd = connection.CreateCommand(); - fallbackCmd.CommandText = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; + if (table == "config_alert_log") + fallbackCmd.CommandText = $"CREATE OR REPLACE VIEW v_{table} AS SELECT *, 'live' AS source FROM {table}"; + else + fallbackCmd.CommandText = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; await fallbackCmd.ExecuteNonQueryAsync(); } catch (Exception fallbackEx) diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs index 9e29d21..0e093bf 100644 --- a/Lite/Services/LocalDataService.AlertHistory.cs +++ b/Lite/Services/LocalDataService.AlertHistory.cs @@ -39,7 +39,8 @@ public async Task> GetAlertHistoryAsync(int hoursBack = 24 notification_type, send_error, muted, - detail_text + detail_text, + source FROM v_config_alert_log WHERE alert_time >= $1 AND server_id = $2 @@ -64,7 +65,8 @@ ORDER BY alert_time DESC notification_type, send_error, muted, - detail_text + detail_text, + source FROM v_config_alert_log WHERE alert_time >= $1 AND dismissed = FALSE @@ -90,7 +92,8 @@ ORDER BY alert_time DESC NotificationType = reader.GetString(7), SendError = reader.IsDBNull(8) ? null : reader.GetString(8), Muted = !reader.IsDBNull(9) && reader.GetBoolean(9), - DetailText = reader.IsDBNull(10) ? null : reader.GetString(10) + DetailText = reader.IsDBNull(10) ? null : reader.GetString(10), + Source = reader.IsDBNull(11) ? "live" : reader.GetString(11) }); } @@ -190,6 +193,9 @@ public class AlertHistoryRow public string? SendError { get; set; } public bool Muted { get; set; } public string? DetailText { get; set; } + public string Source { get; set; } = "live"; + + public bool IsArchived => string.Equals(Source, "archive", StringComparison.OrdinalIgnoreCase); public string TimeLocal => AlertTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); public string CurrentValueDisplay => FormatValue(MetricName, CurrentValue);