From 6ac47b7eb5fd6d77bb223b5a9ed0f867945cfafd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:32:48 -0400 Subject: [PATCH 1/9] Plan analyzer improvements: batch mode sort, table variables, Top N Sort, key lookup severity (#65) - Batch mode sort skew: downgrade to Info with "by design" note (unless feeding Window Aggregate) - Root node tooltip now includes statement-level PlanWarnings (e.g., Optimize For Unknown) - OPTIMIZE FOR UNKNOWN: distinguish from OPTION(RECOMPILE) in parameter annotations - Key Lookup with predicate: upgrade from Warning to Critical - Table variable: add statement-level warnings for stats + modification serial execution (Critical) - Top N Sort: parse XML element, set LogicalOp to "Top N Sort" - Rule 24: broaden to any Top/Top N Sort above scan (Critical when on NL inner side) - Fix IsOrExpansionChain to match normalized "Top N Sort" LogicalOp - Update Rule 22 test for new statement-level warnings Co-authored-by: Claude Opus 4.6 --- .../Controls/PlanViewerControl.axaml.cs | 21 +++- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 108 ++++++++++++++---- .../Services/ShowPlanParser.cs | 4 + .../PlanAnalyzerTests.cs | 9 +- 4 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 3be8573..07b6950 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -331,6 +331,8 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1) if (totalWarningCount > 0) { var allWarnings = new List(); + if (_currentStatement != null) + allWarnings.AddRange(_currentStatement.PlanWarnings); CollectWarnings(node, allWarnings); ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings)); } @@ -2165,9 +2167,22 @@ private void ShowParameters(PlanStatement statement) // Annotations if (allCompiledNull && parameters.Count > 0) { - AddParameterAnnotation( - "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", - "#FFB347"); + var hasOptimizeForUnknown = statement.StatementText + .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) + && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); + + if (hasOptimizeForUnknown) + { + AddParameterAnnotation( + "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values", + "#6BB5FF"); + } + else + { + AddParameterAnnotation( + "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", + "#FFB347"); + } } var unresolved = FindUnresolvedVariables(statement.StatementText, parameters); diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 75ae9b2..6960a81 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -388,6 +388,55 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) } } } + + // Rule 22 (statement-level): Table variable warnings + // Walk the tree to find table variable references, then emit statement-level warnings + if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null) + { + var hasTableVar = false; + var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE"; + var modifiesTableVar = false; + CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); + + if (hasTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", + Severity = PlanWarningSeverity.Warning + }); + } + + if (modifiesTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", + Severity = PlanWarningSeverity.Critical + }); + } + } + } + + private static void CheckForTableVariables(PlanNode node, bool isModification, + ref bool hasTableVar, ref bool modifiesTableVar) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + hasTableVar = true; + // The modification target is typically an Insert/Update/Delete operator on a table variable + if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase))) + { + modifiesTableVar = true; + } + } + foreach (var child in node.Children) + CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar); } private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) @@ -572,11 +621,24 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi var skewThreshold = node.PerThreadStats.Count == 2 ? 0.75 : 0.50; if (skewRatio >= skewThreshold) { + var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; + var severity = PlanWarningSeverity.Warning; + + // Batch mode sorts produce all output on a single thread by design + // unless their parent is a batch mode Window Aggregate + if (node.PhysicalOp == "Sort" + && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch" + && node.Parent?.PhysicalOp != "Window Aggregate") + { + message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate."; + severity = PlanWarningSeverity.Info; + } + node.Warnings.Add(new PlanWarning { WarningType = "Parallel Skew", - Message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much.", - Severity = PlanWarningSeverity.Warning + Message = message, + Severity = severity }); } } @@ -603,7 +665,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi { WarningType = "Key Lookup", Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column.\nPredicate: {Truncate(node.Predicate, 200)}", - Severity = PlanWarningSeverity.Warning + Severity = PlanWarningSeverity.Critical }); } @@ -829,35 +891,39 @@ _ when nonSargableReason.StartsWith("Function call") => }); } - // Rule 24: Top above a scan on the inner side of Nested Loops - // This pattern means the scan executes once per outer row, and the Top - // limits each iteration — but with no supporting index the scan is a - // linear search repeated potentially millions of times. - if (!cfg.IsRuleDisabled(24) && node.PhysicalOp == "Nested Loops" && node.Children.Count >= 2) + // Rule 24: Top above a scan + // Detects Top or Top N Sort operators feeding from a scan. This often means the + // query is scanning the entire table/index and sorting just to return a few rows, + // when an appropriate index could satisfy the request directly. + if (!cfg.IsRuleDisabled(24)) { - var inner = node.Children[1]; + var isTop = node.PhysicalOp == "Top"; + var isTopNSort = node.LogicalOp == "Top N Sort"; - // Walk through pass-through operators to find Top - while (inner.PhysicalOp == "Compute Scalar" && inner.Children.Count > 0) - inner = inner.Children[0]; - - if (inner.PhysicalOp == "Top" && inner.Children.Count > 0) + if ((isTop || isTopNSort) && node.Children.Count > 0) { // Walk through pass-through operators below the Top to find the scan - var scanCandidate = inner.Children[0]; - while (scanCandidate.PhysicalOp == "Compute Scalar" && scanCandidate.Children.Count > 0) + var scanCandidate = node.Children[0]; + while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism") + && scanCandidate.Children.Count > 0) scanCandidate = scanCandidate.Children[0]; if (IsScanOperator(scanCandidate)) { + var topLabel = isTopNSort ? "Top N Sort" : "Top"; + var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2 + && node.Parent.Children[1] == node; + var innerNote = onInner + ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row." + : ""; var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate) ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." : ""; - inner.Warnings.Add(new PlanWarning + node.Warnings.Add(new PlanWarning { WarningType = "Top Above Scan", - Message = $"Top operator reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}) on the inner side of Nested Loops (Node {node.NodeId}).{predInfo} Check that you have appropriate indexes to convert the scan into a seek.", - Severity = PlanWarningSeverity.Warning + Message = $"{topLabel} reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}).{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", + Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } } @@ -1097,8 +1163,8 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode) while (parent != null && parent.PhysicalOp == "Compute Scalar") parent = parent.Parent; - // Expect TopN Sort - if (parent == null || parent.LogicalOp != "TopN Sort") + // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort") + if (parent == null || parent.LogicalOp != "Top N Sort") return false; // Walk up to Merge Interval diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 2d3037f..01bfcd8 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -652,6 +652,10 @@ private static PlanNode ParseRelOp(XElement relOpEl) var physicalOpEl = GetOperatorElement(relOpEl); if (physicalOpEl != null) { + // Top N Sort — XML element is but PhysicalOp is "Sort" + if (physicalOpEl.Name.LocalName == "TopSort") + node.LogicalOp = "Top N Sort"; + // Object reference (table/index name) — scoped to stop at child RelOps var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault(); if (objEl != null) diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 61cd337..dd06547 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -443,8 +443,13 @@ public void Rule22_TableVariable_DetectsAtSignInObjectName() var plan = PlanTestHelper.LoadAndAnalyze("table_variable_plan.sqlplan"); var warnings = PlanTestHelper.WarningsOfType(plan, "Table Variable"); - Assert.Single(warnings); - Assert.Contains("lack column-level statistics", warnings[0].Message); + // Node-level + statement-level warnings + Assert.True(warnings.Count >= 2); + Assert.Contains(warnings, w => w.Message.Contains("lack column-level statistics")); + // Statement-level stats warning + var stmtWarnings = PlanTestHelper.FirstStatement(plan).PlanWarnings + .Where(w => w.WarningType == "Table Variable").ToList(); + Assert.Contains(stmtWarnings, w => w.Message.Contains("lack column-level statistics")); } // --------------------------------------------------------------- From 8629ddab7e4548e87b8add8da7b0607b4b5def13 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:36:38 -0400 Subject: [PATCH 2/9] Plan viewer improvements: edge tooltips, context menu, ManyToMany, DOP-aware parallelism rules (#66) - Edge connector tooltips now match SSMS with all 5 fields (actual rows, estimated per-execution, estimated all-executions, row size, data size) - Right-click context menu on plan canvas (zoom, advice, repro, save) - Fixed context menu to use ScrollViewer (Canvas has no hit-test background) - Merge joins always show Many to Many: Yes/No in tooltip - LayoutTransformControl for proper zoom+scroll behavior - Fit zoom scrolls to origin so plan root is immediately visible - Rules 25/31 now use DOP-aware efficiency scoring: efficiency = (CPU/Elapsed - 1) / (DOP - 1) * 100 instead of simple CPU/Elapsed ratio Co-authored-by: Claude Opus 4.6 --- .../Controls/PlanViewerControl.axaml | 11 +- .../Controls/PlanViewerControl.axaml.cs | 138 ++++++++++++++++-- src/PlanViewer.App/MainWindow.axaml.cs | 5 + src/PlanViewer.Core/Services/PlanAnalyzer.cs | 54 +++---- .../PlanAnalyzerTests.cs | 14 +- 5 files changed, 169 insertions(+), 53 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 503228f..db9d492 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -246,12 +246,13 @@ HorizontalContentAlignment="Left" VerticalContentAlignment="Top" Background="{DynamicResource BackgroundBrush}"> - - + + - - + + + diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 07b6950..8edde32 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -132,8 +132,9 @@ public PlanViewerControl() _splitterColumn = planGrid.ColumnDefinitions[3]; _propertiesColumn = planGrid.ColumnDefinitions[4]; - // ScaleTransform is the RenderTransform of PlanCanvas - _zoomTransform = (ScaleTransform)PlanCanvas.RenderTransform!; + // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas + var layoutTransform = this.FindControl("PlanLayoutTransform")!; + _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; } @@ -166,6 +167,11 @@ public ServerMetadata? Metadata } } + // Events for MainWindow to wire up advice/repro actions + public event EventHandler? HumanAdviceRequested; + public event EventHandler? RobotAdviceRequested; + public event EventHandler? CopyReproRequested; + public void LoadPlan(string planXml, string label, string? queryText = null) { _label = label; @@ -284,6 +290,10 @@ private void RenderStatement(PlanStatement statement) // Scroll to top-left so the plan root is immediately visible PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + // Canvas-level context menu (zoom, advice, repro, save) + // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable + PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); + CostText.Text = ""; } @@ -597,10 +607,6 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); geometry.Figures!.Add(figure); - var rowText = child.HasActualStats - ? $"Actual Rows: {child.ActualRows:N0}" - : $"Estimated Rows: {child.EstimateRows:N0}"; - var path = new AvaloniaPath { Data = geometry, @@ -608,10 +614,77 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) StrokeThickness = thickness, StrokeJoin = PenLineJoin.Round }; - ToolTip.SetTip(path, rowText); + ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); return path; } + private object BuildEdgeTooltipContent(PlanNode child) + { + var panel = new StackPanel { MinWidth = 240 }; + + void AddRow(string label, string value) + { + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + var lbl = new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), + FontSize = 12, + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetColumn(lbl, 0); + Grid.SetColumn(val, 1); + row.Children.Add(lbl); + row.Children.Add(val); + panel.Children.Add(row); + } + + if (child.HasActualStats) + AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); + + AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); + + var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; + var estimatedRowsAllExec = child.EstimateRows * executions; + AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); + + if (child.EstimatedRowSize > 0) + { + AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); + var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; + AddRow("Estimated Data Size", FormatBytes(dataSize)); + } + + return new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6), + CornerRadius = new CornerRadius(4), + Child = panel + }; + } + + private static string FormatBytes(double bytes) + { + if (bytes < 1024) return $"{bytes:N0} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; + return $"{bytes / (1024L * 1024 * 1024):N1} GB"; + } + #endregion #region Node Selection & Properties Panel @@ -694,6 +767,48 @@ private ContextMenu BuildNodeContextMenu(PlanNode node) return menu; } + private ContextMenu BuildCanvasContextMenu() + { + var menu = new ContextMenu(); + + // Zoom + var zoomInItem = new MenuItem { Header = "Zoom In" }; + zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); + menu.Items.Add(zoomInItem); + + var zoomOutItem = new MenuItem { Header = "Zoom Out" }; + zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); + menu.Items.Add(zoomOutItem); + + var fitItem = new MenuItem { Header = "Fit to View" }; + fitItem.Click += ZoomFit_Click; + menu.Items.Add(fitItem); + + menu.Items.Add(new Separator()); + + // Advice + var humanAdviceItem = new MenuItem { Header = "Human Advice" }; + humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(humanAdviceItem); + + var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; + robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(robotAdviceItem); + + menu.Items.Add(new Separator()); + + // Repro & Save + var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; + copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(copyReproItem); + + var saveItem = new MenuItem { Header = "Save .sqlplan" }; + saveItem.Click += SavePlan_Click; + menu.Items.Add(saveItem); + + return menu; + } + private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) { var topLevel = TopLevel.GetTopLevel(this); @@ -788,7 +903,7 @@ private void ShowPropertiesPanel(PlanNode node) || !string.IsNullOrEmpty(node.InnerSideJoinColumns) || !string.IsNullOrEmpty(node.OuterSideJoinColumns) || !string.IsNullOrEmpty(node.ActionColumn) - || node.ManyToMany || node.BitmapCreator + || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator || node.SortDistinct || node.StartupExpression || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || node.WithTies || node.Remoting || node.LocalParallelism @@ -853,8 +968,10 @@ private void ShowPropertiesPanel(PlanNode node) AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); - if (node.ManyToMany) - AddPropertyRow("Many to Many", "True"); + if (node.PhysicalOp == "Merge Join") + AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); + else if (node.ManyToMany) + AddPropertyRow("Many to Many", "Yes"); if (!string.IsNullOrEmpty(node.ConstantScanValues)) AddPropertyRow("Values", node.ConstantScanValues, isCode: true); if (!string.IsNullOrEmpty(node.UdxUsedColumns)) @@ -2718,6 +2835,7 @@ private void ZoomFit_Click(object? sender, RoutedEventArgs e) var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); SetZoom(Math.Min(fitZoom, 1.0)); + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); } private void SetZoom(double level) diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index bc9d8a1..e586162 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -507,6 +507,11 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) } }; + // Wire up context menu events from PlanViewerControl + viewer.HumanAdviceRequested += (_, _) => humanBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + viewer.RobotAdviceRequested += (_, _) => robotBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + viewer.CopyReproRequested += async (_, _) => copyReproBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + var getActualPlanBtn = new Button { Content = "\u25b6 Run Repro", diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 6960a81..81ac926 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -274,54 +274,44 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) }); } - // Rule 25: Ineffective parallelism — parallel plan where CPU ≈ elapsed - // In an effective parallel plan, CPU time should be significantly higher than - // elapsed time because multiple threads are working simultaneously. - // When they're nearly equal (ratio 0.8–1.3), the work ran essentially serially. + // Rule 25: Ineffective parallelism — DOP-aware efficiency scoring + // Efficiency = (speedup - 1) / (DOP - 1) * 100 + // where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%). + // Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working. if (!cfg.IsRuleDisabled(25) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null) { var cpu = stmt.QueryTimeStats.CpuTimeMs; var elapsed = stmt.QueryTimeStats.ElapsedTimeMs; + var dop = stmt.DegreeOfParallelism; if (elapsed >= 1000 && cpu > 0) { - var ratio = (double)cpu / elapsed; - if (ratio >= 0.8 && ratio <= 1.3) + var speedup = (double)cpu / elapsed; + var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0)); + + if (speedup < 0.5 && !cfg.IsRuleDisabled(31)) { + // CPU well below Elapsed: threads are waiting, not doing CPU work + var waitPct = (1.0 - speedup) * 100; stmt.PlanWarnings.Add(new PlanWarning { - WarningType = "Ineffective Parallelism", - Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) but CPU time ({cpu:N0}ms) is nearly equal to elapsed time ({elapsed:N0}ms). " + - $"The work ran essentially serially despite the overhead of parallelism. " + - $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + WarningType = "Parallel Wait Bottleneck", + Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " + + $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + + $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", Severity = PlanWarningSeverity.Warning }); } - } - } - - // Rule 31: Parallel wait bottleneck — elapsed time significantly exceeds CPU time - // When elapsed >> CPU in a parallel plan, threads are spending time waiting rather - // than doing CPU work. Common causes: spills to tempdb, physical I/O, blocking, - // memory grant waits, or other resource contention. - if (!cfg.IsRuleDisabled(31) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null) - { - var cpu = stmt.QueryTimeStats.CpuTimeMs; - var elapsed = stmt.QueryTimeStats.ElapsedTimeMs; - - if (elapsed >= 1000 && cpu > 0) - { - var ratio = (double)cpu / elapsed; - if (ratio < 0.8) + else if (efficiency < 40) { - var waitPct = (1.0 - ratio) * 100; + // CPU >= Elapsed but well below DOP potential — parallelism is ineffective stmt.PlanWarnings.Add(new PlanWarning { - WarningType = "Parallel Wait Bottleneck", - Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) with elapsed time ({elapsed:N0}ms) significantly exceeding CPU time ({cpu:N0}ms). " + - $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + - $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", - Severity = PlanWarningSeverity.Warning + WarningType = "Ineffective Parallelism", + Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " + + $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " + + $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } } diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index dd06547..e8815ab 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -567,13 +567,13 @@ public void Rule29_ImplicitConvertSeekPlan_UpgradedToCritical() [Fact] public void Rule25_IneffectiveParallelism_DetectedWhenCpuEqualsElapsed() { - // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (ratio ~1.0) + // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (efficiency ~0%) var plan = PlanTestHelper.LoadAndAnalyze("serially-parallel.sqlplan"); var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism"); Assert.Single(warnings); Assert.Contains("DOP 8", warnings[0].Message); - Assert.Contains("ran essentially serially", warnings[0].Message); + Assert.Contains("% efficient", warnings[0].Message); } [Fact] @@ -637,13 +637,15 @@ public void Rule30_MissingIndexQuality_DetectsWideOrLowImpact() public void Rule31_ParallelWaitBottleneck_DetectedWhenElapsedExceedsCpu() { // excellent-parallel-spill: DOP 4, CPU 172,222ms vs elapsed 225,870ms - // ratio ~0.76 (< 0.8) — threads are waiting more than working + // speedup ~0.76 — CPU < Elapsed but >= 0.5, so fires as Ineffective Parallelism + // (wait bottleneck only fires when speedup < 0.5 — extreme waiting) var plan = PlanTestHelper.LoadAndAnalyze("excellent-parallel-spill.sqlplan"); - var warnings = PlanTestHelper.WarningsOfType(plan, "Parallel Wait Bottleneck"); - Assert.Single(warnings); + // At DOP 4 with speedup 0.76, efficiency ≈ 0% — fires Ineffective Parallelism + var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism"); + Assert.NotEmpty(warnings); Assert.Contains("DOP 4", warnings[0].Message); - Assert.Contains("waiting", warnings[0].Message); + Assert.Contains("% efficient", warnings[0].Message); } // --------------------------------------------------------------- From 3febb93d01a9f642adf89919ae7d8b02e2bd7058 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:20:36 -0400 Subject: [PATCH 3/9] Advice for Humans UX polish: operator grouping, bars, colors, margins (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Operator grouping, margin standardization, and elapsed time visibility - Group operator name + timing + CPU bar + stats (rows/reads) in a single container with purple left accent border for clear visual association (#14) - Standardize left margins to three tiers: 8px labels, 12px content, 20px nested detail — eliminates ragged left edge (#15) - Use ValueBrush for both CPU and elapsed timing values instead of dimming elapsed with MutedBrush (#16) Co-Authored-By: Claude Opus 4.6 * Add proportional bars to wait stats in Advice for Humans Collect all wait stat lines in a group, find the global max, then render each with a colored bar scaled proportionally. Bar color matches wait category (orange=CPU/parallelism, red=I/O/locks, purple=memory, blue=network). Closes #48 item 7. Co-Authored-By: Claude Opus 4.6 * Triage card headline hierarchy — first item 13px semi-bold The most significant finding in each triage card (parallel efficiency, memory grant, etc.) now renders at 13px semi-bold to establish visual hierarchy over the remaining 12px normal-weight items. Co-Authored-By: Claude Opus 4.6 * Color-code missing index impact percentage by severity Impact line (e.g., "dbo.Posts (impact: 95%)") now renders table name in white and impact in color: red ≥70%, amber ≥40%, blue <40%. Co-Authored-By: Claude Opus 4.6 * Memory grant contextual color in triage card Color by utilization: red <10% used, amber 10-49%, blue ≥50%. Previously was binary amber/blue at 10% threshold. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../Services/AdviceContentBuilder.cs | 410 ++++++++++++++++-- 1 file changed, 370 insertions(+), 40 deletions(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index 35f1c37..eeff7d6 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -1,8 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using Avalonia.Controls; using Avalonia.Controls.Documents; +using Avalonia.Layout; using Avalonia.Media; +using PlanViewer.Core.Output; namespace PlanViewer.App.Services; @@ -24,8 +28,13 @@ internal static class AdviceContentBuilder private static readonly SolidColorBrush SqlKeywordBrush = new(Color.Parse("#569CD6")); private static readonly SolidColorBrush SeparatorBrush = new(Color.Parse("#2A2D35")); private static readonly SolidColorBrush WarningAccentBrush = new(Color.Parse("#332A1A")); + private static readonly SolidColorBrush CardBackgroundBrush = new(Color.Parse("#1A2233")); + private static readonly SolidColorBrush AmberBarBrush = new(Color.Parse("#FFB347")); + private static readonly SolidColorBrush BlueBarBrush = new(Color.Parse("#4FA3FF")); private static readonly FontFamily MonoFont = new("Consolas, Menlo, monospace"); + private const double MaxBarWidth = 200.0; + private static readonly HashSet PhysicalOperators = new(StringComparer.OrdinalIgnoreCase) { "Sort", "Filter", "Bitmap", "Hash Match", "Merge Join", "Nested Loops", @@ -55,7 +64,14 @@ internal static class AdviceContentBuilder "NOLOCK", "READUNCOMMITTED", "READCOMMITTED", "SERIALIZABLE", "HOLDLOCK" }; + private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled); + public static StackPanel Build(string content) + { + return Build(content, null); + } + + public static StackPanel Build(string content, AnalysisResult? analysis) { var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) }; var lines = content.Split('\n'); @@ -63,6 +79,8 @@ public static StackPanel Build(string content) var codeBlockIndent = 0; var isStatementText = false; var inSubSection = false; // tracks sub-sections within a statement + var statementIndex = -1; // tracks which statement we're in (0-based) + var needsTriageCard = false; // inject card on next blank line after SQL text for (int i = 0; i < lines.Length; i++) { @@ -71,7 +89,17 @@ public static StackPanel Build(string content) // Empty lines — small spacer if (string.IsNullOrWhiteSpace(line)) { - panel.Children.Add(new Border { Height = 6 }); + // Inject triage card on the blank line between SQL text and details + if (needsTriageCard && analysis != null && statementIndex >= 0 + && statementIndex < analysis.Statements.Count) + { + var card = CreateTriageSummaryCard(analysis.Statements[statementIndex]); + if (card != null) + panel.Children.Add(card); + needsTriageCard = false; + } + + panel.Children.Add(new Border { Height = 8 }); inCodeBlock = false; isStatementText = false; inSubSection = false; @@ -112,7 +140,11 @@ public static StackPanel Build(string content) // Statement text follows "Statement N:" if (headerText.StartsWith("Statement")) + { isStatementText = true; + statementIndex++; + needsTriageCard = true; + } continue; } @@ -120,7 +152,6 @@ public static StackPanel Build(string content) // Statement text (SQL) — highlight keywords if (isStatementText) { - isStatementText = false; panel.Children.Add(BuildSqlHighlightedLine(line)); continue; } @@ -154,7 +185,7 @@ public static StackPanel Build(string content) FontSize = 12, FontStyle = Avalonia.Media.FontStyle.Italic, Foreground = MutedBrush, - Margin = new Avalonia.Thickness(16, 2, 0, 4), + Margin = new Avalonia.Thickness(20, 2, 0, 4), TextWrapping = TextWrapping.Wrap }); continue; @@ -168,7 +199,7 @@ public static StackPanel Build(string content) FontFamily = MonoFont, FontSize = 12, TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(8, 1, 0, 1) + Margin = new Avalonia.Thickness(12, 1, 0, 1) }; var sniffIdx = line.IndexOf("[SNIFFING]"); tb.Inlines!.Add(new Run(line[..sniffIdx].TrimStart()) { Foreground = ValueBrush }); @@ -178,6 +209,13 @@ public static StackPanel Build(string content) continue; } + // Missing index impact line: "dbo.Posts (impact: 95%)" + if (trimmed.Contains("(impact:") && trimmed.EndsWith("%)")) + { + panel.Children.Add(CreateMissingIndexImpactLine(trimmed)); + continue; + } + // CREATE INDEX lines (multi-line: CREATE..., ON..., INCLUDE..., WHERE...) if (trimmed.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase)) { @@ -209,26 +247,51 @@ public static StackPanel Build(string content) FontSize = 12, Foreground = CodeBrush, TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(8, 1, 0, 1) + Margin = new Avalonia.Thickness(12, 1, 0, 1) }); continue; } - // Expensive operator timing lines: "4,616ms CPU (61%), 586ms elapsed (62%)" - // Must start with a digit to avoid catching "Runtime: 1,234ms elapsed, 1,200ms CPU" - if ((trimmed.Contains("ms CPU") || trimmed.Contains("ms elapsed")) - && trimmed.Length > 0 && char.IsDigit(trimmed[0])) + // Expensive operators section: highlight operator name + grouped timing + stats + // Handles both "Operator (Object):" and bare "Sort:" forms + if (trimmed.EndsWith("):") || + (trimmed.EndsWith(":") && PhysicalOperators.Contains(trimmed[..^1]))) { - panel.Children.Add(CreateOperatorTimingLine(trimmed)); + // Peek ahead for timing line and stats line to group with operator + string? timingLine = null; + string? statsLine = null; + var peekIdx = i + 1; + if (peekIdx < lines.Length) + { + var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart(); + if ((nextTrimmed.Contains("ms CPU") || nextTrimmed.Contains("ms elapsed")) + && nextTrimmed.Length > 0 && char.IsDigit(nextTrimmed[0])) + { + timingLine = nextTrimmed; + peekIdx++; + } + } + // Stats line: "17,142,169 rows, 4,691,534 logical reads, 884 physical reads" + if (peekIdx < lines.Length) + { + var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart(); + if (nextTrimmed.Contains("rows") && nextTrimmed.Length > 0 + && char.IsDigit(nextTrimmed[0])) + { + statsLine = nextTrimmed; + peekIdx++; + } + } + i = peekIdx - 1; // skip consumed lines + panel.Children.Add(CreateOperatorGroup(line, timingLine, statsLine)); continue; } - // Expensive operators section: highlight operator name - // Handles both "Operator (Object):" and bare "Sort:" forms - if (trimmed.EndsWith("):") || - (trimmed.EndsWith(":") && PhysicalOperators.Contains(trimmed[..^1]))) + // Standalone timing lines (fallback for lines not grouped with an operator) + if ((trimmed.Contains("ms CPU") || trimmed.Contains("ms elapsed")) + && trimmed.Length > 0 && char.IsDigit(trimmed[0])) { - panel.Children.Add(CreateOperatorLine(line)); + panel.Children.Add(CreateOperatorTimingLine(trimmed)); continue; } @@ -239,12 +302,12 @@ public static StackPanel Build(string content) inSubSection = true; panel.Children.Add(new SelectableTextBlock { - Text = " " + trimmed, + Text = trimmed, FontFamily = MonoFont, - FontSize = 12, + FontSize = 13, FontWeight = FontWeight.SemiBold, Foreground = LabelBrush, - Margin = new Avalonia.Thickness(0, 6, 0, 2), + Margin = new Avalonia.Thickness(8, 6, 0, 4), TextWrapping = TextWrapping.Wrap }); continue; @@ -259,13 +322,14 @@ public static StackPanel Build(string content) FontFamily = MonoFont, FontSize = 12, Foreground = MutedBrush, - Margin = new Avalonia.Thickness(8, 1, 0, 1), + Margin = new Avalonia.Thickness(12, 1, 0, 1), TextWrapping = TextWrapping.Wrap }); continue; } - // Wait stats lines: " WAITTYPE: 1,234ms" — color by category + // Wait stats lines: " WAITTYPE: 1,234ms" — color by category with proportional bars + // Collect entire group, find global max, then render all with consistent bar scaling if (trimmed.Contains("ms") && trimmed.Contains(':')) { var waitColon = trimmed.IndexOf(':'); @@ -275,7 +339,38 @@ public static StackPanel Build(string content) var waitValue = trimmed[(waitColon + 1)..].Trim(); if (waitValue.EndsWith("ms") && waitName == waitName.ToUpperInvariant() && !waitName.Contains(' ')) { - panel.Children.Add(CreateWaitStatLine(waitName, waitValue, inSubSection)); + // Collect all wait stat lines in this group + var waitGroup = new List<(string name, string value)> + { + (waitName, waitValue) + }; + while (i + 1 < lines.Length) + { + var nextLine = lines[i + 1].TrimEnd('\r').TrimStart(); + if (string.IsNullOrWhiteSpace(nextLine)) break; + var nextColon = nextLine.IndexOf(':'); + if (nextColon <= 0 || nextColon >= nextLine.Length - 1) break; + var nextName = nextLine[..nextColon]; + var nextVal = nextLine[(nextColon + 1)..].Trim(); + if (!nextVal.EndsWith("ms") || nextName != nextName.ToUpperInvariant() + || nextName.Contains(' ')) + break; + waitGroup.Add((nextName, nextVal)); + i++; + } + + // Find global max for bar scaling + var maxWaitMs = 0.0; + foreach (var (_, val) in waitGroup) + { + var ms = ParseWaitMs(val); + if (ms > maxWaitMs) maxWaitMs = ms; + } + + // Render all lines with consistent scaling + foreach (var (name, val) in waitGroup) + panel.Children.Add(CreateWaitStatLine(name, val, maxWaitMs)); + continue; } } @@ -288,7 +383,7 @@ public static StackPanel Build(string content) var labelPart = line[..colonIdx].TrimStart(); if (labelPart.Length < 40 && !labelPart.Contains('(') && !labelPart.Contains('=')) { - var indent = inSubSection ? 8.0 : 0.0; + var indent = inSubSection ? 12.0 : 8.0; var tb = new SelectableTextBlock { FontFamily = MonoFont, @@ -335,7 +430,7 @@ private static SelectableTextBlock BuildSqlHighlightedLine(string line) FontFamily = MonoFont, FontSize = 12, TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(4, 1, 0, 1) + Margin = new Avalonia.Thickness(8, 1, 0, 1) }; int pos = 0; @@ -375,7 +470,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr FontFamily = MonoFont, FontSize = 12, TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(8, 2, 0, 2) + Margin = new Avalonia.Thickness(8, 3, 0, 3) }; foreach (var tag in new[] { "[Critical]", "[Warning]", "[Info]" }) @@ -417,10 +512,10 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr tb.Inlines.Add(new Run("\n" + part) { Foreground = ValueBrush }); } - else if (part.StartsWith("• ")) + else if (part.StartsWith("\u2022 ")) { // Bullet stats: bullet in muted, value in white - tb.Inlines.Add(new Run("\n • ") + tb.Inlines.Add(new Run("\n \u2022 ") { Foreground = MutedBrush }); tb.Inlines.Add(new Run(part[2..]) { Foreground = ValueBrush }); @@ -455,7 +550,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr BorderBrush = severityBrush, BorderThickness = new Avalonia.Thickness(2, 0, 0, 0), Padding = new Avalonia.Thickness(0), - Margin = new Avalonia.Thickness(4, 2, 0, 2), + Margin = new Avalonia.Thickness(12, 4, 0, 4), Child = tb }; } @@ -468,7 +563,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr BorderBrush = severityBrush, BorderThickness = new Avalonia.Thickness(2, 0, 0, 0), Padding = new Avalonia.Thickness(0), - Margin = new Avalonia.Thickness(4, 2, 0, 2), + Margin = new Avalonia.Thickness(12, 4, 0, 4), Child = tb }; } @@ -503,22 +598,73 @@ private static SelectableTextBlock CreateOperatorLine(string line) return tb; } + /// + /// Groups an operator name with its timing line, CPU bar, and stats in a single + /// container with a purple left accent border for clear visual association. + /// + private static Border CreateOperatorGroup(string operatorLine, string? timingLine, string? statsLine) + { + var groupPanel = new StackPanel(); + + // Operator name (no extra margin — Border provides it) + var opTb = CreateOperatorLine(operatorLine); + opTb.Margin = new Avalonia.Thickness(0); + groupPanel.Children.Add(opTb); + + // Timing + CPU bar + if (timingLine != null) + { + var timingPanel = CreateOperatorTimingLine(timingLine); + timingPanel.Margin = new Avalonia.Thickness(4, 2, 0, 0); + groupPanel.Children.Add(timingPanel); + } + + // Stats: rows, logical reads, physical reads + if (statsLine != null) + { + groupPanel.Children.Add(new SelectableTextBlock + { + Text = statsLine, + FontFamily = MonoFont, + FontSize = 12, + Foreground = MutedBrush, + Margin = new Avalonia.Thickness(4, 0, 0, 0), + TextWrapping = TextWrapping.Wrap + }); + } + + return new Border + { + BorderBrush = OperatorBrush, + BorderThickness = new Avalonia.Thickness(2, 0, 0, 0), + Padding = new Avalonia.Thickness(8, 2, 0, 2), + Margin = new Avalonia.Thickness(12, 2, 0, 4), + Child = groupPanel + }; + } + /// /// Renders timing line like "4,616ms CPU (61%), 586ms elapsed (62%)" - /// with ms values in white and percentages in amber. + /// with ms values in white and percentages in amber, plus a proportional bar. /// - private static SelectableTextBlock CreateOperatorTimingLine(string trimmed) + private static StackPanel CreateOperatorTimingLine(string trimmed) { + var wrapper = new StackPanel + { + Margin = new Avalonia.Thickness(16, 1, 0, 1) + }; + var tb = new SelectableTextBlock { FontFamily = MonoFont, FontSize = 12, - TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(16, 1, 0, 1) + TextWrapping = TextWrapping.Wrap }; // Split by ", " to get timing parts like "4,616ms CPU (61%)" and "586ms elapsed (62%)" var parts = trimmed.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries); + int? cpuPct = null; + for (int i = 0; i < parts.Length; i++) { if (i > 0) @@ -531,38 +677,123 @@ private static SelectableTextBlock CreateOperatorTimingLine(string trimmed) { var timePart = part[..pctStart].TrimEnd(); var pctPart = part[pctStart..]; - var brush = timePart.Contains("CPU") ? ValueBrush : MutedBrush; + var brush = ValueBrush; tb.Inlines!.Add(new Run(timePart) { Foreground = brush }); - tb.Inlines.Add(new Run(" " + pctPart) { Foreground = WarningBrush }); + tb.Inlines.Add(new Run(" " + pctPart) { Foreground = WarningBrush, FontSize = 11 }); + + // Capture CPU percentage for the bar + if (timePart.Contains("CPU")) + { + var match = CpuPercentRegex.Match(part); + if (match.Success && int.TryParse(match.Groups[1].Value, out var pctVal)) + cpuPct = pctVal; + } } else { - var brush = part.Contains("CPU") ? ValueBrush : MutedBrush; + var brush = ValueBrush; tb.Inlines!.Add(new Run(part) { Foreground = brush }); } } - return tb; + wrapper.Children.Add(tb); + + // Add proportional CPU bar + if (cpuPct.HasValue && cpuPct.Value > 0) + { + wrapper.Children.Add(new Border + { + Width = MaxBarWidth * (cpuPct.Value / 100.0), + Height = 4, + Background = AmberBarBrush, + CornerRadius = new Avalonia.CornerRadius(2), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Avalonia.Thickness(0, 0, 0, 4) + }); + } + + return wrapper; } - private static SelectableTextBlock CreateWaitStatLine(string waitName, string waitValue, bool indented) + private static StackPanel CreateWaitStatLine(string waitName, string waitValue, double maxWaitMs) { - var leftMargin = indented ? 16.0 : 8.0; + var wrapper = new StackPanel + { + Margin = new Avalonia.Thickness(12, 1, 0, 1) + }; + var tb = new SelectableTextBlock { FontFamily = MonoFont, FontSize = 12, - TextWrapping = TextWrapping.Wrap, - Margin = new Avalonia.Thickness(leftMargin, 1, 0, 1) + TextWrapping = TextWrapping.Wrap }; var waitBrush = GetWaitCategoryBrush(waitName); tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush }); tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush }); + wrapper.Children.Add(tb); + + // Proportional bar scaled to max wait in group + var ms = ParseWaitMs(waitValue); + if (ms > 0 && maxWaitMs > 0) + { + var barWidth = MaxBarWidth * (ms / maxWaitMs); + wrapper.Children.Add(new Border + { + Width = Math.Max(2, barWidth), + Height = 4, + Background = waitBrush, + CornerRadius = new Avalonia.CornerRadius(2), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Avalonia.Thickness(0, 0, 0, 2) + }); + } + + return wrapper; + } + + /// + /// Renders a missing index impact line like "dbo.Posts (impact: 95%)" with + /// the table name in value color and the impact colored by severity. + /// + private static SelectableTextBlock CreateMissingIndexImpactLine(string trimmed) + { + var tb = new SelectableTextBlock + { + FontFamily = MonoFont, + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Margin = new Avalonia.Thickness(12, 2, 0, 0) + }; + + var impactStart = trimmed.IndexOf("(impact:"); + var tableName = trimmed[..impactStart].TrimEnd(); + var impactPart = trimmed[impactStart..]; + + // Parse the percentage to pick a color + var pctStr = impactPart.Replace("(impact:", "").Replace("%)", "").Trim(); + var impactBrush = MutedBrush; + if (double.TryParse(pctStr, out var pct)) + { + impactBrush = pct >= 70 ? CriticalBrush : (pct >= 40 ? WarningBrush : InfoBrush); + } + + tb.Inlines!.Add(new Run(tableName + " ") { Foreground = ValueBrush }); + tb.Inlines.Add(new Run(impactPart) { Foreground = impactBrush, FontWeight = FontWeight.SemiBold }); return tb; } + /// + /// Parses a wait stat value like "1,234ms" into a double. + /// + private static double ParseWaitMs(string waitValue) + { + var numStr = waitValue.Replace("ms", "").Replace(",", "").Trim(); + return double.TryParse(numStr, out var val) ? val : 0; + } + private static SolidColorBrush GetWaitCategoryBrush(string waitType) { // CPU-related @@ -590,4 +821,103 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType) return LabelBrush; // default muted } + + /// + /// Creates a per-statement triage summary card showing key findings at a glance. + /// + private static Border? CreateTriageSummaryCard(StatementResult stmt) + { + var items = new List<(string text, SolidColorBrush brush)>(); + + // Parallel efficiency + var dop = stmt.DegreeOfParallelism; + if (dop > 1 && stmt.QueryTime != null && stmt.QueryTime.ElapsedTimeMs > 0) + { + var cpuMs = (double)stmt.QueryTime.CpuTimeMs; + var elapsedMs = (double)stmt.QueryTime.ElapsedTimeMs; + // efficiency = (cpu/elapsed - 1) / (dop - 1) * 100, clamped 0-100 + var ratio = cpuMs / elapsedMs; + var efficiency = (ratio - 1.0) / (dop - 1.0) * 100.0; + efficiency = Math.Clamp(efficiency, 0, 100); + var effBrush = efficiency < 50 ? CriticalBrush : (efficiency < 75 ? WarningBrush : InfoBrush); + items.Add(($"\u26A0 {efficiency:F0}% parallel efficiency (DOP {dop})", effBrush)); + } + + // Memory grant — color by utilization efficiency + if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) + { + var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0; + var usedPct = stmt.MemoryGrant.MaxUsedKB > 0 + ? (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100.0 + : 0.0; + // Red: <10% used (massive waste), Amber: <50%, Blue: <80%, Green-ish (info): >=80% + var memBrush = usedPct < 10 ? CriticalBrush + : usedPct < 50 ? WarningBrush + : InfoBrush; + items.Add(($"Memory grant: {grantedMB:F1} MB ({usedPct:F0}% used)", memBrush)); + } + + // Warning counts by severity + var criticalCount = stmt.Warnings.Count(w => + w.Severity.Equals("Critical", StringComparison.OrdinalIgnoreCase)); + var warningCount = stmt.Warnings.Count(w => + w.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase)); + if (criticalCount > 0 || warningCount > 0) + { + var parts = new List(); + if (criticalCount > 0) + parts.Add($"{criticalCount} critical"); + if (warningCount > 0) + parts.Add($"{warningCount} warning{(warningCount != 1 ? "s" : "")}"); + var countBrush = criticalCount > 0 ? CriticalBrush : WarningBrush; + items.Add((string.Join(", ", parts), countBrush)); + } + + // Missing indexes + if (stmt.MissingIndexes.Count > 0) + { + items.Add(($"{stmt.MissingIndexes.Count} missing index suggestion{(stmt.MissingIndexes.Count != 1 ? "s" : "")}", InfoBrush)); + } + + // Spill warnings + var spillCount = stmt.Warnings.Count(w => + w.Type.Contains("Spill", StringComparison.OrdinalIgnoreCase)); + if (spillCount > 0) + { + items.Add(($"{spillCount} spill warning{(spillCount != 1 ? "s" : "")}", CriticalBrush)); + } + + if (items.Count == 0) + return null; + + var cardPanel = new StackPanel + { + Margin = new Avalonia.Thickness(4) + }; + + for (int idx = 0; idx < items.Count; idx++) + { + var (text, brush) = items[idx]; + var isHeadline = idx == 0; + cardPanel.Children.Add(new SelectableTextBlock + { + Text = text, + FontFamily = MonoFont, + FontSize = isHeadline ? 13 : 12, + FontWeight = isHeadline ? FontWeight.SemiBold : FontWeight.Normal, + Foreground = brush, + Margin = new Avalonia.Thickness(4, 2, 0, 2), + TextWrapping = TextWrapping.Wrap + }); + } + + return new Border + { + Background = CardBackgroundBrush, + CornerRadius = new Avalonia.CornerRadius(6), + Padding = new Avalonia.Thickness(8, 4, 8, 4), + Margin = new Avalonia.Thickness(0, 4, 0, 6), + Child = cardPanel + }; + } } From 079a3d7c2f48abcf6896d16942f803470b0b69ea Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:23:44 -0400 Subject: [PATCH 4/9] Add nightly build workflow (#71) Builds dev branch daily at 6AM UTC for all 4 platforms (win-x64, linux-x64, osx-x64, osx-arm64) with macOS .app bundling. Skips if no commits in last 24 hours. Creates rolling "nightly" prerelease tag with checksums. Can also be triggered manually. Co-authored-by: Claude Opus 4.6 --- .github/workflows/nightly.yml | 161 ++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..822bafc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,161 @@ +name: Nightly Build + +on: + schedule: + # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT) + - cron: '0 6 * * *' + workflow_dispatch: # manual trigger + +permissions: + contents: write + +jobs: + check: + runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: Check for new commits in last 24 hours + id: check + run: | + RECENT=$(git log --since="24 hours ago" --oneline | head -1) + if [ -n "$RECENT" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "New commits found — building nightly" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No new commits — skipping nightly build" + fi + + build: + needs: check + if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: dev + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Set nightly version + id: version + shell: pwsh + run: | + $base = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $date = Get-Date -Format "yyyyMMdd" + $nightly = "$base-nightly.$date" + echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT + echo "Nightly version: $nightly" + + - name: Restore dependencies + run: | + dotnet restore src/PlanViewer.Core/PlanViewer.Core.csproj + dotnet restore src/PlanViewer.App/PlanViewer.App.csproj + dotnet restore src/PlanViewer.Cli/PlanViewer.Cli.csproj + dotnet restore tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj + + - name: Run tests + run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal + + - name: Publish App (all platforms) + run: | + dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64 + dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64 + dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64 + dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64 + + - name: Package artifacts + shell: pwsh + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + New-Item -ItemType Directory -Force -Path releases + + # Package Windows and Linux as flat zips + foreach ($rid in @('win-x64', 'linux-x64')) { + if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" } + Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force + } + + # Package macOS as proper .app bundles + foreach ($rid in @('osx-x64', 'osx-arm64')) { + $appName = "PerformanceStudio.app" + $bundleDir = "publish/$rid-bundle/$appName" + + New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS" + New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources" + + Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse + + if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") { + Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force + } + + $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw + $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}" + $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}" + Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline + + if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") { + Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force + } + + $wrapperDir = "publish/$rid-bundle" + if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" } + + Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force + } + + - name: Generate checksums + shell: pwsh + run: | + $checksums = Get-ChildItem releases/*.zip | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" + } + $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8 + Write-Host "Checksums:" + $checksums | ForEach-Object { Write-Host $_ } + + - name: Delete previous nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0 + shell: pwsh + + - name: Create nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + $sha = git rev-parse --short HEAD + $body = @" + Automated nightly build from ``dev`` branch. + + **Version:** ``$version`` + **Commit:** ``$sha`` + **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC") + + > These builds include the latest changes and may be unstable. + > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest). + "@ + + gh release create nightly ` + --target dev ` + --title "Nightly Build ($version)" ` + --notes $body ` + --prerelease ` + releases/*.zip releases/SHA256SUMS.txt From 453c238657d3fdc13bde89cf0dab4757ed385ff2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:25:54 -0400 Subject: [PATCH 5/9] Pass AnalysisResult through to AdviceContentBuilder (#72) Thread analysis data to ShowAdviceWindow so triage cards and other structured features have access to parsed statement results. Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs | 6 +++--- src/PlanViewer.App/MainWindow.axaml.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index d0f0fd4..fadeed6 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -694,7 +694,7 @@ private void HumanAdvice_Click(object? sender, RoutedEventArgs e) if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } var text = TextFormatter.Format(analysis); - ShowAdviceWindow("Advice for Humans", text); + ShowAdviceWindow("Advice for Humans", text, analysis); } private void RobotAdvice_Click(object? sender, RoutedEventArgs e) @@ -706,9 +706,9 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e) ShowAdviceWindow("Advice for Robots", json); } - private void ShowAdviceWindow(string title, string content) + private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null) { - var styledContent = AdviceContentBuilder.Build(content); + var styledContent = AdviceContentBuilder.Build(content, analysis); var scrollViewer = new ScrollViewer { diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index e586162..d01fd56 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -444,7 +444,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) { if (viewer.CurrentPlan == null) return; var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata); - ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis)); + ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis); }; robotBtn.Click += (_, _) => @@ -566,9 +566,9 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) return panel; } - private void ShowAdviceWindow(string title, string content) + private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null) { - var styledContent = AdviceContentBuilder.Build(content); + var styledContent = AdviceContentBuilder.Build(content, analysis); var scrollViewer = new ScrollViewer { From 7f57489f0bd7bd07eb0681e912e271bd5ad18d16 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:48:31 -0400 Subject: [PATCH 6/9] Fix false positive parallel skew warning at DOP 2 (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter out thread 0 (coordinator) from skew calculation since it typically processes 0 rows in parallel operators, inflating the thread count. Raise the DOP 2 threshold from 75% to 80% — a 53/47 split is perfectly normal with only 2 worker threads. Fixes #73. Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 81ac926..f16e976 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -600,15 +600,19 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi // Rule 8: Parallel thread skew (actual plans with per-thread stats) // Only warn when there are enough rows to meaningfully distribute across threads + // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1) { - var totalRows = node.PerThreadStats.Sum(t => t.ActualRows); - var minRowsForSkew = node.PerThreadStats.Count * 1000; + var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList(); + if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback + var totalRows = workerThreads.Sum(t => t.ActualRows); + var minRowsForSkew = workerThreads.Count * 1000; if (totalRows >= minRowsForSkew) { - var maxThread = node.PerThreadStats.OrderByDescending(t => t.ActualRows).First(); + var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First(); var skewRatio = (double)maxThread.ActualRows / totalRows; - var skewThreshold = node.PerThreadStats.Count == 2 ? 0.75 : 0.50; + // At DOP 2, a 60/40 split is normal — use higher threshold + var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50; if (skewRatio >= skewThreshold) { var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; From 9fe3f33120c7202c571981f7e078bf56b91120fc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:04:14 -0400 Subject: [PATCH 7/9] Add recent plans menu and restore open plans on startup (#67) (#75) - New AppSettingsService persists recent plans (last 10) and open session state to JSON in %LOCALAPPDATA%/PerformanceStudio/appsettings.json - File > Recent Plans submenu with clear option; gracefully handles moved/deleted files by removing them and notifying the user - On close, saves all open file-based plan tab paths; on next launch, restores them (falls back to a fresh query tab if none restored) - Only file-based plans are tracked (not clipboard paste or Query Store) Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.App/MainWindow.axaml | 2 + src/PlanViewer.App/MainWindow.axaml.cs | 148 +++++++++++++++++- .../Services/AppSettingsService.cs | 116 ++++++++++++++ 3 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 src/PlanViewer.App/Services/AppSettingsService.cs diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml index 68372c0..40e7c87 100644 --- a/src/PlanViewer.App/MainWindow.axaml +++ b/src/PlanViewer.App/MainWindow.axaml @@ -21,6 +21,8 @@ + + diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index d01fd56..dc61e5f 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -35,17 +35,22 @@ public partial class MainWindow : Window private McpHostService? _mcpHost; private CancellationTokenSource? _mcpCts; private int _queryCounter; + private AppSettings _appSettings; public MainWindow() { _credentialService = CredentialServiceFactory.Create(); _connectionStore = new ConnectionStore(); + _appSettings = AppSettingsService.Load(); // Listen for file paths from other instances (e.g. SSMS extension) StartPipeServer(); InitializeComponent(); + // Build the Recent Plans submenu from saved state + RebuildRecentPlansMenu(); + // Wire up drag-and-drop AddHandler(DragDrop.DropEvent, OnDrop); AddHandler(DragDrop.DragOverEvent, OnDragOver); @@ -88,7 +93,7 @@ public MainWindow() } }, RoutingStrategies.Tunnel); - // Accept command-line argument or open a default query editor + // Accept command-line argument or restore previously open plans var args = Environment.GetCommandLineArgs(); if (args.Length > 1 && File.Exists(args[1])) { @@ -96,8 +101,8 @@ public MainWindow() } else { - // Open with a query editor so toolbar buttons are visible on startup - NewQuery_Click(this, new RoutedEventArgs()); + // Restore plans that were open in the previous session + RestoreOpenPlans(); } // Start MCP server if enabled in settings @@ -162,6 +167,9 @@ private void StartMcpServer() protected override async void OnClosed(EventArgs e) { + // Save the list of currently open file-based plans for session restore + SaveOpenPlans(); + _pipeCts.Cancel(); if (_mcpHost != null && _mcpCts != null) @@ -360,6 +368,9 @@ private void LoadPlanFile(string filePath) MainTabControl.Items.Add(tab); MainTabControl.SelectedItem = tab; UpdateEmptyOverlay(); + + // Track in recent plans list and persist + TrackRecentPlan(filePath); } catch (Exception ex) { @@ -1175,6 +1186,137 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer) } } + // ── Recent Plans & Session Restore ──────────────────────────────────── + + /// + /// Adds a file path to the recent plans list, saves settings, and rebuilds the menu. + /// + private void TrackRecentPlan(string filePath) + { + AppSettingsService.AddRecentPlan(_appSettings, filePath); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + } + + /// + /// Rebuilds the Recent Plans submenu from the current settings. + /// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator. + /// + private void RebuildRecentPlansMenu() + { + RecentPlansMenu.Items.Clear(); + + if (_appSettings.RecentPlans.Count == 0) + { + var emptyItem = new MenuItem + { + Header = "(empty)", + IsEnabled = false + }; + RecentPlansMenu.Items.Add(emptyItem); + return; + } + + foreach (var path in _appSettings.RecentPlans) + { + var fileName = Path.GetFileName(path); + var directory = Path.GetDirectoryName(path) ?? ""; + + // Show "filename — directory" so the user can distinguish same-named files + var displayText = string.IsNullOrEmpty(directory) + ? fileName + : $"{fileName} — {directory}"; + + var item = new MenuItem + { + Header = displayText, + Tag = path + }; + + item.Click += RecentPlanItem_Click; + RecentPlansMenu.Items.Add(item); + } + + RecentPlansMenu.Items.Add(new Separator()); + + var clearItem = new MenuItem { Header = "Clear Recent Plans" }; + clearItem.Click += ClearRecentPlans_Click; + RecentPlansMenu.Items.Add(clearItem); + } + + private void RecentPlanItem_Click(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not string path) + return; + + if (!File.Exists(path)) + { + // File was moved or deleted — remove from the list and notify the user + AppSettingsService.RemoveRecentPlan(_appSettings, path); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + + ShowError($"The file no longer exists and has been removed from recent plans:\n\n{path}"); + return; + } + + LoadPlanFile(path); + } + + private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e) + { + _appSettings.RecentPlans.Clear(); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + } + + /// + /// Saves the file paths of all currently open file-based plan tabs. + /// + private void SaveOpenPlans() + { + _appSettings.OpenPlans.Clear(); + + foreach (var item in MainTabControl.Items) + { + if (item is not TabItem tab) continue; + + var path = GetTabFilePath(tab); + if (!string.IsNullOrEmpty(path)) + _appSettings.OpenPlans.Add(path); + } + + AppSettingsService.Save(_appSettings); + } + + /// + /// Restores plan tabs from the previous session. Skips files that no longer exist. + /// Falls back to a new query tab if nothing was restored. + /// + private void RestoreOpenPlans() + { + var restored = false; + + foreach (var path in _appSettings.OpenPlans) + { + if (File.Exists(path)) + { + LoadPlanFile(path); + restored = true; + } + } + + // Clear the open plans list now that we've restored + _appSettings.OpenPlans.Clear(); + AppSettingsService.Save(_appSettings); + + if (!restored) + { + // Nothing to restore — open a fresh query editor like before + NewQuery_Click(this, new RoutedEventArgs()); + } + } + private void ShowError(string message) { var dialog = new Window diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs new file mode 100644 index 0000000..e8faf34 --- /dev/null +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlanViewer.App.Services; + +/// +/// Persists recent plans and open session state to a JSON file in the app's local data directory. +/// +internal sealed class AppSettingsService +{ + private const int MaxRecentPlans = 10; + private static readonly string SettingsDir; + private static readonly string SettingsPath; + + static AppSettingsService() + { + SettingsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PerformanceStudio"); + SettingsPath = Path.Combine(SettingsDir, "appsettings.json"); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Loads settings from disk. Returns default settings if the file is missing or corrupt. + /// + public static AppSettings Load() + { + try + { + if (!File.Exists(SettingsPath)) + return new AppSettings(); + + var json = File.ReadAllText(SettingsPath); + var settings = JsonSerializer.Deserialize(json, JsonOptions); + return settings ?? new AppSettings(); + } + catch + { + return new AppSettings(); + } + } + + /// + /// Saves settings to disk. Silently ignores write failures. + /// + public static void Save(AppSettings settings) + { + try + { + Directory.CreateDirectory(SettingsDir); + var json = JsonSerializer.Serialize(settings, JsonOptions); + File.WriteAllText(SettingsPath, json); + } + catch + { + // Best-effort persistence — don't crash the app + } + } + + /// + /// Adds a file path to the recent plans list (most recent first). + /// Deduplicates by full path (case-insensitive on Windows). + /// + public static void AddRecentPlan(AppSettings settings, string filePath) + { + var fullPath = Path.GetFullPath(filePath); + + // Remove any existing entry for this path + settings.RecentPlans.RemoveAll(p => + string.Equals(p, fullPath, StringComparison.OrdinalIgnoreCase)); + + // Insert at the front + settings.RecentPlans.Insert(0, fullPath); + + // Trim to max size + if (settings.RecentPlans.Count > MaxRecentPlans) + settings.RecentPlans.RemoveRange(MaxRecentPlans, settings.RecentPlans.Count - MaxRecentPlans); + } + + /// + /// Removes a specific path from the recent plans list. + /// + public static void RemoveRecentPlan(AppSettings settings, string filePath) + { + settings.RecentPlans.RemoveAll(p => + string.Equals(p, filePath, StringComparison.OrdinalIgnoreCase)); + } +} + +/// +/// Serializable settings model for the application. +/// +internal sealed class AppSettings +{ + /// + /// Most recently opened plan file paths, newest first. Max 10. + /// + [JsonPropertyName("recent_plans")] + public List RecentPlans { get; set; } = new(); + + /// + /// File paths that were open when the app last closed — restored on next launch. + /// + [JsonPropertyName("open_plans")] + public List OpenPlans { get; set; } = new(); +} From b220373542a65626921de15c37f851b79527fa5d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:04:17 -0400 Subject: [PATCH 8/9] Format memory grants as KB/MB/GB instead of raw KB (#76) Add FormatMemoryGrantKB helper to TextFormatter that picks the most readable unit: KB under 1024, MB with 1 decimal up to 1 GB, GB with 2 decimals above. Applied in both the text output and the runtime summary pane. Closes #68 Co-authored-by: Claude Opus 4.6 --- .../Controls/PlanViewerControl.axaml.cs | 3 ++- src/PlanViewer.Core/Output/TextFormatter.cs | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 8edde32..363d967 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -18,6 +18,7 @@ using PlanViewer.App.Helpers; using PlanViewer.App.Mcp; using PlanViewer.Core.Models; +using PlanViewer.Core.Output; using PlanViewer.Core.Services; using AvaloniaPath = Avalonia.Controls.Shapes.Path; @@ -2622,7 +2623,7 @@ static string EfficiencyColor(double pct) => pct >= 80 ? "#E4E6EB" ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; var grantColor = EfficiencyColor(grantPct); AddRow("Memory grant", - $"{mg.GrantedMemoryKB:N0} KB granted, {mg.MaxUsedMemoryKB:N0} KB used ({grantPct:N0}%)", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", grantColor); if (mg.GrantWaitTimeMs > 0) AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index eb7f99e..04ccc80 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -62,13 +62,15 @@ public static void WriteText(AnalysisResult result, TextWriter writer) writer.WriteLine($"Runtime: {stmt.QueryTime.ElapsedTimeMs:N0}ms elapsed, {stmt.QueryTime.CpuTimeMs:N0}ms CPU"); if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) { - var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0; - var usedMB = stmt.MemoryGrant.MaxUsedKB / 1024.0; - var pctUsed = grantedMB > 0 ? usedMB / grantedMB * 100 : 0; + var pctUsed = stmt.MemoryGrant.GrantedKB > 0 + ? (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100 : 0; var pctContext = ""; if (result.ServerContext?.MaxServerMemoryMB > 0) + { + var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0; pctContext = $", {grantedMB / result.ServerContext.MaxServerMemoryMB * 100:N1}% of max server memory"; - writer.WriteLine($"Memory grant: {grantedMB:N1} MB granted, {usedMB:N1} MB used ({pctUsed:N0}% utilized{pctContext})"); + } + writer.WriteLine($"Memory grant: {FormatMemoryGrantKB(stmt.MemoryGrant.GrantedKB)} granted, {FormatMemoryGrantKB(stmt.MemoryGrant.MaxUsedKB)} used ({pctUsed:N0}% utilized{pctContext})"); } // Expensive operators — promoted to right after memory grant. @@ -323,6 +325,21 @@ private static void WriteGroupedOperatorWarnings(List warnings, T } } + /// + /// Formats a memory value given in KB to a human-readable string. + /// Under 1,024 KB: show KB (e.g., "512 KB"). + /// 1,024 KB to 1,048,576 KB: show MB with 1 decimal (e.g., "533.3 MB"). + /// Over 1,048,576 KB: show GB with 2 decimals (e.g., "2.14 GB"). + /// + public static string FormatMemoryGrantKB(long kb) + { + if (kb < 1024) + return $"{kb:N0} KB"; + if (kb < 1024 * 1024) + return $"{kb / 1024.0:N1} MB"; + return $"{kb / (1024.0 * 1024.0):N2} GB"; + } + /// /// Replaces newlines with unit separator (U+001F) so multi-line warning messages /// survive the top-level line split in AdviceContentBuilder.Build(). From 4f639661b37a412894ca12733f22259d52a8557c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:08:24 -0400 Subject: [PATCH 9/9] Bump version to 1.1.0 (#77) Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.App/PlanViewer.App.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 0cea721..4bdf48c 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -5,7 +5,7 @@ enable app.manifest true - 1.0.0 + 1.1.0 Erik Darling Darling Data LLC Performance Studio