From 8fffe7c09ece1393bd74ca79edaaf739cfcceb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20Silva?= Date: Wed, 4 Mar 2026 12:14:56 +0000 Subject: [PATCH 01/20] Fix #410 (#411) --- Dashboard/Helpers/ChartHoverHelper.cs | 5 +++-- Lite/Helpers/ChartHoverHelper.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index 17f27027..bc46d80d 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -71,9 +71,10 @@ private void OnMouseMove(object sender, MouseEventArgs e) try { var pos = e.GetPosition(_chart); + var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( - (float)(pos.X * _chart.DisplayScale), - (float)(pos.Y * _chart.DisplayScale)); + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); /* Use X-axis (time) proximity as the primary filter, Y-axis distance diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 207f374a..80d51d26 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -69,9 +69,10 @@ private void OnMouseMove(object sender, MouseEventArgs e) _lastUpdate = now; var pos = e.GetPosition(_chart); + var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( - (float)(pos.X * _chart.DisplayScale), - (float)(pos.Y * _chart.DisplayScale)); + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); double bestDistance = double.MaxValue; From 41107693dd9a2e7e4c82de1245a27319488955be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20Silva?= Date: Wed, 4 Mar 2026 12:14:59 +0000 Subject: [PATCH 02/20] Fix #412 (#413) --- Dashboard/Controls/PlanViewerControl.xaml.cs | 6 ++++-- Dashboard/Services/ShowPlanParser.cs | 13 +++++++++++++ Lite/Controls/PlanViewerControl.xaml.cs | 6 ++++-- Lite/Services/ShowPlanParser.cs | 13 +++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs index 6ea34e78..1b2e2ace 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml.cs +++ b/Dashboard/Controls/PlanViewerControl.xaml.cs @@ -534,7 +534,8 @@ private void ShowPropertiesPanel(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; PropertiesHeader.Text = headerText; PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; @@ -1481,7 +1482,8 @@ private ToolTip BuildNodeTooltip(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; stack.Children.Add(new TextBlock { diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 21778012..37f367c0 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -832,6 +832,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; + // Override PhysicalOp, LogicalOp, and icon when Lookup=true. + // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with + // rather than "Key Lookup (Clustered)" — correct the label here so all display + // paths (node card, tooltip, properties panel) show the right operator name. + if (node.Lookup) + { + var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true + || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); + node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + } + // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); diff --git a/Lite/Controls/PlanViewerControl.xaml.cs b/Lite/Controls/PlanViewerControl.xaml.cs index 0603ba59..e6504164 100644 --- a/Lite/Controls/PlanViewerControl.xaml.cs +++ b/Lite/Controls/PlanViewerControl.xaml.cs @@ -550,7 +550,8 @@ private void ShowPropertiesPanel(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; PropertiesHeader.Text = headerText; PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; @@ -1492,7 +1493,8 @@ private ToolTip BuildNodeTooltip(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; stack.Children.Add(new TextBlock { diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index 11c0f9ec..14625899 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -832,6 +832,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; + // Override PhysicalOp, LogicalOp, and icon when Lookup=true. + // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with + // rather than "Key Lookup (Clustered)" — correct the label here so all display + // paths (node card, tooltip, properties panel) show the right operator name. + if (node.Lookup) + { + var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true + || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); + node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + } + // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); From 5c2b7699e93d9bf6400011850bf9531af64ebc96 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:24:20 -0500 Subject: [PATCH 03/20] GUI installer: log installation history to config.installation_history (#414) The GUI installer read from installation_history for version detection but never wrote to it, causing subsequent upgrades to fail to detect the prior install. Adds LogInstallationHistoryAsync mirroring the CLI installer's existing LogInstallationHistory method. Fixes #409 Co-authored-by: Claude Opus 4.6 --- InstallerGui/MainWindow.xaml.cs | 19 ++++ InstallerGui/Services/InstallationService.cs | 111 +++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/InstallerGui/MainWindow.xaml.cs b/InstallerGui/MainWindow.xaml.cs index 91a199d7..111922c0 100644 --- a/InstallerGui/MainWindow.xaml.cs +++ b/InstallerGui/MainWindow.xaml.cs @@ -416,6 +416,25 @@ await _installationService.InstallDependenciesAsync( }, cancellationToken); + /* + Log installation history to database + */ + try + { + await InstallationService.LogInstallationHistoryAsync( + _connectionString, + AppAssemblyVersion, + AppVersion, + _installationResult.StartTime, + _installationResult.FilesSucceeded, + _installationResult.FilesFailed, + _installationResult.Success); + } + catch (Exception ex) + { + LogMessage($"Warning: Could not log installation history: {ex.Message}", "Warning"); + } + /* Run validation if requested */ diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs index 2935702e..25ccc85e 100644 --- a/InstallerGui/Services/InstallationService.cs +++ b/InstallerGui/Services/InstallationService.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.Data; using System.IO; using System.Linq; using System.Net.Http; @@ -1318,5 +1319,115 @@ public void Dispose() } GC.SuppressFinalize(this); } + + /// + /// Log installation history to config.installation_history + /// Mirrors CLI installer's LogInstallationHistory method + /// + public static async Task LogInstallationHistoryAsync( + string connectionString, + string assemblyVersion, + string infoVersion, + DateTime startTime, + int filesExecuted, + int filesFailed, + bool isSuccess) + { + try + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + + /*Check if this is an upgrade by checking for existing installation*/ + string? previousVersion = null; + string installationType = "INSTALL"; + + try + { + using var checkCmd = new SqlCommand(@" + SELECT TOP 1 installer_version + FROM PerformanceMonitor.config.installation_history + WHERE installation_status = 'SUCCESS' + ORDER BY installation_date DESC;", connection); + + var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false); + if (result != null && result != DBNull.Value) + { + previousVersion = result.ToString(); + bool isSameVersion = Version.TryParse(previousVersion, out var prevVer) + && Version.TryParse(assemblyVersion, out var currVer) + && prevVer == currVer; + installationType = isSameVersion ? "REINSTALL" : "UPGRADE"; + } + } + catch (SqlException) + { + /*Table might not exist yet on first install*/ + } + + /*Get SQL Server version info*/ + string sqlVersion = ""; + string sqlEdition = ""; + + using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection)) + using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + if (await reader.ReadAsync().ConfigureAwait(false)) + { + sqlVersion = reader.GetString(0); + sqlEdition = reader.GetString(1); + } + } + + long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds; + string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED"); + + var insertSql = @" + INSERT INTO PerformanceMonitor.config.installation_history + ( + installer_version, + installer_info_version, + sql_server_version, + sql_server_edition, + installation_type, + previous_version, + installation_status, + files_executed, + files_failed, + installation_duration_ms + ) + VALUES + ( + @installer_version, + @installer_info_version, + @sql_server_version, + @sql_server_edition, + @installation_type, + @previous_version, + @installation_status, + @files_executed, + @files_failed, + @installation_duration_ms + );"; + + using var insertCmd = new SqlCommand(insertSql, connection); + 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); + } + catch + { + /*Don't let history logging failure break the installation*/ + } + } } } From 7bb9dd7e832b5eace62c4ae7dbf8534ecb55aacf Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Wed, 4 Mar 2026 07:04:36 -0600 Subject: [PATCH 04/20] Feature/long running queries config settings (#415) * Added exclusions in GetLongRunningQueriesAsync() method for SP_SERVER_DIAGNOSTIC wait types, and WAITFOR wait types. * Added TOP parameter for query in GetLongRunningQueriesAsync() method to allow future configurability on the number of long-running queries returned. Added optional parameter to control display of WAITFOR types in future. * Replaced waitForFilter string constructor with C# string interpolation instead of janky string addition. * Added exclusion for backup-related waits to GetLongRunningQueriesAsync() method. Removed System.Collections.Generic using statement as it is unnecessary. * Reverted Controls/LandingPage.xaml.cs * Added BROKER_RECEIVE_WAITFOR wait type to waitforFilter exclusions. Added miscWaitsFilter to exclude XE_LIVE_TARGET_TVF waits. Removed unused parameters. Corrected minimum value for maxLongRunningQueryCount (minimum 1 instead of 5). * Added filtering for SP_SERVER_DIAGNOSTICS, WAITFOR, BROKER_RECEIVE_WAITFOR, BACKUPTHREAD, BACKUPIO, and XE_LIVE_TARGET_TVF wait types in Lite/Services/LocalDataService.WaitStats.cs * Add configurable max results setting for long-running queries Adds LongRunningQueryMaxResults to UserPreferences (default 5) and exposes it in the Settings UI alongside the existing duration threshold. Threads the value through GetAlertHealthAsync and GetLongRunningQueriesAsync to replace the hardcoded TOP(5) in the DMV query. Co-Authored-By: Claude Sonnet 4.6 * Apply max results validation and Lite parity for long-running queries Adds Math.Clamp(1, int.MaxValue) guard to GetLongRunningQueriesAsync in both Dashboard and Lite, and updates the Dashboard settings validation to use an explicit range check with a descriptive error message. Mirrors the LongRunningQueryMaxResults setting across the Lite project: adds the App property, loads/saves it to settings.json, exposes it in the Lite Settings UI, and passes it through to GetLongRunningQueriesAsync to replace the hardcoded LIMIT 5. Co-Authored-By: Claude Sonnet 4.6 * Use GetInt64() when loading long-running query max results from JSON Prevents an OverflowException if the value in settings.json is outside the int32 range. The value is read as long, clamped to [1, int.MaxValue], then cast back to int. Co-Authored-By: Claude Sonnet 4.6 * Add configurable long-running query filter toggles Replaces hardcoded wait type exclusions in GetLongRunningQueriesAsync with user-configurable booleans for SP_SERVER_DIAGNOSTICS, WAITFOR / BROKER_RECEIVE_WAITFOR, backup waits, and miscellaneous waits. All four filters default to true (existing behavior preserved). Settings are exposed in the Notifications section of both Dashboard and Lite Settings UIs and persisted to UserPreferences / settings.json. Co-Authored-By: Claude Sonnet 4.6 * merged with incoming dev branch * Parameterized TOP/LIMIT value in Dashboard/Services/DatabaseService.NocHealth.cs and Lite/Services/LocalDataService.WaitStats.cs, and clamped the upper bound of the value to 1000 to avoid foot shooting. Removed blank lines as per Erik's request. --------- Co-authored-by: Claude Sonnet 4.6 --- Dashboard/MainWindow.xaml.cs | 2 +- Dashboard/Models/UserPreferences.cs | 5 +++ .../Services/DatabaseService.NocHealth.cs | 44 ++++++++++++------- Dashboard/SettingsWindow.xaml | 22 +++++++++- Dashboard/SettingsWindow.xaml.cs | 19 ++++++++ Lite/App.xaml.cs | 10 +++++ Lite/MainWindow.xaml.cs | 2 +- Lite/Services/LocalDataService.WaitStats.cs | 32 ++++++++------ Lite/Windows/SettingsWindow.xaml | 22 +++++++++- Lite/Windows/SettingsWindow.xaml.cs | 16 +++++++ 10 files changed, 142 insertions(+), 32 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 6358cfd5..275917f0 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -1065,7 +1065,7 @@ private async Task CheckAllServerAlertsAsync() var connectionString = server.GetConnectionString(_credentialService); var databaseService = new DatabaseService(connectionString); var connStatus = _serverManager.GetConnectionStatus(server.Id); - var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier); + var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits); if (health.IsOnline) { diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 01e5dd70..17e3ecb8 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -85,6 +85,11 @@ public class UserPreferences public int PoisonWaitThresholdMs { get; set; } = 500; // Alert when avg ms per wait > X public bool NotifyOnLongRunningQueries { get; set; } = true; public int LongRunningQueryThresholdMinutes { get; set; } = 30; // Alert when query runs > X minutes + public int LongRunningQueryMaxResults { get; set; } = 5; // Max number of long-running queries returned per check + public bool LongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + public bool LongRunningQueryExcludeWaitFor { get; set; } = true; + public bool LongRunningQueryExcludeBackups { get; set; } = true; + public bool LongRunningQueryExcludeMiscWaits { get; set; } = true; public bool NotifyOnTempDbSpace { get; set; } = true; public int TempDbSpaceThresholdPercent { get; set; } = 80; // Alert when TempDB used > X% public bool NotifyOnLongRunningJobs { get; set; } = true; diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index 4e711ef4..48435a15 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -121,7 +121,15 @@ public async Task RefreshNocHealthStatusAsync(ServerHealthStatus status, int eng /// Lightweight alert-only health check. Runs 3 queries instead of 9. /// Used by MainWindow's independent alert timer. /// - public async Task GetAlertHealthAsync(int engineEdition = 0, int longRunningQueryThresholdMinutes = 30, int longRunningJobMultiplier = 3) + public async Task GetAlertHealthAsync( + int engineEdition = 0, + int longRunningQueryThresholdMinutes = 30, + int longRunningJobMultiplier = 3, + int longRunningQueryMaxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) { var result = new AlertHealthResult(); @@ -136,7 +144,7 @@ public async Task GetAlertHealthAsync(int engineEdition = 0, var blockingTask = GetBlockingValuesAsync(connection); var deadlockTask = GetDeadlockCountAsync(connection); var poisonWaitTask = GetPoisonWaitDeltasAsync(connection); - var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes); + var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits); var tempDbTask = GetTempDbSpaceAsync(connection); var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier); @@ -603,24 +611,29 @@ ORDER BY collection_time DESC /// Gets currently running queries that exceed the duration threshold. /// Uses live DMV data (sys.dm_exec_requests) for immediate detection. /// - private async Task> GetLongRunningQueriesAsync(SqlConnection connection, int thresholdMinutes) + private async Task> GetLongRunningQueriesAsync( + SqlConnection connection, + int thresholdMinutes, + int maxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) { + maxResults = Math.Clamp(maxResults, 1, 1000); - // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable. - string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'"; - - // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem. - string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')"; - - // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context. - string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')"; - - // Exclude miscellaneous wait type that aren't typically actionable - string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')"; + string spServerDiagnosticsFilter = excludeSpServerDiagnostics + ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; + string waitForFilter = excludeWaitFor + ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; + string backupsFilter = excludeBackups + ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : ""; + string miscWaitsFilter = excludeMiscWaits + ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : ""; string query = @$"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - SELECT TOP(5) + SELECT TOP(@maxResults) r.session_id, DB_NAME(r.database_id) AS database_name, SUBSTRING(t.text, 1, 300) AS query_text, @@ -651,6 +664,7 @@ ORDER BY r.total_elapsed_time DESC using var cmd = new SqlCommand(query, connection); cmd.CommandTimeout = 10; cmd.Parameters.Add(new SqlParameter("@thresholdMs", SqlDbType.BigInt) { Value = (long)thresholdMinutes * 60 * 1000 }); + cmd.Parameters.Add(new SqlParameter("@maxResults", SqlDbType.Int) { Value = maxResults}); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index f955eff0..5930e733 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -217,8 +217,28 @@ Margin="8,0,8,0" VerticalAlignment="Center" TextAlignment="Center"/> - + + + + + + + + + + = 1 && lrqMaxResults <= int.MaxValue) + { + prefs.LongRunningQueryMaxResults = lrqMaxResults; + } + else + { + validationErrors.Add($"Long-running query max results must be between 1 and {int.MaxValue}"); + } + + prefs.LongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; + prefs.NotifyOnTempDbSpace = NotifyOnTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(TempDbSpaceThresholdTextBox.Text, out int tempDbThreshold) && tempDbThreshold > 0 && tempDbThreshold <= 100) prefs.TempDbSpaceThresholdPercent = tempDbThreshold; diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index e775a91e..bf9c405d 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -64,6 +64,11 @@ public partial class App : Application public static int AlertPoisonWaitThresholdMs { get; set; } = 500; public static bool AlertLongRunningQueryEnabled { get; set; } = true; public static int AlertLongRunningQueryThresholdMinutes { get; set; } = 30; + public static int AlertLongRunningQueryMaxResults { get; set; } = 5; + public static bool AlertLongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + public static bool AlertLongRunningQueryExcludeWaitFor { get; set; } = true; + public static bool AlertLongRunningQueryExcludeBackups { get; set; } = true; + public static bool AlertLongRunningQueryExcludeMiscWaits { get; set; } = true; public static bool AlertTempDbSpaceEnabled { get; set; } = true; public static int AlertTempDbSpaceThresholdPercent { get; set; } = 80; public static bool AlertLongRunningJobEnabled { get; set; } = true; @@ -242,6 +247,11 @@ public static void LoadAlertSettings() if (root.TryGetProperty("alert_poison_wait_threshold_ms", out v)) AlertPoisonWaitThresholdMs = v.GetInt32(); if (root.TryGetProperty("alert_long_running_query_enabled", out v)) AlertLongRunningQueryEnabled = v.GetBoolean(); if (root.TryGetProperty("alert_long_running_query_threshold_minutes", out v)) AlertLongRunningQueryThresholdMinutes = v.GetInt32(); + if (root.TryGetProperty("alert_long_running_query_max_results", out v)) AlertLongRunningQueryMaxResults = (int)Math.Clamp(v.GetInt64(), 1, 1000); + if (root.TryGetProperty("alert_long_running_query_exclude_sp_server_diagnostics", out v)) AlertLongRunningQueryExcludeSpServerDiagnostics = v.GetBoolean(); + if (root.TryGetProperty("alert_long_running_query_exclude_waitfor", out v)) AlertLongRunningQueryExcludeWaitFor = v.GetBoolean(); + if (root.TryGetProperty("alert_long_running_query_exclude_backups", out v)) AlertLongRunningQueryExcludeBackups = v.GetBoolean(); + if (root.TryGetProperty("alert_long_running_query_exclude_misc_waits", out v)) AlertLongRunningQueryExcludeMiscWaits = v.GetBoolean(); if (root.TryGetProperty("alert_tempdb_space_enabled", out v)) AlertTempDbSpaceEnabled = v.GetBoolean(); if (root.TryGetProperty("alert_tempdb_space_threshold_percent", out v)) AlertTempDbSpaceThresholdPercent = v.GetInt32(); if (root.TryGetProperty("alert_long_running_job_enabled", out v)) AlertLongRunningJobEnabled = v.GetBoolean(); diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 2f537d2f..7e16095a 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1122,7 +1122,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes); + var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits); if (longRunning.Count > 0) { diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index e18b4f5e..1b2c4b47 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -195,24 +195,29 @@ ORDER BY collection_time DESC /// Gets long-running queries from the latest collection snapshot. /// Returns sessions whose total elapsed time exceeds the given threshold. /// - public async Task> GetLongRunningQueriesAsync(int serverId, int thresholdMinutes) + public async Task> GetLongRunningQueriesAsync( + int serverId, + int thresholdMinutes, + int maxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var thresholdMs = (long)thresholdMinutes * 60 * 1000; - // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable. - string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'"; - - // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem. - string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')"; - - // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context. - string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')"; - - // Exclude miscellaneous wait type that aren't typically actionable - string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')"; + string spServerDiagnosticsFilter = excludeSpServerDiagnostics + ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; + string waitForFilter = excludeWaitFor + ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; + string backupsFilter = excludeBackups + ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : ""; + string miscWaitsFilter = excludeMiscWaits + ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : ""; + maxResults = Math.Clamp(maxResults, 1, 1000); command.CommandText = @$" SELECT @@ -235,10 +240,11 @@ AND r.session_id > 50 {miscWaitsFilter} AND r.total_elapsed_time_ms >= $2 ORDER BY r.total_elapsed_time_ms DESC - LIMIT 5;"; + LIMIT $3;"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = thresholdMs }); + command.Parameters.Add(new DuckDBParameter { Value = maxResults }); var items = new List(); using var reader = await command.ExecuteReaderAsync(); diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 4412f023..098555b7 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -181,9 +181,29 @@ - + + + + + + + + + diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 8876420d..350458a5 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -436,6 +436,11 @@ private void LoadAlertSettings() AlertPoisonWaitThresholdBox.Text = App.AlertPoisonWaitThresholdMs.ToString(); AlertLongRunningQueryCheckBox.IsChecked = App.AlertLongRunningQueryEnabled; AlertLongRunningQueryThresholdBox.Text = App.AlertLongRunningQueryThresholdMinutes.ToString(); + AlertLongRunningQueryMaxResultsBox.Text = App.AlertLongRunningQueryMaxResults.ToString(); + LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeSpServerDiagnostics; + LrqExcludeWaitForCheckBox.IsChecked = App.AlertLongRunningQueryExcludeWaitFor; + LrqExcludeBackupsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeBackups; + LrqExcludeMiscWaitsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeMiscWaits; AlertTempDbSpaceCheckBox.IsChecked = App.AlertTempDbSpaceEnabled; AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString(); AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled; @@ -463,6 +468,12 @@ private void SaveAlertSettings() App.AlertLongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true; if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0) App.AlertLongRunningQueryThresholdMinutes = lrq; + if (int.TryParse(AlertLongRunningQueryMaxResultsBox.Text, out var lrqMax) && lrqMax >= 1 && lrqMax <= int.MaxValue) + App.AlertLongRunningQueryMaxResults = lrqMax; + App.AlertLongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; App.AlertTempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb > 0 && tempDb <= 100) App.AlertTempDbSpaceThresholdPercent = tempDb; @@ -497,6 +508,11 @@ private void SaveAlertSettings() root["alert_poison_wait_threshold_ms"] = App.AlertPoisonWaitThresholdMs; root["alert_long_running_query_enabled"] = App.AlertLongRunningQueryEnabled; root["alert_long_running_query_threshold_minutes"] = App.AlertLongRunningQueryThresholdMinutes; + root["alert_long_running_query_max_results"] = App.AlertLongRunningQueryMaxResults; + root["alert_long_running_query_exclude_sp_server_diagnostics"] = App.AlertLongRunningQueryExcludeSpServerDiagnostics; + root["alert_long_running_query_exclude_waitfor"] = App.AlertLongRunningQueryExcludeWaitFor; + root["alert_long_running_query_exclude_backups"] = App.AlertLongRunningQueryExcludeBackups; + root["alert_long_running_query_exclude_misc_waits"] = App.AlertLongRunningQueryExcludeMiscWaits; root["alert_tempdb_space_enabled"] = App.AlertTempDbSpaceEnabled; root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent; root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled; From d3b79524a1a854374ccca4c3bdceecc55f46627c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:55:48 -0500 Subject: [PATCH 05/20] Sync plan viewer fixes from plan-b: spool labels, unmatched index detail (#416) - Spool operators now show Eager/Lazy prefix (e.g., "Eager Index Spool" instead of just "Index Spool") by prepending from LogicalOp - PlanIconMapper entries added for all Eager/Lazy spool variants - UnmatchedIndexes warning now parses child Parameterization elements to show specific database.schema.table.index names Co-authored-by: Claude Opus 4.6 --- Dashboard/Services/PlanIconMapper.cs | 6 +++++ Dashboard/Services/ShowPlanParser.cs | 37 +++++++++++++++++++++++++++- Lite/Services/PlanIconMapper.cs | 6 +++++ Lite/Services/ShowPlanParser.cs | 37 +++++++++++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 659a9014..6ed411e3 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 37f367c0..048ead70 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; + // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp + // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" + if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Eager " + node.PhysicalOp; + } + else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Lazy " + node.PhysicalOp; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -1429,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + var unmatchedMsg = "Indexes could not be matched due to parameterization"; + var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); + if (unmatchedEl != null) + { + var unmatchedDetails = new List(); + foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) + { + var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var parts = new List(); + if (!string.IsNullOrEmpty(db)) parts.Add(db); + if (!string.IsNullOrEmpty(schema)) parts.Add(schema); + if (!string.IsNullOrEmpty(table)) parts.Add(table); + if (!string.IsNullOrEmpty(index)) parts.Add(index); + if (parts.Count > 0) + unmatchedDetails.Add(string.Join(".", parts)); + } + if (unmatchedDetails.Count > 0) + unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); + } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); } diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs index 7c542857..f187eda1 100644 --- a/Lite/Services/PlanIconMapper.cs +++ b/Lite/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index 14625899..ef7f805e 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; + // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp + // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" + if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Eager " + node.PhysicalOp; + } + else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Lazy " + node.PhysicalOp; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -1429,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + var unmatchedMsg = "Indexes could not be matched due to parameterization"; + var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); + if (unmatchedEl != null) + { + var unmatchedDetails = new List(); + foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) + { + var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var parts = new List(); + if (!string.IsNullOrEmpty(db)) parts.Add(db); + if (!string.IsNullOrEmpty(schema)) parts.Add(schema); + if (!string.IsNullOrEmpty(table)) parts.Add(table); + if (!string.IsNullOrEmpty(index)) parts.Add(index); + if (parts.Count > 0) + unmatchedDetails.Add(string.Join(".", parts)); + } + if (unmatchedDetails.Count > 0) + unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); + } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); } From e3a646c59a1bf066d41087dc5cc7795c393f7db9 Mon Sep 17 00:00:00 2001 From: Orestes Date: Wed, 4 Mar 2026 16:22:43 +0000 Subject: [PATCH 06/20] fixes many warnings, and pre-calculates the RegEx patterns at compile time instead of at runtime: warning CA1847: Use 'string.Contains(char)' instead of 'string.Contains(string)' when searching for a single character (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1847) warning CA1875: Use 'Regex.Count' instead of 'Regex.Matches(...).Count' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875) warning CA1863: Cache a 'CompositeFormat' for repeated use in this formatting operation (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1863) (#346) Co-authored-by: Orestes Zoupanos --- .../Converters/QueryTextCleanupConverter.cs | 7 +- Dashboard/Helpers/DateFilterHelper.cs | 7 +- Dashboard/Services/PlanAnalyzer.cs | 35 ++++---- Dashboard/Services/ReproScriptBuilder.cs | 7 +- Dashboard/TracePatternHistoryWindow.xaml.cs | 5 +- Installer/Program.cs | 9 +- InstallerGui/Services/InstallationService.cs | 9 +- Lite.Tests/Lite.Tests.csproj | 2 +- Lite/Services/PlanAnalyzer.cs | 13 +-- .../RemoteCollectorService.QuerySnapshots.cs | 84 ++++++++++--------- Lite/Services/ReproScriptBuilder.cs | 7 +- 11 files changed, 104 insertions(+), 81 deletions(-) diff --git a/Dashboard/Converters/QueryTextCleanupConverter.cs b/Dashboard/Converters/QueryTextCleanupConverter.cs index e1ae8481..764131d0 100644 --- a/Dashboard/Converters/QueryTextCleanupConverter.cs +++ b/Dashboard/Converters/QueryTextCleanupConverter.cs @@ -12,7 +12,7 @@ namespace PerformanceMonitorDashboard.Converters { - public class QueryTextCleanupConverter : IValueConverter + public partial class QueryTextCleanupConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { @@ -28,7 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn text = text.Replace("\t", " ", StringComparison.Ordinal); // Replace multiple spaces with single space - text = Regex.Replace(text, @"\s+", " "); + text = MultipleSpacesRegExp().Replace(text, " "); // Trim leading/trailing whitespace text = text.Trim(); @@ -40,5 +40,8 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu { throw new NotImplementedException(); } + + [GeneratedRegex(@"\s+")] + private static partial Regex MultipleSpacesRegExp(); } } diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/Dashboard/Helpers/DateFilterHelper.cs index b9b5e4f4..def0f459 100644 --- a/Dashboard/Helpers/DateFilterHelper.cs +++ b/Dashboard/Helpers/DateFilterHelper.cs @@ -11,7 +11,7 @@ namespace PerformanceMonitorDashboard.Helpers { - public static class DateFilterHelper + public static partial class DateFilterHelper { public static bool MatchesFilter(object? value, string? filterText) { @@ -148,7 +148,7 @@ private static bool TryConvertToDateTime(object value, out DateTime result) } // "last N hours/days/weeks" expressions - var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)"); + var lastMatch = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower); if (lastMatch.Success) { int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture); @@ -231,5 +231,8 @@ private static bool IsRelativeExpression(string expression) expression == "tomorrow" || Regex.IsMatch(expression, @"last\s+\d+\s+(hour|hours|day|days|week|weeks|month|months)"); } + + [GeneratedRegex(@"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)")] + private static partial Regex LastNHoursDaysWeeksMonthsRegExp(); } } diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index ec03090e..6734c901 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -10,24 +10,16 @@ namespace PerformanceMonitorDashboard.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = new( - @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); - private static readonly Regex LeadingWildcardLikeRegex = new( - @"\blike\b[^'""]*?N?'%", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); - private static readonly Regex CaseInPredicateRegex = new( - @"\bCASE\s+(WHEN\b|$)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = new( - @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); public static void Analyze(ParsedPlan plan) { @@ -686,7 +678,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) + node.ObjectName.StartsWith('@')) { node.Warnings.Add(new PlanWarning { @@ -890,7 +882,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -930,7 +922,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Matches(text).Count; + var refCount = refPattern.Count(text); if (refCount > 1) { @@ -1243,4 +1235,15 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex FunctionInPredicateRegExp(); + [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex LeadingWildcardLikeRegExp(); + [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex CaseInPredicateRegExp(); + [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex CteDefinitionRegExp(); + [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex IsNullCoalesceRegExp(); } diff --git a/Dashboard/Services/ReproScriptBuilder.cs b/Dashboard/Services/ReproScriptBuilder.cs index 3605db3e..f008c549 100644 --- a/Dashboard/Services/ReproScriptBuilder.cs +++ b/Dashboard/Services/ReproScriptBuilder.cs @@ -20,7 +20,7 @@ namespace PerformanceMonitorDashboard.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static class ReproScriptBuilder +public static partial class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -399,7 +399,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var matches = AtVariableRegExp().Matches(queryText); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -429,6 +429,9 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index 71ce17c3..fd1de4e8 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -59,7 +59,7 @@ public TracePatternHistoryWindow( _toDate = toDate; // Collapse newlines/tabs to spaces and truncate for a clean single-line header - var displayPattern = System.Text.RegularExpressions.Regex.Replace(queryPattern, @"\s+", " ").Trim(); + var displayPattern = MultipleSpacesRegExp().Replace(queryPattern, " ").Trim(); if (displayPattern.Length > 120) displayPattern = displayPattern.Substring(0, 120) + "..."; QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}"; @@ -406,6 +406,9 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) } } + [System.Text.RegularExpressions.GeneratedRegex(@"\s+")] + private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp(); + #endregion } } diff --git a/Installer/Program.cs b/Installer/Program.cs index d16303ae..69f2776d 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -19,14 +19,12 @@ namespace PerformanceMonitorInstaller { - class Program + partial class Program { /* Pre-compiled regex patterns for performance */ - private static readonly Regex GoBatchPattern = new Regex( - @"^\s*GO\s*(?:--[^\r\n]*)?\s*$", - RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase); + private static readonly Regex GoBatchPattern = GoBatchRegExp(); private static readonly Regex SqlFileNamePattern = new Regex( @"^\d{2}[a-z]?_.*\.sql$", @@ -1800,5 +1798,8 @@ Write file return reportPath; } + + [GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex GoBatchRegExp(); } } diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs index 25ccc85e..97a5f0e3 100644 --- a/InstallerGui/Services/InstallationService.cs +++ b/InstallerGui/Services/InstallationService.cs @@ -62,7 +62,7 @@ public class InstallationResult /// /// Service for installing the Performance Monitor database /// - public class InstallationService : IDisposable + public partial class InstallationService : IDisposable { private readonly HttpClient _httpClient; private bool _disposed; @@ -70,9 +70,7 @@ public class InstallationService : IDisposable /* Compiled regex patterns for better performance */ - private static readonly Regex SqlFilePattern = new( - @"^\d{2}[a-z]?_.*\.sql$", - RegexOptions.Compiled); + private static readonly Regex SqlFilePattern = SqlFileRegExp(); private static readonly Regex SqlCmdDirectivePattern = new( @"^:r\s+.*$", @@ -1429,5 +1427,8 @@ INSERT INTO PerformanceMonitor.config.installation_history /*Don't let history logging failure break the installation*/ } } + + [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$", RegexOptions.Compiled)] + private static partial Regex SqlFileRegExp(); } } diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 86268874..874fd31f 100644 --- a/Lite.Tests/Lite.Tests.csproj +++ b/Lite.Tests/Lite.Tests.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 8031874d..b3d56074 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -10,11 +10,9 @@ namespace PerformanceMonitorLite.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = new( - @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); private static readonly Regex LeadingWildcardLikeRegex = new( @"\blike\b[^'""]*?N?'%", @@ -686,7 +684,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) + node.ObjectName.StartsWith('@')) { node.Warnings.Add(new PlanWarning { @@ -930,7 +928,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Matches(text).Count; + var refCount = refPattern.Count(text); if (refCount > 1) { @@ -1243,4 +1241,7 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex FunctionInPredicateRegExp(); } diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index b85500c1..93f5e363 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -8,6 +8,7 @@ using System; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; using DuckDB.NET.Data; @@ -19,7 +20,8 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - private const string QuerySnapshotsBase = @" + private const string QuerySnapshotsBase = """ + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET LOCK_TIMEOUT 1000; @@ -80,7 +82,9 @@ WHERE der.session_id <> @@SPID AND dest.text IS NOT NULL AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC -OPTION(MAXDOP 1, RECOMPILE);"; +OPTION(MAXDOP 1, RECOMPILE); +"""; + private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); /// /// Builds the query snapshots SQL with or without live query plan support. @@ -89,8 +93,8 @@ AND dest.text IS NOT NULL internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan) { return supportsLiveQueryPlan - ? string.Format(QuerySnapshotsBase, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") - : string.Format(QuerySnapshotsBase, "live_query_plan = CONVERT(xml, NULL),", ""); + ? string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") + : string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = CONVERT(xml, NULL),", ""); } /// @@ -126,44 +130,42 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc { await duckConnection.OpenAsync(cancellationToken); - using (var appender = duckConnection.CreateAppender("query_snapshots")) + using var appender = duckConnection.CreateAppender("query_snapshots"); + while (await reader.ReadAsync(cancellationToken)) { - while (await reader.ReadAsync(cancellationToken)) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ - .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ - .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ - .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ - .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ - .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ - .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ - .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ - .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ - .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ - .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ - .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ - .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ - .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ - .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ - .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ - .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ - .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ - .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ - .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ - .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ - .EndRow(); - - rowsCollected++; - } + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ + .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ + .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ + .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ + .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ + .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ + .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ + .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ + .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ + .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ + .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ + .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ + .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ + .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ + .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ + .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ + .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ + .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ + .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ + .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ + .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ + .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ + .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ + .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ + .EndRow(); + + rowsCollected++; } } diff --git a/Lite/Services/ReproScriptBuilder.cs b/Lite/Services/ReproScriptBuilder.cs index 6a9a35a9..a1fef754 100644 --- a/Lite/Services/ReproScriptBuilder.cs +++ b/Lite/Services/ReproScriptBuilder.cs @@ -19,7 +19,7 @@ namespace PerformanceMonitorLite.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static class ReproScriptBuilder +public static partial class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -397,7 +397,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var matches = AtVariableRegExp().Matches(queryText); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -427,6 +427,9 @@ private static List FindUnresolvedVariables(string queryText, List From 5fb6d47da16b0fb969092cf19dd884e6a961c1fe Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:32:28 -0500 Subject: [PATCH 07/20] Complete GeneratedRegex conversion and remove Compiled flags (#420) PR #346 left Lite PlanAnalyzer with 3 old-style regex fields and inline Regex.IsMatch calls, and added unnecessary RegexOptions.Compiled to all GeneratedRegex attributes (source generator always compiles, flag is ignored). - Convert remaining Lite regex fields to use GeneratedRegex source generator - Convert all inline Regex.IsMatch calls to GeneratedRegex in both apps - Remove RegexOptions.Compiled from all [GeneratedRegex] attributes - Keep both PlanAnalyzer copies in sync with identical regex methods Co-authored-by: Claude Opus 4.6 --- Dashboard/Services/PlanAnalyzer.cs | 16 ++++++---- Installer/Program.cs | 2 +- InstallerGui/Services/InstallationService.cs | 2 +- Lite/Services/PlanAnalyzer.cs | 32 ++++++++++++-------- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 6734c901..69be6509 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -178,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -785,7 +785,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + !NotInRegExp().IsMatch(stmt.StatementText)) return false; // Walk up the tree checking ancestors and their children @@ -1236,14 +1236,18 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } - [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] private static partial Regex FunctionInPredicateRegExp(); - [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] private static partial Regex LeadingWildcardLikeRegExp(); - [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] private static partial Regex CaseInPredicateRegExp(); - [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] private static partial Regex CteDefinitionRegExp(); [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] private static partial Regex IsNullCoalesceRegExp(); + [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] + private static partial Regex OptimizeForUnknownRegExp(); + [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] + private static partial Regex NotInRegExp(); } diff --git a/Installer/Program.cs b/Installer/Program.cs index 69f2776d..dc0b35c8 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -1799,7 +1799,7 @@ Write file return reportPath; } - [GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)] + [GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex GoBatchRegExp(); } } diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs index 97a5f0e3..e7af2a4b 100644 --- a/InstallerGui/Services/InstallationService.cs +++ b/InstallerGui/Services/InstallationService.cs @@ -1428,7 +1428,7 @@ INSERT INTO PerformanceMonitor.config.installation_history } } - [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$", RegexOptions.Compiled)] + [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")] private static partial Regex SqlFileRegExp(); } } diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index b3d56074..9ba4fe6f 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -14,18 +14,12 @@ public static partial class PlanAnalyzer { private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); - private static readonly Regex LeadingWildcardLikeRegex = new( - @"\blike\b[^'""]*?N?'%", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); - private static readonly Regex CaseInPredicateRegex = new( - @"\bCASE\s+(WHEN\b|$)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = new( - @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); public static void Analyze(ParsedPlan plan) { @@ -184,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -791,7 +785,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + !NotInRegExp().IsMatch(stmt.StatementText)) return false; // Walk up the tree checking ancestors and their children @@ -888,7 +882,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -1242,6 +1236,18 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } - [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] private static partial Regex FunctionInPredicateRegExp(); + [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] + private static partial Regex LeadingWildcardLikeRegExp(); + [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] + private static partial Regex CaseInPredicateRegExp(); + [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex CteDefinitionRegExp(); + [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex IsNullCoalesceRegExp(); + [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] + private static partial Regex OptimizeForUnknownRegExp(); + [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] + private static partial Regex NotInRegExp(); } From 6d4478a1c46eb9c98a1e99e1bae148e6fc274277 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:00:23 -0500 Subject: [PATCH 08/20] Add permissions section to README with least-privilege setup (#421) Documents required permissions for all platforms: on-prem Full/Lite, Azure SQL Database (contained user), Azure SQL MI, and AWS RDS. Includes copy-paste SQL scripts. Updates comparison table and troubleshooting to link to the new section. Prompted by user question in issue #418. Co-authored-by: Claude Opus 4.6 --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b383ad9..fa8c4e32 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ |---|---|---| | **What it does** | Installs a `PerformanceMonitor` database with 30 T-SQL collectors running via SQL Agent. Separate dashboard app connects to view everything. | Single desktop app that monitors remotely. Stores data locally in DuckDB + Parquet. Nothing touches your server. | | **Best for** | Production 24/7 monitoring, long-term baselining | Quick triage, Azure SQL DB, locked-down servers, consultants, firefighting | -| **Requires** | sysadmin + SQL Agent running | `VIEW SERVER STATE` (that's it) | +| **Requires** | SQL Agent running ([see permissions](#permissions)) | `VIEW SERVER STATE` ([see permissions](#permissions)) | | **Get started** | Run the installer, open the dashboard | Download, run, add a server, done | Both editions include real-time alerts (system tray + email), charts and graphs, dark and light themes, CSV export, and a built-in MCP server for AI-powered analysis with tools like Claude. @@ -461,7 +461,76 @@ Common issues: 1. **No data after connecting** — Wait for the first collection cycle (1–5 minutes). Check logs for connection errors. 2. **Query Store tab empty** — Query Store must be enabled on the target database (`ALTER DATABASE [YourDB] SET QUERY_STORE = ON`). 3. **Blocked process reports empty** — Both editions attempt to auto-configure the blocked process threshold to 5 seconds via `sp_configure`. On **AWS RDS**, `sp_configure` is not available — you must set `blocked process threshold (s)` through an RDS Parameter Group (see "AWS RDS Parameter Group Configuration" above). On **Azure SQL Database**, the threshold is fixed at 20 seconds and cannot be changed. If you still see no data on other platforms, verify the login has `ALTER SETTINGS` permission. -4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has `VIEW SERVER STATE`. +4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has the required [permissions](#permissions). For Azure SQL Database, use a contained database user with `VIEW DATABASE STATE`. + +--- + +## Permissions + +### Full Edition (On-Premises) + +The installer needs `sysadmin` to create the database, Agent jobs, and configure `sp_configure` settings. After installation, the collection jobs can run under a **least-privilege login** with these grants: + +```sql +USE [master]; +CREATE LOGIN [SQLServerPerfMon] WITH PASSWORD = N'YourStrongPassword'; +GRANT VIEW SERVER STATE TO [SQLServerPerfMon]; + +USE [PerformanceMonitor]; +CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; +ALTER ROLE [db_owner] ADD MEMBER [SQLServerPerfMon]; + +USE [msdb]; +CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; +ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [SQLServerPerfMon]; +``` + +| Grant | Why | +|---|---| +| `VIEW SERVER STATE` | All DMV access (wait stats, query stats, memory, CPU, file I/O, etc.) | +| `db_owner` on PerformanceMonitor | Collectors insert data, create/alter tables, execute procedures. Scoped to just this database — not sysadmin. | +| `SQLAgentReaderRole` on msdb | Read `sysjobs`, `sysjobactivity`, `sysjobhistory` for the running jobs collector | + +**Optional** (gracefully skipped if missing): +- `ALTER SETTINGS` — installer sets `blocked process threshold` via `sp_configure`. Skipped with a warning if unavailable. +- `ALTER TRACE` — default trace collector. Skipped if denied. +- `DBCC TRACESTATUS` — server config collector skips trace flag detection if denied. + +Change the SQL Agent job owner to the new login after installation if you want to run under least privilege end-to-end. + +### Lite Edition (On-Premises) + +Nothing is installed on the target server. The login only needs: + +```sql +USE [master]; +GRANT VIEW SERVER STATE TO [YourLogin]; + +-- Optional: for SQL Agent job monitoring +USE [msdb]; +CREATE USER [YourLogin] FOR LOGIN [YourLogin]; +ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [YourLogin]; +``` + +### Azure SQL Database (Lite Only) + +Azure SQL Database doesn't support server-level logins. Create a **contained database user** directly on the target database: + +```sql +-- Connect to your target database (not master) +CREATE USER [SQLServerPerfMon] WITH PASSWORD = 'YourStrongPassword'; +GRANT VIEW DATABASE STATE TO [SQLServerPerfMon]; +``` + +When connecting in Lite, specify the database name in the connection. SQL Agent and msdb are not available on Azure SQL Database — those collectors are skipped automatically. + +### Azure SQL Managed Instance + +Works like on-premises. Use server-level logins with `VIEW SERVER STATE`. SQL Agent is available. + +### AWS RDS for SQL Server + +Use the RDS master user for installation. The master user has the necessary permissions. For ongoing collection, `VIEW SERVER STATE` and msdb access work the same as on-premises, but `sp_configure` is not available (use RDS Parameter Groups instead — see above). --- From 3daf1b0606129ae0c962e46525539064f2ee0a56 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:33:21 -0500 Subject: [PATCH 09/20] Replace custom TrayToolTip with plain ToolTipText to fix crash The custom visual TrayToolTip (Border + TextBlock) triggers a known race condition in Hardcodet.NotifyIcon.Wpf where Popup.CreateWindow throws "The root Visual of a VisualTarget cannot have a parent." This crash poisons the ReaderWriterLockSlim on the UI thread, breaking Overview queries permanently until restart. Plain string ToolTipText avoids the WPF Popup infrastructure entirely. Fixes #422 Co-Authored-By: Claude Opus 4.6 --- Dashboard/Services/NotificationService.cs | 27 +++---------------- Lite/Services/SystemTrayService.cs | 33 +++++------------------ 2 files changed, 10 insertions(+), 50 deletions(-) diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs index 5e30b333..1a2e1be1 100644 --- a/Dashboard/Services/NotificationService.cs +++ b/Dashboard/Services/NotificationService.cs @@ -42,29 +42,10 @@ public void Initialize() _trayIcon = new TaskbarIcon(); - bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; - - /* Custom tooltip styled to match current theme */ - _trayIcon.TrayToolTip = new Border - { - Background = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#FFFFFF") - : (Color)ColorConverter.ConvertFromString("#22252b")), - BorderBrush = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#DEE2E6") - : (Color)ColorConverter.ConvertFromString("#33363e")), - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 8, 10, 8), - CornerRadius = new CornerRadius(4), - Child = new TextBlock - { - Text = "SQL Server Performance Monitor", - Foreground = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#1A1D23") - : (Color)ColorConverter.ConvertFromString("#E4E6EB")), - FontSize = 12 - } - }; + /* Use plain string tooltip to avoid Hardcodet TrayToolTip crash (issue #422). + Custom visual tooltips trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." */ + _trayIcon.ToolTipText = "SQL Server Performance Monitor"; // Load icon from embedded resource using pack URI try diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs index a54bfc19..77d8b9b9 100644 --- a/Lite/Services/SystemTrayService.cs +++ b/Lite/Services/SystemTrayService.cs @@ -25,7 +25,6 @@ public class SystemTrayService : IDisposable private readonly CollectionBackgroundService? _backgroundService; private bool _disposed; private MenuItem? _pauseResumeItem; - private TextBlock? _tooltipText; public SystemTrayService(Window mainWindow, CollectionBackgroundService? backgroundService = null) { @@ -43,30 +42,10 @@ public void Initialize() _trayIcon = new TaskbarIcon(); - bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; - - /* Custom tooltip styled to match current theme */ - _tooltipText = new TextBlock - { - Text = "Performance Monitor Lite", - Foreground = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#1A1D23") - : (Color)ColorConverter.ConvertFromString("#E4E6EB")), - FontSize = 12 - }; - _trayIcon.TrayToolTip = new Border - { - Background = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#FFFFFF") - : (Color)ColorConverter.ConvertFromString("#22252b")), - BorderBrush = new SolidColorBrush(HasLightBackground - ? (Color)ColorConverter.ConvertFromString("#DEE2E6") - : (Color)ColorConverter.ConvertFromString("#33363e")), - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 8, 10, 8), - CornerRadius = new CornerRadius(4), - Child = _tooltipText - }; + /* Use plain string tooltip to avoid Hardcodet TrayToolTip crash (issue #422). + Custom visual tooltips trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." */ + _trayIcon.ToolTipText = "Performance Monitor Lite"; /* Load icon */ try @@ -135,9 +114,9 @@ private void ToggleCollection() _pauseResumeItem.Icon = new TextBlock { Text = _backgroundService.IsPaused ? "▶" : "⏸", Background = Brushes.Transparent }; } - if (_tooltipText != null) + if (_trayIcon != null) { - _tooltipText.Text = _backgroundService.IsPaused + _trayIcon.ToolTipText = _backgroundService.IsPaused ? "Performance Monitor Lite (Paused)" : "Performance Monitor Lite"; } From ebe622667b746384f88ccecf83024aca2c6de58e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:34:58 -0500 Subject: [PATCH 10/20] Add resilience to DuckDB read lock acquisition If an unhandled exception leaks a read lock on the UI thread, subsequent EnterReadLock() calls throw LockRecursionException permanently. Catch this and return a no-op disposable instead, since the thread already has read protection in place. This makes the lock self-healing: even if a crash leaks the lock, subsequent operations recover gracefully rather than failing forever. Fixes #423 Co-Authored-By: Claude Opus 4.6 --- Lite/Database/DuckDbInitializer.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 953401fe..78b6a37f 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -27,10 +27,22 @@ public class DuckDbInitializer /// /// Acquires a read lock on the database. Multiple readers can hold this concurrently. /// Dispose the returned object to release the lock. + /// If the current thread already owns a read lock (e.g., leaked by an unhandled exception), + /// returns a no-op disposable to allow the operation to proceed. /// public IDisposable AcquireReadLock() { - s_dbLock.EnterReadLock(); + try + { + s_dbLock.EnterReadLock(); + } + catch (LockRecursionException) + { + /* The current thread already owns a read lock — likely leaked by an unhandled + exception that prevented Dispose(). Since we're already protected by a read lock, + return a no-op disposable so the caller can proceed normally. */ + return NoOpDisposable.Instance; + } return new LockReleaser(s_dbLock, write: false); } @@ -44,6 +56,12 @@ public IDisposable AcquireWriteLock() return new LockReleaser(s_dbLock, write: true); } + private sealed class NoOpDisposable : IDisposable + { + public static readonly NoOpDisposable Instance = new(); + public void Dispose() { } + } + private sealed class LockReleaser : IDisposable { private readonly ReaderWriterLockSlim _lock; From 562269f61bf91b8813a11c552bc003dca9a5392a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:40:19 -0500 Subject: [PATCH 11/20] Restore custom TrayToolTip and silently handle Hardcodet crash (issue #422) Reverts the ToolTipText approach which caused context menu positioning and theme regressions. Instead, keeps the original custom TrayToolTip for proper dark theme styling and popup anchoring, and adds IsTrayToolTipCrash() detection in both apps' DispatcherUnhandledException handlers to silently swallow the rare Hardcodet VisualTarget race condition without showing error dialogs. Co-Authored-By: Claude Opus 4.6 --- Dashboard/App.xaml.cs | 20 +++++++++++++ Dashboard/Services/NotificationService.cs | 30 ++++++++++++++++--- Lite/App.xaml.cs | 20 +++++++++++++ Lite/Services/SystemTrayService.cs | 36 +++++++++++++++++++---- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index f920d823..c87d0743 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -95,6 +95,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { + /* Silently swallow Hardcodet TrayToolTip race condition (issue #422). + The crash occurs in Popup.CreateWindow when showing the custom visual tooltip + and is harmless — the tooltip simply doesn't show that one time. */ + if (IsTrayToolTipCrash(e.Exception)) + { + Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422)"); + e.Handled = true; + return; + } + Logger.Error("Unhandled Dispatcher Exception", e.Exception); MessageBox.Show( @@ -114,6 +124,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv e.SetObserved(); // Prevent process termination } + /// + /// Detects the Hardcodet TrayToolTip race condition crash (issue #422). + /// + private static bool IsTrayToolTipCrash(Exception ex) + { + return ex is ArgumentException + && ex.Message.Contains("VisualTarget") + && ex.StackTrace?.Contains("TaskbarIcon") == true; + } + private void CreateCrashDump(Exception? exception) { try diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs index 1a2e1be1..e174f311 100644 --- a/Dashboard/Services/NotificationService.cs +++ b/Dashboard/Services/NotificationService.cs @@ -42,10 +42,32 @@ public void Initialize() _trayIcon = new TaskbarIcon(); - /* Use plain string tooltip to avoid Hardcodet TrayToolTip crash (issue #422). - Custom visual tooltips trigger a race condition in Popup.CreateWindow - that throws "The root Visual of a VisualTarget cannot have a parent." */ - _trayIcon.ToolTipText = "SQL Server Performance Monitor"; + bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; + + /* Custom tooltip styled to match current theme. + Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). + The DispatcherUnhandledException handler silently swallows this specific crash. */ + _trayIcon.TrayToolTip = new Border + { + Background = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#FFFFFF") + : (Color)ColorConverter.ConvertFromString("#22252b")), + BorderBrush = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#DEE2E6") + : (Color)ColorConverter.ConvertFromString("#33363e")), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 8, 10, 8), + CornerRadius = new CornerRadius(4), + Child = new TextBlock + { + Text = "SQL Server Performance Monitor", + Foreground = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#1A1D23") + : (Color)ColorConverter.ConvertFromString("#E4E6EB")), + FontSize = 12 + } + }; // Load icon from embedded resource using pack URI try diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index bf9c405d..8cc53c40 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -324,6 +324,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { + /* Silently swallow Hardcodet TrayToolTip race condition (issue #422). + The crash occurs in Popup.CreateWindow when showing the custom visual tooltip + and is harmless — the tooltip simply doesn't show that one time. */ + if (IsTrayToolTipCrash(e.Exception)) + { + AppLogger.Warn("Dispatcher", "Suppressed Hardcodet TrayToolTip crash (issue #422)"); + e.Handled = true; + return; + } + AppLogger.Error("Dispatcher", "Unhandled exception", e.Exception); AppLogger.Flush(); @@ -344,6 +354,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv e.SetObserved(); /* Prevent process termination */ } + /// + /// Detects the Hardcodet TrayToolTip race condition crash (issue #422). + /// + private static bool IsTrayToolTipCrash(Exception ex) + { + return ex is System.ArgumentException + && ex.Message.Contains("VisualTarget") + && ex.StackTrace?.Contains("TaskbarIcon") == true; + } + private static string FormatExceptionDetails(Exception? ex) { if (ex == null) return "Unknown error"; diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs index 77d8b9b9..8c1968b5 100644 --- a/Lite/Services/SystemTrayService.cs +++ b/Lite/Services/SystemTrayService.cs @@ -25,6 +25,7 @@ public class SystemTrayService : IDisposable private readonly CollectionBackgroundService? _backgroundService; private bool _disposed; private MenuItem? _pauseResumeItem; + private TextBlock? _tooltipText; public SystemTrayService(Window mainWindow, CollectionBackgroundService? backgroundService = null) { @@ -42,10 +43,33 @@ public void Initialize() _trayIcon = new TaskbarIcon(); - /* Use plain string tooltip to avoid Hardcodet TrayToolTip crash (issue #422). - Custom visual tooltips trigger a race condition in Popup.CreateWindow - that throws "The root Visual of a VisualTarget cannot have a parent." */ - _trayIcon.ToolTipText = "Performance Monitor Lite"; + bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; + + /* Custom tooltip styled to match current theme. + Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). + The DispatcherUnhandledException handler silently swallows this specific crash. */ + _tooltipText = new TextBlock + { + Text = "Performance Monitor Lite", + Foreground = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#1A1D23") + : (Color)ColorConverter.ConvertFromString("#E4E6EB")), + FontSize = 12 + }; + _trayIcon.TrayToolTip = new Border + { + Background = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#FFFFFF") + : (Color)ColorConverter.ConvertFromString("#22252b")), + BorderBrush = new SolidColorBrush(HasLightBackground + ? (Color)ColorConverter.ConvertFromString("#DEE2E6") + : (Color)ColorConverter.ConvertFromString("#33363e")), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 8, 10, 8), + CornerRadius = new CornerRadius(4), + Child = _tooltipText + }; /* Load icon */ try @@ -114,9 +138,9 @@ private void ToggleCollection() _pauseResumeItem.Icon = new TextBlock { Text = _backgroundService.IsPaused ? "▶" : "⏸", Background = Brushes.Transparent }; } - if (_trayIcon != null) + if (_tooltipText != null) { - _trayIcon.ToolTipText = _backgroundService.IsPaused + _tooltipText.Text = _backgroundService.IsPaused ? "Performance Monitor Lite (Paused)" : "Performance Monitor Lite"; } From 7544ebc7f814376aeb435e99b4271e252d231094 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:39:18 -0500 Subject: [PATCH 12/20] Fix incorrect table name in Data Retention section README referenced config.retention_settings which doesn't exist. Retention is configured via the retention_days column in config.collection_schedule. Same bug reported in #223 but the fix never actually landed. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa8c4e32..7baad826 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ ORDER BY collection_time DESC; ### Data Retention -Default: 30 days (configurable per table in `config.retention_settings`). +Default: 30 days (configurable per collector via the `retention_days` column in `config.collection_schedule`). Storage estimates: 5–10 GB per week, 20–40 GB per month. From d84496af79c89b16e0ad807d9221be444f7054d0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:57:12 -0500 Subject: [PATCH 13/20] Fix RID Lookup analyzer rule to match new PhysicalOp label (#429) The Key Lookup parser fix (PR #413) changes PhysicalOp from "RID Lookup" to "RID Lookup (Heap)". The analyzer rule used exact equality which no longer matched. Changed to StartsWith. Co-authored-by: Claude Opus 4.6 --- Dashboard/Services/PlanAnalyzer.cs | 2 +- Lite/Services/PlanAnalyzer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 69be6509..8effd575 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -459,7 +459,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp == "RID Lookup") + if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 9ba4fe6f..6ac33d40 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -459,7 +459,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp == "RID Lookup") + if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) From 6e3fce31a13a7148a31ca32dde18574b93710e15 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:43:18 -0500 Subject: [PATCH 14/20] Add uninstall option to CLI and GUI installers (#431) Adds complete uninstall capability that removes all server-level objects: - 3 SQL Agent jobs (Collection, Data Retention, Hung Job Monitor) - 2 Extended Events sessions (BlockedProcess, Deadlock) - Server-side traces - PerformanceMonitor database Changes: - New install/00_uninstall.sql standalone script for SSMS users - CLI: --uninstall flag with interactive confirmation - GUI: Uninstall button (red, enabled when installed version detected) - Fix: existing clean install now removes all 3 jobs + XE sessions (previously missed Hung Job Monitor and both XE sessions) blocked process threshold (s) is intentionally NOT reset during uninstall as other monitoring tools may depend on it. Co-Authored-By: Claude Opus 4.6 --- Installer/Program.cs | 192 ++++++++++++--- InstallerGui/MainWindow.xaml | 9 + InstallerGui/MainWindow.xaml.cs | 95 +++++++ InstallerGui/Services/InstallationService.cs | 86 ++++++- install/00_uninstall.sql | 246 +++++++++++++++++++ 5 files changed, 589 insertions(+), 39 deletions(-) create mode 100644 install/00_uninstall.sql diff --git a/Installer/Program.cs b/Installer/Program.cs index dc0b35c8..a33fc096 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -21,6 +21,69 @@ namespace PerformanceMonitorInstaller { partial class Program { + /// + /// Complete uninstall SQL: stops traces, deletes all 3 Agent jobs, + /// drops both XE sessions, and drops the database. + /// + private const string UninstallSql = @" +/* +Remove SQL Agent jobs +*/ +USE msdb; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1; + PRINT 'Deleted job: PerformanceMonitor - Collection'; +END; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1; + PRINT 'Deleted job: PerformanceMonitor - Data Retention'; +END; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1; + PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor'; +END; + +/* +Drop Extended Events sessions +*/ +USE master; + +IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') +BEGIN + IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') + ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP; + DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER; + PRINT 'Dropped XE session: PerformanceMonitor_BlockedProcess'; +END; + +IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock') +BEGIN + IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock') + ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP; + DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER; + PRINT 'Dropped XE session: PerformanceMonitor_Deadlock'; +END; + +/* +Drop the database +*/ +IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') +BEGIN + ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE PerformanceMonitor; + PRINT 'PerformanceMonitor database dropped'; +END +ELSE +BEGIN + PRINT 'PerformanceMonitor database does not exist'; +END;"; + /* Pre-compiled regex patterns for performance */ @@ -53,6 +116,7 @@ private static class ExitCodes public const int PartialInstallation = 4; public const int VersionCheckFailed = 5; public const int SqlFilesNotFound = 6; + public const int UninstallFailed = 7; } static async Task Main(string[] args) @@ -92,6 +156,7 @@ static async Task Main(string[] args) Console.WriteLine("Options:"); Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(" --reinstall Drop existing database and perform clean install"); + Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions"); Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); Console.WriteLine(" --trust-cert Trust server certificate without validation"); @@ -107,11 +172,13 @@ static async Task Main(string[] args) Console.WriteLine(" 4 Partial installation (non-critical failures)"); Console.WriteLine(" 5 Version check failed"); Console.WriteLine(" 6 SQL files not found"); + Console.WriteLine(" 7 Uninstall failed"); return 0; } bool automatedMode = args.Length > 0; bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)); + bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)); bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)); bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)); @@ -132,6 +199,7 @@ static async Task Main(string[] args) /*Filter out option flags to get positional arguments*/ var filteredArgs = args .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)) @@ -308,6 +376,14 @@ Test connection and get SQL Server version return ExitCodes.ConnectionFailed; } + /* + Handle --uninstall mode (no SQL files needed) + */ + if (uninstallMode) + { + return await PerformUninstallAsync(builder.ConnectionString, automatedMode); + } + /* Find SQL files to execute (do this once before the installation loop) Search current directory and up to 5 parent directories @@ -466,41 +542,7 @@ Traces are server-level and persist after database drops /*Database or procedure doesn't exist - no traces to clean*/ } - string cleanupSql = @" -/* -Remove any existing Agent jobs -*/ -USE msdb; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection'; - PRINT 'Dropped PerformanceMonitor - Collection job'; -END; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention'; - PRINT 'Dropped PerformanceMonitor - Data Retention job'; -END; - -/* -Drop the database -*/ -USE master; - -IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') -BEGIN - ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE PerformanceMonitor; - PRINT 'PerformanceMonitor database dropped successfully'; -END -ELSE -BEGIN - PRINT 'PerformanceMonitor database does not exist'; -END"; - - using (var command = new SqlCommand(cleanupSql, connection)) + using (var command = new SqlCommand(UninstallSql, connection)) { command.CommandTimeout = ShortTimeoutSeconds; await command.ExecuteNonQueryAsync().ConfigureAwait(false); @@ -1086,6 +1128,86 @@ Log installation history to database Tracks version, duration, success/failure, and upgrade detection */ + /// + /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database. + /// + private static async Task PerformUninstallAsync(string connectionString, bool automatedMode) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("UNINSTALL MODE"); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + + if (!automatedMode) + { + Console.WriteLine("This will remove:"); + Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)"); + Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)"); + Console.WriteLine(" - Server-side traces"); + Console.WriteLine(" - PerformanceMonitor database and ALL collected data"); + Console.WriteLine(); + Console.Write("Are you sure you want to continue? (Y/N, default N): "); + string? confirm = Console.ReadLine(); + if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true) + { + Console.WriteLine("Uninstall cancelled."); + WaitForExit(); + return ExitCodes.Success; + } + } + + Console.WriteLine(); + Console.WriteLine("Uninstalling Performance Monitor..."); + + try + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + + /*Stop traces first (procedure lives in the database)*/ + try + { + using var traceCmd = new SqlCommand( + "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", + connection); + traceCmd.CommandTimeout = ShortTimeoutSeconds; + await traceCmd.ExecuteNonQueryAsync().ConfigureAwait(false); + Console.WriteLine("✓ Stopped server-side traces"); + } + catch (SqlException) + { + Console.WriteLine(" No traces to stop (database or procedure not found)"); + } + + /*Remove jobs, XE sessions, and database*/ + using var command = new SqlCommand(UninstallSql, connection); + command.CommandTimeout = ShortTimeoutSeconds; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + + Console.WriteLine(); + Console.WriteLine("✓ Uninstall completed successfully"); + Console.WriteLine(); + Console.WriteLine("Note: blocked process threshold (s) was NOT reset."); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine($"Uninstall failed: {ex.Message}"); + if (!automatedMode) + { + WaitForExit(); + } + return ExitCodes.UninstallFailed; + } + + if (!automatedMode) + { + WaitForExit(); + } + return ExitCodes.Success; + } + /* Get currently installed version from database Returns null if not installed (database or table doesn't exist) diff --git a/InstallerGui/MainWindow.xaml b/InstallerGui/MainWindow.xaml index 61b4046f..50e0da72 100644 --- a/InstallerGui/MainWindow.xaml +++ b/InstallerGui/MainWindow.xaml @@ -258,6 +258,15 @@ Margin="0,0,10,0" Click="ViewReport_Click" IsEnabled="False"/> + - public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -369,5 +369,7 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string chart.Plot.Axes.AutoScale(); chart.Refresh(); }; + + return contextMenu; } } diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs new file mode 100644 index 00000000..fec73a60 --- /dev/null +++ b/Lite/Helpers/WaitDrillDownHelper.cs @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorLite.Helpers; + +/// +/// Classifies wait types for drill-down behavior and walks blocking chains +/// to find head blockers. Used by WaitDrillDownWindow. +/// +public static class WaitDrillDownHelper +{ + public enum WaitCategory + { + /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. + Correlated, + /// Walk blocking chain to find head blockers (LCK_M_*). + Chain, + /// Sessions may lack worker threads, unlikely to appear in snapshots. + Uncapturable, + /// Attempt direct wait_type filter; may return empty for brief waits. + Filtered + } + + public sealed record WaitClassification( + WaitCategory Category, + string SortProperty, + string Description + ); + + /// + /// Lightweight result from the chain walker — just the head blocker identity and blocked count. + /// Callers look up the original full row by (CollectionTime, SessionId). + /// + public sealed record HeadBlockerInfo( + DateTime CollectionTime, + int SessionId, + int BlockedSessionCount, + string BlockingPath + ); + + public sealed record SnapshotInfo + { + public int SessionId { get; init; } + public int BlockingSessionId { get; init; } + public DateTime CollectionTime { get; init; } + public string DatabaseName { get; init; } = ""; + public string Status { get; init; } = ""; + public string QueryText { get; init; } = ""; + public string? WaitType { get; init; } + public long WaitTimeMs { get; init; } + public long CpuTimeMs { get; init; } + public long Reads { get; init; } + public long Writes { get; init; } + public long LogicalReads { get; init; } + } + + private const int MaxChainDepth = 20; + + public static WaitClassification Classify(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); + + return waitType switch + { + "SOS_SCHEDULER_YIELD" => + new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), + "WRITELOG" => + new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), + "CXPACKET" or "CXCONSUMER" => + new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), + "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => + new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), + "THREADPOOL" => + new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), + "LATCH_EX" or "LATCH_UP" => + new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), + _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), + _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), + _ => + new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") + }; + } + + /// + /// Walks blocking chains to find head blockers. + /// Returns lightweight HeadBlockerInfo records — callers look up original full rows + /// by (CollectionTime, SessionId) to preserve all columns. + /// + public static List WalkBlockingChains( + IEnumerable waiters, + IEnumerable allSnapshots) + { + var byTime = allSnapshots + .GroupBy(s => s.CollectionTime) + .ToDictionary( + g => g.Key, + g => g.ToDictionary(s => s.SessionId)); + + var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); + + foreach (var waiter in waiters) + { + if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) + continue; + + var head = FindHeadBlocker(waiter, sessionsAtTime); + if (head == null) + continue; + + var key = (waiter.CollectionTime, head.SessionId); + if (!headBlockers.TryGetValue(key, out var existing)) + { + existing = (head, new HashSet()); + headBlockers[key] = existing; + } + + existing.BlockedSessions.Add(waiter.SessionId); + } + + return headBlockers.Values + .Select(hb => new HeadBlockerInfo( + hb.Info.CollectionTime, + hb.Info.SessionId, + hb.BlockedSessions.Count, + $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) + .OrderByDescending(r => r.BlockedSessionCount) + .ThenByDescending(r => r.CollectionTime) + .ToList(); + } + + private static SnapshotInfo? FindHeadBlocker( + SnapshotInfo waiter, + Dictionary sessionsAtTime) + { + var visited = new HashSet(); + var current = waiter; + + for (int depth = 0; depth < MaxChainDepth; depth++) + { + if (!visited.Add(current.SessionId)) + return current; // cycle detected — treat current as head + + var blockerId = current.BlockingSessionId; + + // Head blocker: not blocked by anyone, or blocked by self, or blocker not found + if (blockerId <= 0 || blockerId == current.SessionId) + return current; + + if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) + return current; // blocker not in snapshot — treat current as head + + current = blocker; + } + + return current; // max depth — treat current as head + } +} diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 99ba8e14..80e49cb9 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -788,4 +788,8 @@ public class QuerySnapshotRow public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime); + + // Chain mode — set by WaitDrillDownWindow when showing head blockers + public int ChainBlockedCount { get; set; } + public string ChainBlockingPath { get; set; } = ""; } diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 1b2c4b47..d00a2364 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -191,6 +191,188 @@ ORDER BY collection_time DESC return items; } + /// + /// Gets query snapshots filtered by wait type, for the wait drill-down feature. + /// Returns sessions that were experiencing the specified wait type during the time range. + /// + public async Task> GetQuerySnapshotsByWaitTypeAsync( + int serverId, string waitType, int hoursBack = 24, + DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + session_id, + database_name, + elapsed_time_formatted, + query_text, + status, + blocking_session_id, + wait_type, + wait_time_ms, + wait_resource, + cpu_time_ms, + total_elapsed_time_ms, + reads, + writes, + logical_reads, + granted_query_memory_gb, + transaction_isolation_level, + dop, + parallel_worker_count, + query_plan, + live_query_plan, + collection_time, + login_name, + host_name, + program_name, + open_transaction_count, + percent_complete +FROM v_query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND wait_type = $4 +ORDER BY wait_time_ms DESC +LIMIT 500"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + command.Parameters.Add(new DuckDBParameter { Value = waitType }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotRow + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), + Status = reader.IsDBNull(4) ? "" : reader.GetString(4), + BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), + TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), + Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), + ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), + LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), + CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), + LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), + HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), + ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), + OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + }); + } + + return items; + } + + /// + /// Gets ALL query snapshots in a time range (for chain walking). + /// Used when a chain wait type (LCK_M_*, LATCH_EX/UP) needs blocking chain traversal. + /// + public async Task> GetAllQuerySnapshotsInRangeAsync( + int serverId, int hoursBack = 24, + DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + session_id, + database_name, + elapsed_time_formatted, + query_text, + status, + blocking_session_id, + wait_type, + wait_time_ms, + wait_resource, + cpu_time_ms, + total_elapsed_time_ms, + reads, + writes, + logical_reads, + granted_query_memory_gb, + transaction_isolation_level, + dop, + parallel_worker_count, + query_plan, + live_query_plan, + collection_time, + login_name, + host_name, + program_name, + open_transaction_count, + percent_complete +FROM v_query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 2000"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotRow + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), + Status = reader.IsDBNull(4) ? "" : reader.GetString(4), + BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), + TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), + Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), + ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), + LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), + CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), + LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), + HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), + ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), + OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + }); + } + + return items; + } + /// /// Gets long-running queries from the latest collection snapshot. /// Returns sessions whose total elapsed time exceeds the given threshold. diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml new file mode 100644 index 00000000..fa349388 --- /dev/null +++ b/Lite/Windows/WaitDrillDownWindow.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +