Skip to content

Commit c20c280

Browse files
Fix Lite perfmon chart bugs and Dashboard ScottPlot crash handling (#544, #545)
Lite perfmon fixes (#544): - Aggregate chart query by collection_time (SUM across instance_name) so multi-instance counters show one clean line instead of jagged duplicates - Add gap detection to DeltaCalculator — if >5min since last cached value, return 0 delta instead of inflated value spanning the entire gap - Seed timestamps from DuckDB so gap detection works after app restarts - Fix sample_interval_seconds from hardcoded 600 to 60 (actual interval) Dashboard fix (#545): - Wrap ServerTab construction in try-catch with user-friendly error message pointing to VC++ Redistributable download for SkiaSharp failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93948e4 commit c20c280

4 files changed

Lines changed: 75 additions & 37 deletions

File tree

Dashboard/MainWindow.xaml.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,26 @@ private async Task OpenServerTabAsync(ServerConnection server)
502502
var utcOffset = connStatus.UtcOffsetMinutes ?? (int)TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes;
503503
Helpers.ServerTimeHelper.UtcOffsetMinutes = utcOffset;
504504

505-
var serverTab = new ServerTab(server, utcOffset);
505+
ServerTab serverTab;
506+
try
507+
{
508+
serverTab = new ServerTab(server, utcOffset);
509+
}
510+
catch (Exception ex)
511+
{
512+
var inner = ex.InnerException?.Message ?? ex.Message;
513+
System.Windows.MessageBox.Show(
514+
$"Failed to open server tab for '{server.DisplayName}'.\n\n" +
515+
$"This is usually caused by a missing Visual C++ Redistributable (x64) " +
516+
$"or an OS compatibility issue with the SkiaSharp rendering library.\n\n" +
517+
$"Download the latest VC++ Redistributable from:\n" +
518+
$"https://aka.ms/vs/17/release/vc_redist.x64.exe\n\n" +
519+
$"Error: {inner}",
520+
"Chart Initialization Error",
521+
System.Windows.MessageBoxButton.OK,
522+
System.Windows.MessageBoxImage.Error);
523+
return;
524+
}
506525
serverTab.AlertAcknowledged += (_, _) =>
507526
{
508527
_emailAlertService.HideAllAlerts(8760, server.DisplayName);

Lite/Services/DeltaCalculator.cs

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ namespace PerformanceMonitorLite.Services;
2424
public class DeltaCalculator
2525
{
2626
/// <summary>
27-
/// Cache structure: serverId -> collectorName -> key -> previousValue
27+
/// Cache structure: serverId -> collectorName -> key -> (previousValue, timestamp)
2828
/// </summary>
29-
private readonly ConcurrentDictionary<int, ConcurrentDictionary<string, ConcurrentDictionary<string, long>>> _cache = new();
29+
private readonly ConcurrentDictionary<int, ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>> _cache = new();
3030

3131
private readonly ILogger? _logger;
3232

@@ -63,12 +63,15 @@ public async Task SeedFromDatabaseAsync(DuckDbInitializer duckDb)
6363
/// Calculates the delta between the current value and the previous cached value.
6464
/// First-ever sighting (no baseline): returns currentValue so single-execution queries appear.
6565
/// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn.
66+
/// Gap detection: if collectionTime and maxGapSeconds are provided and the gap since the
67+
/// last cached value exceeds maxGapSeconds, returns 0 to avoid inflated deltas after restarts.
6668
/// Thread-safe via atomic AddOrUpdate.
6769
/// </summary>
68-
public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, bool baselineOnly = false)
70+
public long CalculateDelta(int serverId, string collectorName, string key, long currentValue,
71+
bool baselineOnly = false, DateTime? collectionTime = null, int maxGapSeconds = 0)
6972
{
70-
var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, long>>());
71-
var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, long>());
73+
var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>());
74+
var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>());
7275

7376
long delta = 0;
7477

