diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml deleted file mode 100644 index 19bca8d..0000000 --- a/.github/workflows/check-version-bump.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Check version bump -on: - pull_request: - branches: [main] - -jobs: - check-version: - if: github.head_ref == 'dev' - runs-on: ubuntu-latest - - steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - - - name: Get PR version - id: pr - shell: pwsh - run: | - $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "PR version: $version" - - - name: Checkout main - uses: actions/checkout@v4 - with: - ref: main - path: main-branch - - - name: Get main version - id: main - shell: pwsh - run: | - $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "Main version: $version" - - - name: Compare versions - env: - PR_VERSION: ${{ steps.pr.outputs.VERSION }} - MAIN_VERSION: ${{ steps.main.outputs.VERSION }} - run: | - echo "Main version: $MAIN_VERSION" - echo "PR version: $PR_VERSION" - if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then - echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main." - exit 1 - fi - echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION" diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index c87d074..f920d82 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -95,16 +95,6 @@ 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( @@ -124,16 +114,6 @@ 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/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs index 1b2e2ac..6ea34e7 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml.cs +++ b/Dashboard/Controls/PlanViewerControl.xaml.cs @@ -534,8 +534,7 @@ private void ShowPropertiesPanel(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) headerText += $" ({node.LogicalOp})"; PropertiesHeader.Text = headerText; PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; @@ -1482,8 +1481,7 @@ private ToolTip BuildNodeTooltip(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) headerText += $" ({node.LogicalOp})"; stack.Children.Add(new TextBlock { diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index 02d7598..e17265a 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -199,8 +199,7 @@ private void SetupChartContextMenus() TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats"); // Wait Stats Detail chart - var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); - AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu); + TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); } /// @@ -1814,48 +1813,6 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) await UpdateWaitStatsDetailChartAsync(); } - private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) - { - contextMenu.Items.Insert(0, new Separator()); - var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; - drillDownItem.Click += ShowQueriesForWaitType_Click; - contextMenu.Items.Insert(0, drillDownItem); - - contextMenu.Opened += (s, _) => - { - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = _waitStatsHover?.GetNearestSeries(pos); - if (nearest.HasValue) - { - drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); - drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; - drillDownItem.IsEnabled = true; - } - else - { - drillDownItem.Tag = null; - drillDownItem.Header = "Show Queries With This Wait"; - drillDownItem.IsEnabled = false; - } - }; - } - - private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - if (menuItem.Tag is not ValueTuple tag) return; - if (_databaseService == null) return; - - // ±15 minute window around the clicked point - var fromDate = tag.Item2.AddMinutes(-15); - var toDate = tag.Item2.AddMinutes(15); - - var window = new WaitDrillDownWindow( - _databaseService, tag.Item1, 1, fromDate, toDate); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_allWaitStatsDetailData != null) diff --git a/Dashboard/Converters/QueryTextCleanupConverter.cs b/Dashboard/Converters/QueryTextCleanupConverter.cs index 764131d..e1ae848 100644 --- a/Dashboard/Converters/QueryTextCleanupConverter.cs +++ b/Dashboard/Converters/QueryTextCleanupConverter.cs @@ -12,7 +12,7 @@ namespace PerformanceMonitorDashboard.Converters { - public partial class QueryTextCleanupConverter : IValueConverter + public 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 = MultipleSpacesRegExp().Replace(text, " "); + text = Regex.Replace(text, @"\s+", " "); // Trim leading/trailing whitespace text = text.Trim(); @@ -40,8 +40,5 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu { throw new NotImplementedException(); } - - [GeneratedRegex(@"\s+")] - private static partial Regex MultipleSpacesRegExp(); } } diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index e15a88b..0f21541 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -6,10 +6,10 @@ true PerformanceMonitorDashboard SQL Server Performance Monitor Dashboard - 2.2.0 - 2.2.0.0 - 2.2.0.0 - 2.2.0 + 2.1.0 + 2.1.0.0 + 2.1.0.0 + 2.1.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC EDD.ico diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index 1fb73cc..17f2702 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -61,50 +61,6 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); - /// - /// Returns the nearest series label and data-point time for the given mouse position, - /// or null if no series is close enough. - /// - public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) - { - if (_scatters.Count == 0) return null; - try - { - var dpi = VisualTreeHelper.GetDpi(_chart); - var pixel = new ScottPlot.Pixel( - (float)(mousePos.X * dpi.DpiScaleX), - (float)(mousePos.Y * dpi.DpiScaleY)); - var mouseCoords = _chart.Plot.GetCoordinates(pixel); - - double bestYDistance = double.MaxValue; - ScottPlot.DataPoint bestPoint = default; - string bestLabel = ""; - bool found = false; - - foreach (var (scatter, label) in _scatters) - { - var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender); - if (!nearest.IsReal) continue; - var nearestPixel = _chart.Plot.GetPixel( - new ScottPlot.Coordinates(nearest.X, nearest.Y)); - double dx = Math.Abs(nearestPixel.X - pixel.X); - double dy = Math.Abs(nearestPixel.Y - pixel.Y); - if (dx < 80 && dy < bestYDistance) - { - bestYDistance = dy; - bestPoint = nearest; - bestLabel = label; - found = true; - } - } - - if (found) - return (bestLabel, DateTime.FromOADate(bestPoint.X)); - } - catch { } - return null; - } - private void OnMouseMove(object sender, MouseEventArgs e) { if (_scatters.Count == 0) return; @@ -115,10 +71,9 @@ 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 * dpi.DpiScaleX), - (float)(pos.Y * dpi.DpiScaleY)); + (float)(pos.X * _chart.DisplayScale), + (float)(pos.Y * _chart.DisplayScale)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); /* Use X-axis (time) proximity as the primary filter, Y-axis distance diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/Dashboard/Helpers/DateFilterHelper.cs index def0f45..b9b5e4f 100644 --- a/Dashboard/Helpers/DateFilterHelper.cs +++ b/Dashboard/Helpers/DateFilterHelper.cs @@ -11,7 +11,7 @@ namespace PerformanceMonitorDashboard.Helpers { - public static partial class DateFilterHelper + public static 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 = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower); + var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)"); if (lastMatch.Success) { int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture); @@ -231,8 +231,5 @@ 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/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs index 6a5a736..ed1260b 100644 --- a/Dashboard/Helpers/TabHelpers.cs +++ b/Dashboard/Helpers/TabHelpers.cs @@ -603,7 +603,7 @@ public static string FormatForExport(object? value) /// The WpfPlot chart control /// A descriptive name for the chart (used in filenames) /// Optional SQL view/table name that populates this chart - public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -786,8 +786,6 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, chart.Plot.Axes.AutoScale(); chart.Refresh(); }; - - return contextMenu; } /// diff --git a/Dashboard/Helpers/WaitDrillDownHelper.cs b/Dashboard/Helpers/WaitDrillDownHelper.cs deleted file mode 100644 index ef62d97..0000000 --- a/Dashboard/Helpers/WaitDrillDownHelper.cs +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * 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 PerformanceMonitorDashboard.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/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 275917f..6358cfd 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, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits); + var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier); if (health.IsOnline) { diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/Dashboard/Models/QuerySnapshotItem.cs index 0b3d2c7..caa95db 100644 --- a/Dashboard/Models/QuerySnapshotItem.cs +++ b/Dashboard/Models/QuerySnapshotItem.cs @@ -45,8 +45,5 @@ public class QuerySnapshotItem // Property alias for XAML binding compatibility public string? QueryText => SqlText; - - // Chain mode — set by WaitDrillDownWindow when showing head blockers - public string ChainBlockingPath { get; set; } = ""; } } diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 17e3ecb..01e5dd7 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -85,11 +85,6 @@ 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 48435a1..4e711ef 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -121,15 +121,7 @@ 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, - int longRunningQueryMaxResults = 5, - bool excludeSpServerDiagnostics = true, - bool excludeWaitFor = true, - bool excludeBackups = true, - bool excludeMiscWaits = true) + public async Task GetAlertHealthAsync(int engineEdition = 0, int longRunningQueryThresholdMinutes = 30, int longRunningJobMultiplier = 3) { var result = new AlertHealthResult(); @@ -144,7 +136,7 @@ public async Task GetAlertHealthAsync( var blockingTask = GetBlockingValuesAsync(connection); var deadlockTask = GetDeadlockCountAsync(connection); var poisonWaitTask = GetPoisonWaitDeltasAsync(connection); - var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits); + var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes); var tempDbTask = GetTempDbSpaceAsync(connection); var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier); @@ -611,29 +603,24 @@ 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, - int maxResults = 5, - bool excludeSpServerDiagnostics = true, - bool excludeWaitFor = true, - bool excludeBackups = true, - bool excludeMiscWaits = true) + private async Task> GetLongRunningQueriesAsync(SqlConnection connection, int thresholdMinutes) { - maxResults = Math.Clamp(maxResults, 1, 1000); - 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')" : ""; + // 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 query = @$"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - SELECT TOP(@maxResults) + SELECT TOP(5) r.session_id, DB_NAME(r.database_id) AS database_name, SUBSTRING(t.text, 1, 300) AS query_text, @@ -664,7 +651,6 @@ 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/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index 1202c01..6c3c2c8 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -690,163 +690,6 @@ FROM report.query_snapshots AS qs return result == DBNull.Value ? null : result as string; } - /// - /// Gets query snapshots filtered by wait type for the wait drill-down feature. - /// Uses LIKE on wait_info to match sp_WhoIsActive's formatted wait string. - /// - public async Task> GetQuerySnapshotsByWaitTypeAsync( - string waitType, int hoursBack = 1, - DateTime? fromDate = null, DateTime? toDate = null) - { - var items = new List(); - - await using var tc = await OpenThrottledConnectionAsync(); - var connection = tc.Connection; - - // Check if the view exists - string checkViewQuery = @" - SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - - SELECT 1 FROM sys.views - WHERE name = 'query_snapshots' - AND schema_id = SCHEMA_ID('report')"; - - using var checkCommand = new SqlCommand(checkViewQuery, connection); - var viewExists = await checkCommand.ExecuteScalarAsync(); - - if (viewExists == null) - return items; - - bool useCustomDates = fromDate.HasValue && toDate.HasValue; - - // sp_WhoIsActive formats wait_info as "(1x: 349ms)LCK_M_X, (1x: 12ms)..." - // The ')' always precedes the wait type name, so we use '%)WAIT_TYPE%' - // to avoid false positives (e.g., LCK_M_X matching LCK_M_IX) - string query = useCustomDates - ? @" - SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - - SELECT TOP (500) - qs.collection_time, - qs.[dd hh:mm:ss.mss], - qs.session_id, - qs.status, - qs.wait_info, - qs.blocking_session_id, - qs.blocked_session_count, - qs.database_name, - qs.login_name, - qs.host_name, - qs.program_name, - sql_text = CONVERT(nvarchar(max), qs.sql_text), - sql_command = CONVERT(nvarchar(max), qs.sql_command), - qs.CPU, - qs.reads, - qs.writes, - qs.physical_reads, - qs.context_switches, - qs.used_memory, - qs.tempdb_current, - qs.tempdb_allocations, - qs.tran_log_writes, - qs.open_tran_count, - qs.percent_complete, - qs.start_time, - qs.tran_start_time, - qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) - FROM report.query_snapshots AS qs - WHERE qs.collection_time >= @from_date - AND qs.collection_time <= @to_date - AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' - ORDER BY - qs.collection_time DESC, - qs.session_id;" - : @" - SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - - SELECT TOP (500) - qs.collection_time, - qs.[dd hh:mm:ss.mss], - qs.session_id, - qs.status, - qs.wait_info, - qs.blocking_session_id, - qs.blocked_session_count, - qs.database_name, - qs.login_name, - qs.host_name, - qs.program_name, - sql_text = CONVERT(nvarchar(max), qs.sql_text), - sql_command = CONVERT(nvarchar(max), qs.sql_command), - qs.CPU, - qs.reads, - qs.writes, - qs.physical_reads, - qs.context_switches, - qs.used_memory, - qs.tempdb_current, - qs.tempdb_allocations, - qs.tran_log_writes, - qs.open_tran_count, - qs.percent_complete, - qs.start_time, - qs.tran_start_time, - qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) - FROM report.query_snapshots AS qs - WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) - AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' - ORDER BY - qs.collection_time DESC, - qs.session_id;"; - - using var command = new SqlCommand(query, connection); - command.CommandTimeout = 120; - command.Parameters.Add(new SqlParameter("@wait_type", SqlDbType.NVarChar, 200) { Value = waitType }); - command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack }); - if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); - if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); - - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new QuerySnapshotItem - { - CollectionTime = reader.GetDateTime(0), - Duration = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty, - SessionId = SafeToInt16(reader.GetValue(2), "session_id") ?? 0, - Status = reader.IsDBNull(3) ? null : reader.GetValue(3)?.ToString(), - WaitInfo = reader.IsDBNull(4) ? null : reader.GetValue(4)?.ToString(), - BlockingSessionId = SafeToInt16(reader.GetValue(5), "blocking_session_id"), - BlockedSessionCount = SafeToInt16(reader.GetValue(6), "blocked_session_count"), - DatabaseName = reader.IsDBNull(7) ? null : reader.GetValue(7)?.ToString(), - LoginName = reader.IsDBNull(8) ? null : reader.GetValue(8)?.ToString(), - HostName = reader.IsDBNull(9) ? null : reader.GetValue(9)?.ToString(), - ProgramName = reader.IsDBNull(10) ? null : reader.GetValue(10)?.ToString(), - SqlText = reader.IsDBNull(11) ? null : reader.GetValue(11)?.ToString(), - SqlCommand = reader.IsDBNull(12) ? null : reader.GetValue(12)?.ToString(), - Cpu = SafeToInt64(reader.GetValue(13), "CPU"), - Reads = SafeToInt64(reader.GetValue(14), "reads"), - Writes = SafeToInt64(reader.GetValue(15), "writes"), - PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"), - ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"), - UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"), - TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"), - TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"), - TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(), - OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"), - PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"), - StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), - TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25), - RequestId = SafeToInt16(reader.GetValue(26), "request_id"), - AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString() - }); - } - - return items; - } - public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { var items = new List(); @@ -896,8 +739,8 @@ WITH per_lifetime AS total_spills = MAX(qs.total_spills), min_spills = MIN(qs.min_spills), max_spills = MAX(qs.max_spills), - query_text = CAST(DECOMPRESS(MAX(qs.query_text)) AS nvarchar(max)), - query_plan_text = CAST(DECOMPRESS(MAX(qs.query_plan_text)) AS nvarchar(max)), + query_text = MAX(qs.query_text), + query_plan_text = MAX(qs.query_plan_text), query_plan_hash = MAX(qs.query_plan_hash), sql_handle = MAX(qs.sql_handle), plan_handle = MAX(qs.plan_handle) @@ -910,7 +753,7 @@ FROM collect.query_stats AS qs OR (qs.last_execution_time >= @fromDate AND qs.last_execution_time <= @toDate) OR (qs.creation_time <= @fromDate AND qs.last_execution_time >= @toDate))) ) - AND CAST(DECOMPRESS(qs.query_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%' + AND qs.query_text NOT LIKE N'WAITFOR%' GROUP BY qs.database_name, qs.query_hash, @@ -1079,7 +922,7 @@ WITH per_lifetime AS total_spills = MAX(ps.total_spills), min_spills = MIN(ps.min_spills), max_spills = MAX(ps.max_spills), - query_plan_text = CAST(DECOMPRESS(MAX(ps.query_plan_text)) AS nvarchar(max)), + query_plan_text = MAX(ps.query_plan_text), sql_handle = MAX(ps.sql_handle), plan_handle = MAX(ps.plan_handle) FROM collect.procedure_stats AS ps @@ -1258,7 +1101,7 @@ public async Task> GetQueryStoreDataAsync(int hoursBack = 2 plan_type = MAX(qsd.plan_type), is_forced_plan = MAX(CONVERT(tinyint, qsd.is_forced_plan)), compatibility_level = MAX(qsd.compatibility_level), - query_sql_text = CAST(DECOMPRESS(MAX(qsd.query_sql_text)) AS nvarchar(max)), + query_sql_text = CONVERT(nvarchar(max), MAX(qsd.query_sql_text)), query_plan_hash = CONVERT(nvarchar(20), MAX(qsd.query_plan_hash), 1), force_failure_count = SUM(qsd.force_failure_count), last_force_failure_reason_desc = MAX(qsd.last_force_failure_reason_desc), @@ -1278,7 +1121,7 @@ FROM collect.query_store_data AS qsd OR (qsd.server_last_execution_time >= @fromDate AND qsd.server_last_execution_time <= @toDate) OR (qsd.server_first_execution_time <= @fromDate AND qsd.server_last_execution_time >= @toDate))) ) - AND CAST(DECOMPRESS(qsd.query_sql_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%' + AND qsd.query_sql_text NOT LIKE N'WAITFOR%' GROUP BY qsd.database_name, qsd.query_id @@ -2385,7 +2228,7 @@ ORDER BY SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - CAST(DECOMPRESS(qsd.query_plan_text) AS nvarchar(max)) AS query_plan_text + qsd.query_plan_text FROM collect.query_store_data AS qsd WHERE qsd.collection_id = @collection_id;"; @@ -2433,7 +2276,7 @@ FROM collect.procedure_stats AS ps SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max)) AS query_plan_text + qs.query_plan_text FROM collect.query_stats AS qs WHERE qs.collection_id = @collection_id;"; diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs index e174f31..5e30b33 100644 --- a/Dashboard/Services/NotificationService.cs +++ b/Dashboard/Services/NotificationService.cs @@ -44,10 +44,7 @@ public void Initialize() 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. */ + /* Custom tooltip styled to match current theme */ _trayIcon.TrayToolTip = new Border { Background = new SolidColorBrush(HasLightBackground diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 8effd57..ec03090 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -10,16 +10,24 @@ 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 partial class PlanAnalyzer +public static class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); + 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 LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); + private static readonly Regex LeadingWildcardLikeRegex = new( + @"\blike\b[^'""]*?N?'%", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); + private static readonly Regex CaseInPredicateRegex = new( + @"\bCASE\s+(WHEN\b|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); + private static readonly Regex CteDefinitionRegex = new( + @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); public static void Analyze(ParsedPlan plan) { @@ -178,7 +186,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) + Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -459,7 +467,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.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) + if (node.PhysicalOp == "RID Lookup") { 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)) @@ -678,7 +686,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 { @@ -785,7 +793,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !NotInRegExp().IsMatch(stmt.StatementText)) + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) return false; // Walk up the tree checking ancestors and their children @@ -882,7 +890,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (IsNullCoalesceRegExp().IsMatch(predicate)) + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -922,7 +930,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Count(text); + var refCount = refPattern.Matches(text).Count; if (refCount > 1) { @@ -1235,19 +1243,4 @@ 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)] - 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(); } diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 6ed411e..659a901 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -30,8 +30,6 @@ 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 @@ -76,11 +74,7 @@ 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/ReproScriptBuilder.cs b/Dashboard/Services/ReproScriptBuilder.cs index f008c54..3605db3 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 partial class ReproScriptBuilder +public static 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 = AtVariableRegExp().Matches(queryText); + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -429,9 +429,6 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 048ead7..2177801 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -631,19 +631,6 @@ 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); @@ -845,19 +832,6 @@ 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); @@ -1442,32 +1416,10 @@ 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 = unmatchedMsg, + Message = "Indexes could not be matched due to parameterization", Severity = PlanWarningSeverity.Warning }); } diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index 5930e73..f955eff 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -217,28 +217,8 @@ 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/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index fd1de4e..71ce17c 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 = MultipleSpacesRegExp().Replace(queryPattern, " ").Trim(); + var displayPattern = System.Text.RegularExpressions.Regex.Replace(queryPattern, @"\s+", " ").Trim(); if (displayPattern.Length > 120) displayPattern = displayPattern.Substring(0, 120) + "..."; QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}"; @@ -406,9 +406,6 @@ 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/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml deleted file mode 100644 index f23e2da..0000000 --- a/Dashboard/WaitDrillDownWindow.xaml +++ /dev/null @@ -1,301 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -369,7 +369,5 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, chart.Plot.Axes.AutoScale(); chart.Refresh(); }; - - return contextMenu; } } diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs deleted file mode 100644 index fec73a6..0000000 --- a/Lite/Helpers/WaitDrillDownHelper.cs +++ /dev/null @@ -1,169 +0,0 @@ -/* - * 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/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index f52087b..2f537d2 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1099,9 +1099,7 @@ await _emailAlertService.TrySendAlertEmailAsync( allWaitNames, $"{App.AlertPoisonWaitThresholdMs}ms avg", summary.ServerId, - poisonContext, - numericCurrentValue: worst.AvgMsPerWait, - numericThresholdValue: App.AlertPoisonWaitThresholdMs); + poisonContext); } } else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait) @@ -1124,7 +1122,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits); + var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes); if (longRunning.Count > 0) { @@ -1149,9 +1147,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{longRunning.Count} query(s), longest {elapsedMinutes}m", $"{App.AlertLongRunningQueryThresholdMinutes}m", summary.ServerId, - lrqContext, - numericCurrentValue: elapsedMinutes, - numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes); + lrqContext); } } else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning) @@ -1195,9 +1191,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", $"{App.AlertTempDbSpaceThresholdPercent}%", summary.ServerId, - tempDbContext, - numericCurrentValue: tempDb.UsedPercent, - numericThresholdValue: App.AlertTempDbSpaceThresholdPercent); + tempDbContext); } } else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb) @@ -1246,9 +1240,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{anomalousJobs.Count} job(s) exceeding {App.AlertLongRunningJobMultiplier}x average", $"{App.AlertLongRunningJobMultiplier}x historical avg", summary.ServerId, - jobContext, - numericCurrentValue: (double)worst.PercentOfAverage, - numericThresholdValue: App.AlertLongRunningJobMultiplier * 100); + jobContext); } } else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob) diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index a40ad9d..88ee7eb 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -7,10 +7,10 @@ PerformanceMonitorLite PerformanceMonitorLite SQL Server Performance Monitor Lite - 2.2.0 - 2.2.0.0 - 2.2.0.0 - 2.2.0 + 2.1.0 + 2.1.0.0 + 2.1.0.0 + 2.1.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Lightweight SQL Server performance monitoring - no installation required on target servers diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index 743a93f..f837221 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -48,9 +48,7 @@ public async Task TrySendAlertEmailAsync( string currentValue, string thresholdValue, int serverId = 0, - AlertContext? context = null, - double? numericCurrentValue = null, - double? numericThresholdValue = null) + AlertContext? context = null) { try { @@ -110,12 +108,10 @@ public async Task TrySendAlertEmailAsync( } /* Always log the alert to DuckDB, regardless of email status */ - var logCurrent = numericCurrentValue - ?? (double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0); - var logThreshold = numericThresholdValue - ?? (double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0); await LogAlertAsync(serverId, serverName, metricName, - logCurrent, logThreshold, sent, notificationType, sendError); + double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0, + double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0, + sent, notificationType, sendError); } catch (Exception ex) { diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 80e49cb..99ba8e1 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -788,8 +788,4 @@ 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 7175358..e18b4f5 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -170,7 +170,6 @@ FROM v_wait_stats WHERE server_id = $1 AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE') AND delta_waiting_tasks > 0 -AND collection_time >= NOW() - INTERVAL '10 minutes' ORDER BY collection_time DESC LIMIT 3"; @@ -192,215 +191,28 @@ 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. /// - public async Task> GetLongRunningQueriesAsync( - int serverId, - int thresholdMinutes, - int maxResults = 5, - bool excludeSpServerDiagnostics = true, - bool excludeWaitFor = true, - bool excludeBackups = true, - bool excludeMiscWaits = true) + public async Task> GetLongRunningQueriesAsync(int serverId, int thresholdMinutes) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var thresholdMs = (long)thresholdMinutes * 60 * 1000; - 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); + // 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')"; command.CommandText = @$" SELECT @@ -423,11 +235,10 @@ AND r.session_id > 50 {miscWaitsFilter} AND r.total_elapsed_time_ms >= $2 ORDER BY r.total_elapsed_time_ms DESC - LIMIT $3;"; + LIMIT 5;"; 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/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 6ac33d4..8031874 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -10,16 +10,24 @@ 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 partial class PlanAnalyzer +public static class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); + 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 LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); + private static readonly Regex LeadingWildcardLikeRegex = new( + @"\blike\b[^'""]*?N?'%", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); + private static readonly Regex CaseInPredicateRegex = new( + @"\bCASE\s+(WHEN\b|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); + private static readonly Regex CteDefinitionRegex = new( + @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); public static void Analyze(ParsedPlan plan) { @@ -178,7 +186,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) + Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -459,7 +467,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.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) + if (node.PhysicalOp == "RID Lookup") { 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)) @@ -678,7 +686,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 { @@ -785,7 +793,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !NotInRegExp().IsMatch(stmt.StatementText)) + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) return false; // Walk up the tree checking ancestors and their children @@ -882,7 +890,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (IsNullCoalesceRegExp().IsMatch(predicate)) + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -922,7 +930,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Count(text); + var refCount = refPattern.Matches(text).Count; if (refCount > 1) { @@ -1235,19 +1243,4 @@ 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)] - 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(); } diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs index f187eda..7c54285 100644 --- a/Lite/Services/PlanIconMapper.cs +++ b/Lite/Services/PlanIconMapper.cs @@ -30,8 +30,6 @@ 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 @@ -76,11 +74,7 @@ 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/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index 93f5e36..b85500c 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -8,7 +8,6 @@ using System; using System.Diagnostics; -using System.Text; using System.Threading; using System.Threading.Tasks; using DuckDB.NET.Data; @@ -20,8 +19,7 @@ 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; @@ -82,9 +80,7 @@ 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); -"""; - private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); +OPTION(MAXDOP 1, RECOMPILE);"; /// /// Builds the query snapshots SQL with or without live query plan support. @@ -93,8 +89,8 @@ AND dest.text IS NOT NULL internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan) { return supportsLiveQueryPlan - ? 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),", ""); + ? 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),", ""); } /// @@ -130,42 +126,44 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc { await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("query_snapshots"); - while (await reader.ReadAsync(cancellationToken)) + using (var appender = duckConnection.CreateAppender("query_snapshots")) { - 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++; + 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++; + } } } diff --git a/Lite/Services/ReproScriptBuilder.cs b/Lite/Services/ReproScriptBuilder.cs index a1fef75..6a9a35a 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 partial class ReproScriptBuilder +public static 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 = AtVariableRegExp().Matches(queryText); + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -427,9 +427,6 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index ef7f805..11c0f9e 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -631,19 +631,6 @@ 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); @@ -845,19 +832,6 @@ 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); @@ -1442,32 +1416,10 @@ 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 = unmatchedMsg, + Message = "Indexes could not be matched due to parameterization", Severity = PlanWarningSeverity.Warning }); } diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs index 8c1968b..a54bfc1 100644 --- a/Lite/Services/SystemTrayService.cs +++ b/Lite/Services/SystemTrayService.cs @@ -45,10 +45,7 @@ public void Initialize() 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. */ + /* Custom tooltip styled to match current theme */ _tooltipText = new TextBlock { Text = "Performance Monitor Lite", diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 098555b..4412f02 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -181,29 +181,9 @@ - - - - - - - - - - diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 350458a..8876420 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -436,11 +436,6 @@ 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; @@ -468,12 +463,6 @@ 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; @@ -508,11 +497,6 @@ 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; diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml deleted file mode 100644 index fa34938..0000000 --- a/Lite/Windows/WaitDrillDownWindow.xaml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -