From 0b8a8405144c995058834a2391fb022cd411241d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:57:34 -0500 Subject: [PATCH 1/2] Replace all AddWithValue with explicit SqlParameter types, fix alert re-firing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddWithValue infers types incorrectly — for DateTime it sends legacy datetime (3.33ms precision) instead of datetime2, causing the deadlock dedup cutoff to round and re-collect the same event every cycle. Replaced all 28 usages across 7 files with explicitly typed SqlParameter. Fixed alert re-firing: ServerTab used CollectionTime (always new) instead of DeadlockTime/EventTime for ack comparison. Overview deadlock count query also filtered by collection_time instead of deadlock_time, inflating counts with duplicate rows and triggering spurious popup alerts. Tested: zero AddWithValue remaining, all 3 projects build clean, dedup confirmed working under HammerDB load (97 new deadlocks collected, zero duplicates). Co-Authored-By: Claude Opus 4.6 --- .../Services/DatabaseService.NocHealth.cs | 5 +++-- Dashboard/Services/DatabaseService.cs | 9 ++++---- Installer/Program.cs | 21 ++++++++++--------- Lite/Controls/ServerTab.xaml.cs | 4 ++-- Lite/Services/LocalDataService.Overview.cs | 2 +- Lite/Services/LocalDataService.QueryStats.cs | 9 ++++---- Lite/Services/LocalDataService.QueryStore.cs | 3 ++- ...teCollectorService.BlockedProcessReport.cs | 8 +++---- .../RemoteCollectorService.Deadlocks.cs | 8 +++---- 9 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index f678a3c3..33a68263 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.Threading.Tasks; using Microsoft.Data.SqlClient; using PerformanceMonitorDashboard.Helpers; @@ -631,7 +632,7 @@ ORDER BY r.total_elapsed_time DESC { using var cmd = new SqlCommand(query, connection); cmd.CommandTimeout = 10; - cmd.Parameters.AddWithValue("@thresholdMs", (long)thresholdMinutes * 60 * 1000); + cmd.Parameters.Add(new SqlParameter("@thresholdMs", SqlDbType.BigInt) { Value = (long)thresholdMinutes * 60 * 1000 }); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) @@ -685,7 +686,7 @@ ORDER BY percent_of_average DESC { using var cmd = new SqlCommand(query, connection); cmd.CommandTimeout = 10; - cmd.Parameters.AddWithValue("@thresholdPercent", thresholdPercent); + cmd.Parameters.Add(new SqlParameter("@thresholdPercent", SqlDbType.Int) { Value = thresholdPercent }); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) diff --git a/Dashboard/Services/DatabaseService.cs b/Dashboard/Services/DatabaseService.cs index ffcd89d7..f255a10b 100644 --- a/Dashboard/Services/DatabaseService.cs +++ b/Dashboard/Services/DatabaseService.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.Globalization; using System.Threading; using System.Threading.Tasks; @@ -302,10 +303,10 @@ public async Task UpdateCollectorScheduleAsync(int scheduleId, bool enabled, int using var command = new SqlCommand(query, connection); command.CommandTimeout = 120; - command.Parameters.AddWithValue("@schedule_id", scheduleId); - command.Parameters.AddWithValue("@enabled", enabled); - command.Parameters.AddWithValue("@frequency_minutes", frequencyMinutes); - command.Parameters.AddWithValue("@retention_days", retentionDays); + command.Parameters.Add(new SqlParameter("@schedule_id", SqlDbType.Int) { Value = scheduleId }); + command.Parameters.Add(new SqlParameter("@enabled", SqlDbType.Bit) { Value = enabled }); + command.Parameters.Add(new SqlParameter("@frequency_minutes", SqlDbType.Int) { Value = frequencyMinutes }); + command.Parameters.Add(new SqlParameter("@retention_days", SqlDbType.Int) { Value = retentionDays }); await command.ExecuteNonQueryAsync(); } diff --git a/Installer/Program.cs b/Installer/Program.cs index a3b7c97e..db6bceac 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.IO; using System.Linq; using System.Net.Http; @@ -1341,16 +1342,16 @@ INSERT INTO PerformanceMonitor.config.installation_history using (var insertCmd = new SqlCommand(insertSql, connection)) { - insertCmd.Parameters.AddWithValue("@installer_version", assemblyVersion); - insertCmd.Parameters.AddWithValue("@installer_info_version", (object?)infoVersion ?? DBNull.Value); - insertCmd.Parameters.AddWithValue("@sql_server_version", sqlVersion); - insertCmd.Parameters.AddWithValue("@sql_server_edition", sqlEdition); - insertCmd.Parameters.AddWithValue("@installation_type", installationType); - insertCmd.Parameters.AddWithValue("@previous_version", (object?)previousVersion ?? DBNull.Value); - insertCmd.Parameters.AddWithValue("@installation_status", status); - insertCmd.Parameters.AddWithValue("@files_executed", filesExecuted); - insertCmd.Parameters.AddWithValue("@files_failed", filesFailed); - insertCmd.Parameters.AddWithValue("@installation_duration_ms", durationMs); + insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion }); + insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value }); + insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion }); + insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition }); + insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType }); + insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value }); + insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status }); + insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted }); + insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed }); + insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs }); await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false); } diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 4b771b0f..113adc41 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -536,8 +536,8 @@ Include the latest event timestamp so acknowledgement is only DateTime? latestEventTime = null; if (blockingCount > 0 || deadlockCount > 0) { - var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.CollectionTime); - var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.CollectionTime); + var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime); + var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime); latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; } AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); diff --git a/Lite/Services/LocalDataService.Overview.cs b/Lite/Services/LocalDataService.Overview.cs index 3e353376..2c9789be 100644 --- a/Lite/Services/LocalDataService.Overview.cs +++ b/Lite/Services/LocalDataService.Overview.cs @@ -85,7 +85,7 @@ FROM blocked_process_reports SELECT COUNT(*) FROM deadlocks WHERE server_id = $1 -AND collection_time >= $2"; +AND deadlock_time >= $2"; cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-1) }); var result = await cmd.ExecuteScalarAsync(); diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs index 78b4ab51..fd4bafaf 100644 --- a/Lite/Services/LocalDataService.QueryStats.cs +++ b/Lite/Services/LocalDataService.QueryStats.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.Threading.Tasks; using DuckDB.NET.Data; using Microsoft.Data.SqlClient; @@ -27,7 +28,7 @@ public partial class LocalDataService quoted_name = QUOTENAME(d.name) FROM sys.databases AS d WHERE d.name = @database_name;", connection); - command.Parameters.AddWithValue("@database_name", databaseName); + command.Parameters.Add(new SqlParameter("@database_name", SqlDbType.NVarChar, 128) { Value = databaseName }); var result = await command.ExecuteScalarAsync(); return result as string; } @@ -247,7 +248,7 @@ qs.total_elapsed_time DESC using var connection = new SqlConnection(connectionString); await connection.OpenAsync(); using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; - command.Parameters.AddWithValue("@query_hash", queryHash); + command.Parameters.Add(new SqlParameter("@query_hash", SqlDbType.VarChar, 64) { Value = queryHash }); var result = await command.ExecuteScalarAsync(); return result as string; } @@ -294,8 +295,8 @@ ps.total_elapsed_time DESC @schema_name;"; using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; - command.Parameters.AddWithValue("@object_name", objectName); - command.Parameters.AddWithValue("@schema_name", schemaName); + command.Parameters.Add(new SqlParameter("@object_name", SqlDbType.NVarChar, 128) { Value = objectName }); + command.Parameters.Add(new SqlParameter("@schema_name", SqlDbType.NVarChar, 128) { Value = schemaName }); var result = await command.ExecuteScalarAsync(); return result as string; } diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs index f02fe78f..b492ae72 100644 --- a/Lite/Services/LocalDataService.QueryStore.cs +++ b/Lite/Services/LocalDataService.QueryStore.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.Threading.Tasks; using DuckDB.NET.Data; using Microsoft.Data.SqlClient; @@ -198,7 +199,7 @@ AND qsp.query_plan IS NOT NULL @plan_id;"; using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; - command.Parameters.AddWithValue("@plan_id", planId); + command.Parameters.Add(new SqlParameter("@plan_id", SqlDbType.BigInt) { Value = planId }); var result = await command.ExecuteScalarAsync(); return result as string; } diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index c66f5957..7c6da3d3 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -7,6 +7,7 @@ */ using System; +using System.Data; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -126,7 +127,7 @@ LEFT JOIN sys.dm_xe_sessions AS dxs WHERE ses.name = @session_name;", connection)) { cmd.CommandTimeout = CommandTimeoutSeconds; - cmd.Parameters.AddWithValue("@session_name", BlockedProcessXeSessionName); + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = BlockedProcessXeSessionName }); var result = await cmd.ExecuteScalarAsync(cancellationToken); if (result != null) @@ -205,7 +206,7 @@ FROM sys.database_event_sessions AS des WHERE des.name = @session_name;", connection)) { cmd.CommandTimeout = CommandTimeoutSeconds; - cmd.Parameters.AddWithValue("@session_name", BlockedProcessXeSessionName); + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = BlockedProcessXeSessionName }); var result = await cmd.ExecuteScalarAsync(cancellationToken); if (result != null) @@ -380,8 +381,7 @@ as it lingers in the ring buffer across collection cycles. */ command.CommandTimeout = CommandTimeoutSeconds; /* Use the most recent timestamp from DuckDB as the cutoff, or fall back to 10-minute window */ - command.Parameters.AddWithValue("@cutoff_time", - lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10)); + command.Parameters.Add(new SqlParameter("@cutoff_time", SqlDbType.DateTime2) { Value = lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10) }); try { diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs index 4a1e5a7e..1a76b726 100644 --- a/Lite/Services/RemoteCollectorService.Deadlocks.cs +++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs @@ -7,6 +7,7 @@ */ using System; +using System.Data; using System.Diagnostics; using System.Linq; using System.Threading; @@ -80,7 +81,7 @@ LEFT JOIN sys.dm_xe_sessions AS dxs WHERE ses.name = @session_name;", connection)) { cmd.CommandTimeout = CommandTimeoutSeconds; - cmd.Parameters.AddWithValue("@session_name", DeadlockXeSessionName); + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = DeadlockXeSessionName }); var result = await cmd.ExecuteScalarAsync(cancellationToken); if (result != null) @@ -179,7 +180,7 @@ ELSE NULL END;", connection)) { cmd.CommandTimeout = CommandTimeoutSeconds; - cmd.Parameters.AddWithValue("@session_name", DeadlockXeSessionName); + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = DeadlockXeSessionName }); var result = await cmd.ExecuteScalarAsync(cancellationToken); if (result is int hasCorrectEvent) @@ -380,8 +381,7 @@ lingers in the ring buffer across collection cycles. */ command.CommandTimeout = CommandTimeoutSeconds; /* Use the most recent timestamp from DuckDB as the cutoff, or fall back to 10-minute window */ - command.Parameters.AddWithValue("@cutoff_time", - lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10)); + command.Parameters.Add(new SqlParameter("@cutoff_time", SqlDbType.DateTime2) { Value = lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10) }); try { From f8d1b63471ad849e9a51bd683e2ceec83b78df77 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:08:08 -0500 Subject: [PATCH 2/2] Fix chart zero lines, collection log server name, and overview online status (#85, #91, #93) #85: Blocking/deadlock trend charts now show a visible flat line at zero instead of empty space. Added negative Y-axis margin so zero-value data renders above the axis border. Reduced fallback Y range from +100 to +1. #91: Added server_name column to collection_log DuckDB table (schema v10) so log entries identify which server produced them without a lookup join. #93: Sidebar status dots now reflect actual connection check results (IsOnline) instead of the IsEnabled user config toggle. Added IsOnline property to ServerConnection model, synced from connection status in RefreshServerList and CheckConnectionsAndNotify. Fixed first-check refresh so dots update as soon as initial connection checks complete. Co-Authored-By: Claude Opus 4.6 --- Lite/Controls/ServerTab.xaml.cs | 28 ++++++++++++++----- Lite/Database/DuckDbInitializer.cs | 17 ++++++++++- Lite/Database/Schema.cs | 1 + Lite/MainWindow.xaml | 4 +-- Lite/MainWindow.xaml.cs | 18 +++++++++++- Lite/Models/ServerConnection.cs | 7 +++++ .../LocalDataService.CollectionHealth.cs | 7 +++-- Lite/Services/RemoteCollectorService.cs | 9 +++--- 8 files changed, 74 insertions(+), 17 deletions(-) diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 113adc41..b14035fb 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -849,12 +849,19 @@ private void UpdateBlockingTrendChart(List data, int hoursBack, Date if (data.Count == 0) { - /* Show empty chart with correct time range */ + /* No blocking events — show a flat line at zero so the chart looks active */ + var zeroLine = BlockingTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocking Incidents"; + zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); + zeroLine.MarkerSize = 0; BlockingTrendChart.Plot.Axes.DateTimeTicksBottom(); BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - BlockingTrendChart.Plot.Axes.SetLimitsY(0, 1); ReapplyAxisColors(BlockingTrendChart); BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1); + ShowChartLegend(BlockingTrendChart); BlockingTrendChart.Refresh(); return; } @@ -919,12 +926,19 @@ private void UpdateDeadlockTrendChart(List data, int hoursBack, Date if (data.Count == 0) { - /* Show empty chart with correct time range */ + /* No deadlocks — show a flat line at zero so the chart looks active */ + var zeroLine = DeadlockTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Deadlocks"; + zeroLine.Color = ScottPlot.Color.FromHex("#FFB74D"); + zeroLine.MarkerSize = 0; DeadlockTrendChart.Plot.Axes.DateTimeTicksBottom(); DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - DeadlockTrendChart.Plot.Axes.SetLimitsY(0, 1); ReapplyAxisColors(DeadlockTrendChart); DeadlockTrendChart.Plot.YLabel("Deadlocks"); + SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, 1); + ShowChartLegend(DeadlockTrendChart); DeadlockTrendChart.Refresh(); return; } @@ -1495,13 +1509,13 @@ private static void SetChartYLimitsWithLegendPadding(ScottPlot.WPF.WpfPlot chart dataYMin = limits.Bottom; dataYMax = limits.Top; } - if (dataYMax <= dataYMin) dataYMax = dataYMin + 100; + if (dataYMax <= dataYMin) dataYMax = dataYMin + 1; double range = dataYMax - dataYMin; double topPadding = range * 0.05; - /* Only add bottom padding if dataYMin is above zero - don't go negative */ - double yMin = dataYMin >= 0 ? 0 : dataYMin - (range * 0.10); + /* Add small bottom margin when dataYMin is zero so flat lines at Y=0 are visible above the axis */ + double yMin = dataYMin > 0 ? 0 : dataYMin == 0 ? -(range * 0.05) : dataYMin - (range * 0.10); double yMax = dataYMax + topPadding; chart.Plot.Axes.SetLimitsY(yMin, yMax); diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 809fb1ea..a288429a 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -19,7 +19,7 @@ public class DuckDbInitializer /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - private const int CurrentSchemaVersion = 9; + private const int CurrentSchemaVersion = 10; public DuckDbInitializer(string databasePath, ILogger? logger = null) { @@ -328,6 +328,21 @@ Safe to ALTER because this table uses INSERT (not appender). */ /* Table doesn't exist yet — will be created with correct schema below */ } } + + if (fromVersion < 10) + { + /* v10: Added server_name column to collection_log so log entries + can be identified by server without needing a lookup table. */ + _logger?.LogInformation("Running migration to v10: adding server_name column to collection_log"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE collection_log ADD COLUMN IF NOT EXISTS server_name VARCHAR"); + } + catch + { + /* Table doesn't exist yet — will be created with correct schema below */ + } + } } /// diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 658325b3..24f46ab5 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -38,6 +38,7 @@ modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP CREATE TABLE IF NOT EXISTS collection_log ( log_id BIGINT PRIMARY KEY, server_id INTEGER NOT NULL, + server_name VARCHAR, collector_name VARCHAR NOT NULL, collection_time TIMESTAMP NOT NULL, duration_ms INTEGER, diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml index 55d89105..c57bb020 100644 --- a/Lite/MainWindow.xaml +++ b/Lite/MainWindow.xaml @@ -97,10 +97,10 @@