@@ -80,15 +83,24 @@ public long CalculateDelta(int serverId, string collectorName, string key, long
8083
_ =>
8184
{
8285
delta = baselineOnly ? 0 : currentValue;
83-
return currentValue;
86+
return (currentValue, collectionTime);
8487
},
8588
/* Update: compute delta atomically */
86-
(_, previousValue) =>
89+
(_, previous) =>
8790
{
88-
delta = currentValue < previousValue
91+
/* Gap detection: if too much time has passed since the last cached value,
92+
treat this as a new baseline to avoid inflated deltas after app restarts */
93+
if (maxGapSeconds > 0 && collectionTime.HasValue && previous.Timestamp.HasValue
94+
&& (collectionTime.Value - previous.Timestamp.Value).TotalSeconds > maxGapSeconds)
95+
{
96+
delta = 0;
97+
return (currentValue, collectionTime);
98+
}
99+
100+
delta = currentValue < previous.Value
89101
? 0 /* counter reset (plan cache eviction/re-entry) — not real new work */
90-
: currentValue - previousValue;
91-
return currentValue;
102+
: currentValue - previous.Value;
103+
return (currentValue, collectionTime);
92104
});
93105

94106
return delta;
@@ -97,18 +109,18 @@ public long CalculateDelta(int serverId, string collectorName, string key, long
97109
/// <summary>
98110
/// Seeds a single value into the cache without computing a delta.
99111
/// </summary>
100-
private void Seed(int serverId, string collectorName, string key, long value)
112+
private void Seed(int serverId, string collectorName, string key, long value, DateTime? timestamp = null)
101113
{
102-
var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, long>>());
103-
var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, long>());
104-
collectorCache[key] = value;
114+
var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>());
115+
var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>());
116+
collectorCache[key] = (value, timestamp);
105117
}
106118

107119
private async Task SeedWaitStatsAsync(DuckDBConnection connection)
108120
{
109121
using var cmd = connection.CreateCommand();
110122
cmd.CommandText = @"
111-
SELECT server_id, wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms
123+
SELECT server_id, wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms, collection_time
112124
FROM wait_stats
113125
WHERE (server_id, collection_time) IN (
114126
SELECT server_id, MAX(collection_time) FROM wait_stats GROUP BY server_id
@@ -119,9 +131,10 @@ FROM wait_stats
119131
{
120132
var serverId = reader.GetInt32(0);
121133
var waitType = reader.GetString(1);
122-
Seed(serverId, "wait_stats_tasks", waitType, reader.GetInt64(2));
123-
Seed(serverId, "wait_stats_time", waitType, reader.GetInt64(3));
124-
Seed(serverId, "wait_stats_signal", waitType, reader.GetInt64(4));
134+
var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5);
135+
Seed(serverId, "wait_stats_tasks", waitType, reader.GetInt64(2), ts);
136+
Seed(serverId, "wait_stats_time", waitType, reader.GetInt64(3), ts);
137+
Seed(serverId, "wait_stats_signal", waitType, reader.GetInt64(4), ts);
125138
count++;
126139
}
127140
if (count > 0) _logger?.LogDebug("Seeded {Count} wait_stats baseline rows", count);
@@ -134,7 +147,8 @@ private async Task SeedFileIoStatsAsync(DuckDBConnection connection)
134147
SELECT server_id, database_name, file_name,
135148
num_of_reads, num_of_writes, read_bytes, write_bytes,
136149
io_stall_read_ms, io_stall_write_ms,
137-
io_stall_queued_read_ms, io_stall_queued_write_ms
150+
io_stall_queued_read_ms, io_stall_queued_write_ms,
151+
collection_time
138152
FROM file_io_stats
139153
WHERE (server_id, collection_time) IN (
140154
SELECT server_id, MAX(collection_time) FROM file_io_stats GROUP BY server_id
@@ -147,14 +161,15 @@ FROM file_io_stats
147161
var dbName = reader.IsDBNull(1) ? "" : reader.GetString(1);
148162
var fileName = reader.IsDBNull(2) ? "" : reader.GetString(2);
149163
var deltaKey = $"{dbName}|{fileName}";
150-
Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3));
151-
Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4));
152-
Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5));
153-
Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6));
154-
Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7));
155-
Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
156-
Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9));
157-
Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10));
164+
var ts = reader.IsDBNull(11) ? (DateTime?)null : reader.GetDateTime(11);
165+
Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), ts);
166+
Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), ts);
167+
Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5), ts);
168+
Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6), ts);
169+
Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7), ts);
170+
Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8), ts);
171+
Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9), ts);
172+
Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10), ts);
158173
count++;
159174
}
160175
if (count > 0) _logger?.LogDebug("Seeded {Count} file_io_stats baseline rows", count);
@@ -164,7 +179,7 @@ private async Task SeedPerfmonStatsAsync(DuckDBConnection connection)
164179
{
165180
using var cmd = connection.CreateCommand();
166181
cmd.CommandText = @"
167-
SELECT server_id, object_name, counter_name, instance_name, cntr_value
182+
SELECT server_id, object_name, counter_name, instance_name, cntr_value, collection_time
168183
FROM perfmon_stats
169184
WHERE (server_id, collection_time) IN (
170185
SELECT server_id, MAX(collection_time) FROM perfmon_stats GROUP BY server_id
@@ -177,7 +192,8 @@ FROM perfmon_stats
177192
var objectName = reader.IsDBNull(1) ? "" : reader.GetString(1);
178193
var counter = reader.IsDBNull(2) ? "" : reader.GetString(2);
179194
var instance = reader.IsDBNull(3) ? "" : reader.GetString(3);
180-
Seed(serverId, "perfmon", $"{objectName}|{counter}|{instance}", reader.GetInt64(4));
195+
var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5);
196+
Seed(serverId, "perfmon", $"{objectName}|{counter}|{instance}", reader.GetInt64(4), ts);
181197
count++;
182198
}
183199
if (count > 0) _logger?.LogDebug("Seeded {Count} perfmon_stats baseline rows", count);
@@ -202,8 +218,8 @@ FROM memory_grant_stats
202218
var poolId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
203219
var semaphoreId = reader.IsDBNull(2) ? (short)0 : reader.GetInt16(2);
204220
var deltaKey = $"{poolId}_{semaphoreId}";
205-
Seed(serverId, "memory_grants_timeouts", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3));
206-
Seed(serverId, "memory_grants_forced", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4));
221+
Seed(serverId, "memory_grants_timeouts", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), null);
222+
Seed(serverId, "memory_grants_forced", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), null);
207223
count++;
208224
}
209225
if (count > 0) _logger?.LogDebug("Seeded {Count} memory_grant_stats baseline rows", count);

