From 2ac10cc79fecc8278e0c26a50463f619078cf81a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:55:05 -0400 Subject: [PATCH 1/5] Operator grouping, margin standardization, and elapsed time visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Services/AdviceContentBuilder.cs | 298 +++++++++++++++--- 1 file changed, 261 insertions(+), 37 deletions(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index 35f1c37..fa8e8d6 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 }); @@ -209,26 +240,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 +295,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,7 +315,7 @@ 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; @@ -275,7 +331,7 @@ 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)); + panel.Children.Add(CreateWaitStatLine(waitName, waitValue)); continue; } } @@ -288,7 +344,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 +391,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 +431,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 +473,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 +511,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 +524,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 +559,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,23 +638,47 @@ 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 SelectableTextBlock CreateWaitStatLine(string waitName, string waitValue) { - var leftMargin = indented ? 16.0 : 8.0; + var leftMargin = 12.0; var tb = new SelectableTextBlock { FontFamily = MonoFont, @@ -590,4 +721,97 @@ 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 + 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; + var memBrush = usedPct < 10 ? 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) + }; + + foreach (var (text, brush) in items) + { + cardPanel.Children.Add(new SelectableTextBlock + { + Text = text, + FontFamily = MonoFont, + FontSize = 12, + 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 e3c0aeae61a32b9d60c267271a9b443159a48d9f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:01:12 -0400 Subject: [PATCH 2/5] 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 --- .../Services/AdviceContentBuilder.cs | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index fa8e8d6..086224a 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -321,7 +321,8 @@ public static StackPanel Build(string content, AnalysisResult? analysis) 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(':'); @@ -331,7 +332,38 @@ public static StackPanel Build(string content, AnalysisResult? analysis) var waitValue = trimmed[(waitColon + 1)..].Trim(); if (waitValue.EndsWith("ms") && waitName == waitName.ToUpperInvariant() && !waitName.Contains(' ')) { - panel.Children.Add(CreateWaitStatLine(waitName, waitValue)); + // 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; } } @@ -676,22 +708,51 @@ private static StackPanel CreateOperatorTimingLine(string trimmed) return wrapper; } - private static SelectableTextBlock CreateWaitStatLine(string waitName, string waitValue) + private static StackPanel CreateWaitStatLine(string waitName, string waitValue, double maxWaitMs) { - var leftMargin = 12.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); - return 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; + } + + /// + /// 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) From 7961d044d434a7eb2ef9615cefe7ff8405a28fc0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:04:33 -0400 Subject: [PATCH 3/5] =?UTF-8?q?Triage=20card=20headline=20hierarchy=20?= =?UTF-8?q?=E2=80=94=20first=20item=2013px=20semi-bold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/PlanViewer.App/Services/AdviceContentBuilder.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index 086224a..1dd5c29 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -853,13 +853,16 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType) Margin = new Avalonia.Thickness(4) }; - foreach (var (text, brush) in items) + 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 = 12, + FontSize = isHeadline ? 13 : 12, + FontWeight = isHeadline ? FontWeight.SemiBold : FontWeight.Normal, Foreground = brush, Margin = new Avalonia.Thickness(4, 2, 0, 2), TextWrapping = TextWrapping.Wrap From 313e361d5d98581837f0af4738e050f8b7f53ae4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:13:17 -0400 Subject: [PATCH 4/5] Color-code missing index impact percentage by severity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Services/AdviceContentBuilder.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index 1dd5c29..ed3a9f2 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -209,6 +209,13 @@ public static StackPanel Build(string content, AnalysisResult? analysis) 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)) { @@ -746,6 +753,38 @@ private static StackPanel CreateWaitStatLine(string waitName, string waitValue, 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. /// From 9cba8b8d61089bce18e0cf546982dcf1264b7abe Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:16:18 -0400 Subject: [PATCH 5/5] Memory grant contextual color in triage card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/PlanViewer.App/Services/AdviceContentBuilder.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index ed3a9f2..eeff7d6 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -843,14 +843,17 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType) items.Add(($"\u26A0 {efficiency:F0}% parallel efficiency (DOP {dop})", effBrush)); } - // Memory grant + // 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; - var memBrush = usedPct < 10 ? WarningBrush : InfoBrush; + // 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)); }