diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 00ffaf9..44a66a6 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -502,7 +502,26 @@ private async Task OpenServerTabAsync(ServerConnection server) var utcOffset = connStatus.UtcOffsetMinutes ?? (int)TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes; Helpers.ServerTimeHelper.UtcOffsetMinutes = utcOffset; - var serverTab = new ServerTab(server, utcOffset); + ServerTab serverTab; + try + { + serverTab = new ServerTab(server, utcOffset); + } + catch (Exception ex) + { + var inner = ex.InnerException?.Message ?? ex.Message; + System.Windows.MessageBox.Show( + $"Failed to open server tab for '{server.DisplayName}'.\n\n" + + $"This is usually caused by a missing Visual C++ Redistributable (x64) " + + $"or an OS compatibility issue with the SkiaSharp rendering library.\n\n" + + $"Download the latest VC++ Redistributable from:\n" + + $"https://aka.ms/vs/17/release/vc_redist.x64.exe\n\n" + + $"Error: {inner}", + "Chart Initialization Error", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Error); + return; + } serverTab.AlertAcknowledged += (_, _) => { _emailAlertService.HideAllAlerts(8760, server.DisplayName); diff --git a/Lite/Services/DeltaCalculator.cs b/Lite/Services/DeltaCalculator.cs index 377130d..383f054 100644 --- a/Lite/Services/DeltaCalculator.cs +++ b/Lite/Services/DeltaCalculator.cs @@ -24,9 +24,9 @@ namespace PerformanceMonitorLite.Services; public class DeltaCalculator { /// - /// Cache structure: serverId -> collectorName -> key -> previousValue + /// Cache structure: serverId -> collectorName -> key -> (previousValue, timestamp) /// - private readonly ConcurrentDictionary>> _cache = new(); + private readonly ConcurrentDictionary>> _cache = new(); private readonly ILogger? _logger; @@ -63,12 +63,15 @@ public async Task SeedFromDatabaseAsync(DuckDbInitializer duckDb) /// Calculates the delta between the current value and the previous cached value. /// First-ever sighting (no baseline): returns currentValue so single-execution queries appear. /// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn. + /// Gap detection: if collectionTime and maxGapSeconds are provided and the gap since the + /// last cached value exceeds maxGapSeconds, returns 0 to avoid inflated deltas after restarts. /// Thread-safe via atomic AddOrUpdate. /// - public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, bool baselineOnly = false) + public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, + bool baselineOnly = false, DateTime? collectionTime = null, int maxGapSeconds = 0) { - var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>()); - var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary()); + var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>()); + var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary()); long delta = 0; @@ -80,15 +83,24 @@ public long CalculateDelta(int serverId, string collectorName, string key, long _ => { delta = baselineOnly ? 0 : currentValue; - return currentValue; + return (currentValue, collectionTime); }, /* Update: compute delta atomically */ - (_, previousValue) => + (_, previous) => { - delta = currentValue < previousValue + /* Gap detection: if too much time has passed since the last cached value, + treat this as a new baseline to avoid inflated deltas after app restarts */ + if (maxGapSeconds > 0 && collectionTime.HasValue && previous.Timestamp.HasValue + && (collectionTime.Value - previous.Timestamp.Value).TotalSeconds > maxGapSeconds) + { + delta = 0; + return (currentValue, collectionTime); + } + + delta = currentValue < previous.Value ? 0 /* counter reset (plan cache eviction/re-entry) — not real new work */ - : currentValue - previousValue; - return currentValue; + : currentValue - previous.Value; + return (currentValue, collectionTime); }); return delta; @@ -97,18 +109,18 @@ public long CalculateDelta(int serverId, string collectorName, string key, long /// /// Seeds a single value into the cache without computing a delta. /// - private void Seed(int serverId, string collectorName, string key, long value) + private void Seed(int serverId, string collectorName, string key, long value, DateTime? timestamp = null) { - var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>()); - var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary()); - collectorCache[key] = value; + var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>()); + var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary()); + collectorCache[key] = (value, timestamp); } private async Task SeedWaitStatsAsync(DuckDBConnection connection) { using var cmd = connection.CreateCommand(); cmd.CommandText = @" -SELECT server_id, wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms +SELECT server_id, wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms, collection_time FROM wait_stats WHERE (server_id, collection_time) IN ( SELECT server_id, MAX(collection_time) FROM wait_stats GROUP BY server_id @@ -119,9 +131,10 @@ FROM wait_stats { var serverId = reader.GetInt32(0); var waitType = reader.GetString(1); - Seed(serverId, "wait_stats_tasks", waitType, reader.GetInt64(2)); - Seed(serverId, "wait_stats_time", waitType, reader.GetInt64(3)); - Seed(serverId, "wait_stats_signal", waitType, reader.GetInt64(4)); + var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); + Seed(serverId, "wait_stats_tasks", waitType, reader.GetInt64(2), ts); + Seed(serverId, "wait_stats_time", waitType, reader.GetInt64(3), ts); + Seed(serverId, "wait_stats_signal", waitType, reader.GetInt64(4), ts); count++; } if (count > 0) _logger?.LogDebug("Seeded {Count} wait_stats baseline rows", count); @@ -134,7 +147,8 @@ private async Task SeedFileIoStatsAsync(DuckDBConnection connection) SELECT server_id, database_name, file_name, num_of_reads, num_of_writes, read_bytes, write_bytes, io_stall_read_ms, io_stall_write_ms, - io_stall_queued_read_ms, io_stall_queued_write_ms + io_stall_queued_read_ms, io_stall_queued_write_ms, + collection_time FROM file_io_stats WHERE (server_id, collection_time) IN ( SELECT server_id, MAX(collection_time) FROM file_io_stats GROUP BY server_id @@ -147,14 +161,15 @@ FROM file_io_stats var dbName = reader.IsDBNull(1) ? "" : reader.GetString(1); var fileName = reader.IsDBNull(2) ? "" : reader.GetString(2); var deltaKey = $"{dbName}|{fileName}"; - Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3)); - Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4)); - Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5)); - Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6)); - Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7)); - Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8)); - Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9)); - Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10)); + var ts = reader.IsDBNull(11) ? (DateTime?)null : reader.GetDateTime(11); + Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), ts); + Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), ts); + Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5), ts); + Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6), ts); + Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7), ts); + Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8), ts); + Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9), ts); + Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10), ts); count++; } if (count > 0) _logger?.LogDebug("Seeded {Count} file_io_stats baseline rows", count); @@ -164,7 +179,7 @@ private async Task SeedPerfmonStatsAsync(DuckDBConnection connection) { using var cmd = connection.CreateCommand(); cmd.CommandText = @" -SELECT server_id, object_name, counter_name, instance_name, cntr_value +SELECT server_id, object_name, counter_name, instance_name, cntr_value, collection_time FROM perfmon_stats WHERE (server_id, collection_time) IN ( SELECT server_id, MAX(collection_time) FROM perfmon_stats GROUP BY server_id @@ -177,7 +192,8 @@ FROM perfmon_stats var objectName = reader.IsDBNull(1) ? "" : reader.GetString(1); var counter = reader.IsDBNull(2) ? "" : reader.GetString(2); var instance = reader.IsDBNull(3) ? "" : reader.GetString(3); - Seed(serverId, "perfmon", $"{objectName}|{counter}|{instance}", reader.GetInt64(4)); + var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); + Seed(serverId, "perfmon", $"{objectName}|{counter}|{instance}", reader.GetInt64(4), ts); count++; } if (count > 0) _logger?.LogDebug("Seeded {Count} perfmon_stats baseline rows", count); @@ -202,8 +218,8 @@ FROM memory_grant_stats var poolId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); var semaphoreId = reader.IsDBNull(2) ? (short)0 : reader.GetInt16(2); var deltaKey = $"{poolId}_{semaphoreId}"; - Seed(serverId, "memory_grants_timeouts", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3)); - Seed(serverId, "memory_grants_forced", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4)); + Seed(serverId, "memory_grants_timeouts", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), null); + Seed(serverId, "memory_grants_forced", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), null); count++; } if (count > 0) _logger?.LogDebug("Seeded {Count} memory_grant_stats baseline rows", count); diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs index f748846..7d7748d 100644 --- a/Lite/Services/LocalDataService.Perfmon.cs +++ b/Lite/Services/LocalDataService.Perfmon.cs @@ -95,13 +95,14 @@ public async Task> GetPerfmonTrendAsync(int serverId, st command.CommandText = @" SELECT collection_time, - cntr_value, - delta_cntr_value + SUM(cntr_value) AS cntr_value, + SUM(delta_cntr_value) AS delta_cntr_value FROM v_perfmon_stats WHERE server_id = $1 AND counter_name = $2 AND collection_time >= $3 AND collection_time <= $4 +GROUP BY collection_time ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); diff --git a/Lite/Services/RemoteCollectorService.Perfmon.cs b/Lite/Services/RemoteCollectorService.Perfmon.cs index 8e0b1ec..145b77b 100644 --- a/Lite/Services/RemoteCollectorService.Perfmon.cs +++ b/Lite/Services/RemoteCollectorService.Perfmon.cs @@ -178,9 +178,11 @@ WHERE pc.counter_name IN ( var instanceName = reader.IsDBNull(2) ? "" : reader.GetString(2); var cntrValue = reader.GetInt64(3); - /* Delta for per-second counters */ + /* Delta for per-second counters — gap detection at 5min (5x the 1-min collection interval) + prevents inflated deltas after app restarts */ var deltaKey = $"{objectName}|{counterName}|{instanceName}"; - var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, baselineOnly: true); + var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, + baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) @@ -192,7 +194,7 @@ WHERE pc.counter_name IN ( .AppendValue(instanceName) .AppendValue(cntrValue) .AppendValue(deltaCntrValue) - .AppendValue(600) /* 10-minute interval */ + .AppendValue(60) /* 1-minute collection interval */ .EndRow(); rowsCollected++;