Lite/Services/LocalDataService.Perfmon.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,14 @@ public async Task<List<PerfmonTrendPoint>> GetPerfmonTrendAsync(int serverId, st
9595
command.CommandText = @"
9696
SELECT
9797
collection_time,
98-
cntr_value,
99-
delta_cntr_value
98+
SUM(cntr_value) AS cntr_value,
99+
SUM(delta_cntr_value) AS delta_cntr_value
100100
FROM v_perfmon_stats
101101
WHERE server_id = $1
102102
AND counter_name = $2
103103
AND collection_time >= $3
104104
AND collection_time <= $4
105+
GROUP BY collection_time
105106
ORDER BY collection_time";
106107

107108
command.Parameters.Add(new DuckDBParameter { Value = serverId });

Lite/Services/RemoteCollectorService.Perfmon.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,11 @@ WHERE pc.counter_name IN (
178178
var instanceName = reader.IsDBNull(2) ? "" : reader.GetString(2);
179179
var cntrValue = reader.GetInt64(3);
180180

181-
/* Delta for per-second counters */
181+
/* Delta for per-second counters — gap detection at 5min (5x the 1-min collection interval)
182+
prevents inflated deltas after app restarts */
182183
var deltaKey = $"{objectName}|{counterName}|{instanceName}";
183-
var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, baselineOnly: true);
184+
var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue,
185+
baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
184186

185187
var row = appender.CreateRow();
186188
row.AppendValue(GenerateCollectionId())
@@ -192,7 +194,7 @@ WHERE pc.counter_name IN (
192194
.AppendValue(instanceName)
193195
.AppendValue(cntrValue)
194196
.AppendValue(deltaCntrValue)
195-
.AppendValue(600) /* 10-minute interval */
197+
.AppendValue(60) /* 1-minute collection interval */
196198
.EndRow();
197199

198200
rowsCollected++;

0 commit comments

Comments
 (0)