diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.NodeLinks.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.NodeLinks.cs
new file mode 100644
index 0000000..38bbb1b
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.NodeLinks.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Services;
+
+internal static partial class AdviceContentBuilder
+{
+ ///
+ /// Walks all children recursively and replaces "Node N" text with clickable inline links.
+ ///
+ private static void MakeNodeRefsClickable(Panel panel, Action onNodeClick)
+ {
+ for (int i = 0; i < panel.Children.Count; i++)
+ {
+ var child = panel.Children[i];
+
+ // Recurse into containers
+ if (child is Panel innerPanel)
+ {
+ MakeNodeRefsClickable(innerPanel, onNodeClick);
+ continue;
+ }
+ if (child is Border border)
+ {
+ if (border.Child is Panel borderPanel)
+ {
+ MakeNodeRefsClickable(borderPanel, onNodeClick);
+ continue;
+ }
+ if (border.Child is SelectableTextBlock borderStb)
+ {
+ if (borderStb.Inlines?.Count > 0)
+ ProcessInlines(borderStb, onNodeClick);
+ else if (!string.IsNullOrEmpty(borderStb.Text) && NodeRefRegex.IsMatch(borderStb.Text))
+ {
+ var bText = borderStb.Text;
+ var bFg = borderStb.Foreground;
+ borderStb.Text = null;
+ AddRunsWithNodeLinks(borderStb.Inlines!, bText, bFg, onNodeClick);
+ WireNodeClickHandler(borderStb, onNodeClick);
+ }
+ continue;
+ }
+ }
+ if (child is Expander expander && expander.Content is Panel expanderPanel)
+ {
+ MakeNodeRefsClickable(expanderPanel, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with Inlines
+ if (child is SelectableTextBlock stb && stb.Inlines?.Count > 0)
+ {
+ ProcessInlines(stb, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with plain Text
+ if (child is SelectableTextBlock stbPlain && stbPlain.Inlines?.Count == 0
+ && !string.IsNullOrEmpty(stbPlain.Text) && NodeRefRegex.IsMatch(stbPlain.Text))
+ {
+ var text = stbPlain.Text;
+ var fg = stbPlain.Foreground;
+ stbPlain.Text = null;
+ AddRunsWithNodeLinks(stbPlain.Inlines!, text, fg, onNodeClick);
+ WireNodeClickHandler(stbPlain, onNodeClick);
+ }
+ }
+ }
+
+ ///
+ /// Processes existing Inlines in a SelectableTextBlock, splitting any Run that
+ /// contains "Node N" into segments with clickable links.
+ ///
+ private static void ProcessInlines(SelectableTextBlock stb, Action onNodeClick)
+ {
+ var inlines = stb.Inlines!;
+ var snapshot = inlines.ToList();
+ var changed = false;
+
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ changed = true;
+ break;
+ }
+ }
+
+ if (!changed) return;
+
+ // Rebuild inlines
+ var newInlines = new List();
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ var text = run.Text;
+ int pos = 0;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ newInlines.Add(new Run(text[pos..m.Index]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+
+ if (int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ var linkRun = new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline,
+ FontWeight = run.FontWeight,
+ FontSize = run.FontSize > 0 ? run.FontSize : double.NaN
+ };
+ newInlines.Add(linkRun);
+ }
+ else
+ {
+ newInlines.Add(new Run(m.Value) { Foreground = run.Foreground, FontWeight = run.FontWeight });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ newInlines.Add(new Run(text[pos..]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+ }
+ else
+ {
+ newInlines.Add(inline);
+ }
+ }
+
+ inlines.Clear();
+ foreach (var ni in newInlines)
+ inlines.Add(ni);
+
+ // Wire up PointerPressed on the TextBlock to detect clicks on link runs
+ WireNodeClickHandler(stb, onNodeClick);
+ }
+
+ ///
+ /// Splits plain text into Runs, making "Node N" references clickable.
+ ///
+ private static void AddRunsWithNodeLinks(InlineCollection inlines, string text, IBrush? defaultFg, Action onNodeClick)
+ {
+ int pos = 0;
+ var stb = inlines.FirstOrDefault()?.Parent as SelectableTextBlock;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ inlines.Add(new Run(text[pos..m.Index]) { Foreground = defaultFg });
+
+ if (int.TryParse(m.Groups[1].Value, out _))
+ {
+ inlines.Add(new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline
+ });
+ }
+ else
+ {
+ inlines.Add(new Run(m.Value) { Foreground = defaultFg });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ inlines.Add(new Run(text[pos..]) { Foreground = defaultFg });
+
+ // Find the parent SelectableTextBlock to attach click handler
+ // The inlines collection is owned by the SelectableTextBlock that called us
+ // We need to wire it up after — caller should call WireNodeClickHandler separately
+ }
+
+ ///
+ /// Attaches a PointerPressed handler to a SelectableTextBlock that detects clicks
+ /// on underlined "Node N" text and invokes the callback.
+ /// Uses Tunnel routing so the handler fires before SelectableTextBlock's
+ /// built-in text selection consumes the event.
+ ///
+ private static void WireNodeClickHandler(SelectableTextBlock stb, Action onNodeClick)
+ {
+ stb.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside) return;
+
+ var charIndex = hit.TextPosition;
+
+ // Walk through inlines to find which Run the charIndex falls in
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ if (run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush)
+ {
+ var m = NodeRefRegex.Match(run.Text);
+ if (m.Success && int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ e.Handled = true;
+
+ // Clear any text selection and release pointer capture
+ // to prevent SelectableTextBlock from starting a selection drag
+ stb.SelectionStart = 0;
+ stb.SelectionEnd = 0;
+ e.Pointer.Capture(null);
+
+ onNodeClick(nodeId);
+ }
+ }
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
+ // Change cursor on hover over link runs
+ stb.PointerMoved += (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside)
+ {
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ return;
+ }
+
+ var charIndex = hit.TextPosition;
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush
+ ? HandCursor
+ : Avalonia.Input.Cursor.Default;
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ };
+ }
+}
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.Operators.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.Operators.cs
new file mode 100644
index 0000000..6ce7def
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.Operators.cs
@@ -0,0 +1,274 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Services;
+
+internal static partial class AdviceContentBuilder
+{
+ ///
+ /// Creates a warning line with a left accent border for better scannability.
+ ///
+ private static Border CreateWarningBlock(string line, SolidColorBrush severityBrush)
+ {
+ var tb = new SelectableTextBlock
+ {
+ FontFamily = MonoFont,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Avalonia.Thickness(8, 3, 0, 3)
+ };
+
+ foreach (var tag in new[] { "[Critical]", "[Warning]", "[Info]" })
+ {
+ var idx = line.IndexOf(tag);
+ if (idx >= 0)
+ {
+ var afterTag = line[(idx + tag.Length)..].TrimStart();
+ // Extract "Type: Message" — show type in severity color, message in value
+ var typeColon = afterTag.IndexOf(':');
+ if (typeColon > 0)
+ {
+ var typeName = afterTag[..typeColon];
+ var message = afterTag[(typeColon + 1)..].TrimStart();
+ tb.Inlines!.Add(new Run(tag + " ")
+ { Foreground = severityBrush, FontWeight = FontWeight.SemiBold });
+ tb.Inlines.Add(new Run(typeName)
+ { Foreground = severityBrush });
+
+ // Split on unit separator (U+001F) — TextFormatter encodes \n as \x1F
+ // so multi-line messages survive the top-level line split in Build().
+ var messageParts = message.Split('\x1F');
+ for (int p = 0; p < messageParts.Length; p++)
+ {
+ var part = messageParts[p].Trim();
+ if (string.IsNullOrEmpty(part))
+ continue;
+
+ if (part.StartsWith("Predicate:"))
+ {
+ tb.Inlines.Add(new Run("\n" + part[..10])
+ { Foreground = LabelBrush });
+ tb.Inlines.Add(new Run(part[10..])
+ { Foreground = CodeBrush });
+ }
+ else if (p == 0)
+ {
+ // First line: the main description
+ tb.Inlines.Add(new Run("\n" + part)
+ { Foreground = ValueBrush });
+ }
+ else if (part.StartsWith("\u2022 "))
+ {
+ // Bullet stats: bullet in muted, value in white
+ tb.Inlines.Add(new Run("\n \u2022 ")
+ { Foreground = MutedBrush });
+ tb.Inlines.Add(new Run(part[2..])
+ { Foreground = ValueBrush });
+ }
+ else if (part.StartsWith("CREATE ", StringComparison.OrdinalIgnoreCase)
+ || part.StartsWith("ON ", StringComparison.OrdinalIgnoreCase)
+ || part.StartsWith("INCLUDE ", StringComparison.OrdinalIgnoreCase)
+ || part.StartsWith("WHERE ", StringComparison.OrdinalIgnoreCase))
+ {
+ // SQL DDL lines (CREATE INDEX, ON, INCLUDE, WHERE)
+ tb.Inlines.Add(new Run("\n" + part)
+ { Foreground = CodeBrush });
+ }
+ else
+ {
+ // Other detail lines
+ tb.Inlines.Add(new Run("\n" + part)
+ { Foreground = MutedBrush });
+ }
+ }
+ }
+ else
+ {
+ tb.Inlines!.Add(new Run(tag)
+ { Foreground = severityBrush, FontWeight = FontWeight.SemiBold });
+ tb.Inlines.Add(new Run(" " + afterTag)
+ { Foreground = ValueBrush });
+ }
+
+ return new Border
+ {
+ BorderBrush = severityBrush,
+ BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
+ Padding = new Avalonia.Thickness(0),
+ Margin = new Avalonia.Thickness(12, 4, 0, 4),
+ Child = tb
+ };
+ }
+ }
+
+ tb.Text = line.TrimStart();
+ tb.Foreground = severityBrush;
+ return new Border
+ {
+ BorderBrush = severityBrush,
+ BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
+ Padding = new Avalonia.Thickness(0),
+ Margin = new Avalonia.Thickness(12, 4, 0, 4),
+ Child = tb
+ };
+ }
+
+ private static SelectableTextBlock CreateOperatorLine(string line)
+ {
+ var tb = new SelectableTextBlock
+ {
+ FontFamily = MonoFont,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Avalonia.Thickness(8, 2, 0, 0)
+ };
+
+ var trimmed = line.TrimStart();
+ var parenIdx = trimmed.LastIndexOf('(');
+
+ if (parenIdx > 0)
+ {
+ var opName = trimmed[..parenIdx].TrimEnd();
+ var rest = trimmed[parenIdx..];
+ tb.Inlines!.Add(new Run(opName) { Foreground = OperatorBrush, FontWeight = FontWeight.SemiBold });
+ tb.Inlines.Add(new Run(" " + rest) { Foreground = MutedBrush });
+ }
+ else
+ {
+ tb.Text = trimmed;
+ tb.Foreground = OperatorBrush;
+ tb.FontWeight = FontWeight.SemiBold;
+ }
+
+ 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, plus a proportional bar.
+ ///
+ 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
+ };
+
+ // 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)
+ tb.Inlines!.Add(new Run(", ") { Foreground = MutedBrush });
+
+ var part = parts[i].Trim();
+ // Extract percentage in parentheses at the end
+ var pctStart = part.LastIndexOf('(');
+ if (pctStart > 0 && part.EndsWith(")"))
+ {
+ var timePart = part[..pctStart].TrimEnd();
+ var pctPart = part[pctStart..];
+ var brush = ValueBrush;
+ tb.Inlines!.Add(new Run(timePart) { Foreground = brush });
+ 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 = ValueBrush;
+ tb.Inlines!.Add(new Run(part) { Foreground = brush });
+ }
+ }
+
+ 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;
+ }
+}
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.Sql.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.Sql.cs
new file mode 100644
index 0000000..ed5c351
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.Sql.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Services;
+
+internal static partial class AdviceContentBuilder
+{
+ private static SelectableTextBlock BuildSqlHighlightedLine(string line)
+ {
+ var tb = new SelectableTextBlock
+ {
+ FontFamily = MonoFont,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Avalonia.Thickness(8, 1, 0, 1)
+ };
+
+ int pos = 0;
+ var text = line.TrimStart();
+ while (pos < text.Length)
+ {
+ if (!char.IsLetterOrDigit(text[pos]) && text[pos] != '_')
+ {
+ int start = pos;
+ while (pos < text.Length && !char.IsLetterOrDigit(text[pos]) && text[pos] != '_')
+ pos++;
+ tb.Inlines!.Add(new Run(text[start..pos]) { Foreground = ValueBrush });
+ continue;
+ }
+
+ int wordStart = pos;
+ while (pos < text.Length && (char.IsLetterOrDigit(text[pos]) || text[pos] == '_'))
+ pos++;
+ var word = text[wordStart..pos];
+
+ if (SqlKeywords.Contains(word))
+ tb.Inlines!.Add(new Run(word) { Foreground = SqlKeywordBrush, FontWeight = FontWeight.SemiBold });
+ else
+ tb.Inlines!.Add(new Run(word) { Foreground = ValueBrush });
+ }
+
+ return tb;
+ }
+}
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.WaitStats.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.WaitStats.cs
new file mode 100644
index 0000000..7c0a8a3
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.WaitStats.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Services;
+
+internal static partial class AdviceContentBuilder
+{
+ private static StackPanel CreateWaitStatLine(string waitName, string waitValue, double maxWaitMs)
+ {
+ var wrapper = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(12, 1, 0, 1)
+ };
+
+ var tb = new SelectableTextBlock
+ {
+ FontFamily = MonoFont,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap
+ };
+
+ var waitBrush = GetWaitCategoryBrush(waitName);
+ tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush });
+ tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush });
+
+ // Inline description label for the wait type
+ var label = PlanAnalyzer.GetWaitLabel(waitName);
+ if (!string.IsNullOrEmpty(label))
+ tb.Inlines.Add(new Run(" " + label) { Foreground = MutedBrush, FontSize = 11 });
+
+ 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
+ if (waitType.StartsWith("SOS_SCHEDULER") || waitType.StartsWith("CXPACKET") ||
+ waitType.StartsWith("CXCONSUMER") || waitType == "THREADPOOL" ||
+ waitType.StartsWith("EXECSYNC"))
+ return new SolidColorBrush(Color.Parse("#FFB347")); // orange
+
+ // I/O-related
+ if (waitType.StartsWith("PAGEIOLATCH") || waitType.StartsWith("WRITELOG") ||
+ waitType.StartsWith("IO_COMPLETION") || waitType.StartsWith("ASYNC_IO"))
+ return new SolidColorBrush(Color.Parse("#E57373")); // red
+
+ // Lock/blocking
+ if (waitType.StartsWith("LCK_") || waitType.StartsWith("LOCK"))
+ return new SolidColorBrush(Color.Parse("#E57373")); // red
+
+ // Memory
+ if (waitType.StartsWith("RESOURCE_SEMAPHORE") || waitType.StartsWith("CMEMTHREAD"))
+ return new SolidColorBrush(Color.Parse("#C792EA")); // purple
+
+ // Network
+ if (waitType.StartsWith("ASYNC_NETWORK"))
+ return new SolidColorBrush(Color.Parse("#6BB5FF")); // blue
+
+ 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));
+ }
+
+ // Wait profile classification
+ if (stmt.WaitStats.Count > 0)
+ {
+ var totalMs = stmt.WaitStats.Sum(w => w.WaitTimeMs);
+ if (totalMs > 0)
+ {
+ long ioMs = 0, cpuMs = 0, parallelMs = 0, lockMs = 0;
+ foreach (var w in stmt.WaitStats)
+ {
+ var wt = w.WaitType.ToUpperInvariant();
+ if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION"))
+ ioMs += w.WaitTimeMs;
+ else if (wt == "SOS_SCHEDULER_YIELD")
+ cpuMs += w.WaitTimeMs;
+ else if (wt.StartsWith("CX"))
+ parallelMs += w.WaitTimeMs;
+ else if (wt.StartsWith("LCK_"))
+ lockMs += w.WaitTimeMs;
+ }
+
+ // Pick the dominant category (>= 30% of total)
+ var categories = new List<(string label, long ms)>();
+ if (ioMs * 100 / totalMs >= 30) categories.Add(("I/O", ioMs));
+ if (cpuMs * 100 / totalMs >= 30) categories.Add(("CPU", cpuMs));
+ if (parallelMs * 100 / totalMs >= 30) categories.Add(("parallelism", parallelMs));
+ if (lockMs * 100 / totalMs >= 30) categories.Add(("lock contention", lockMs));
+
+ if (categories.Count > 0)
+ {
+ var label = string.Join(" + ", categories.Select(c => c.label));
+ items.Add(($"{label} bound ({totalMs:N0}ms total wait time)", InfoBrush));
+ }
+ }
+ }
+
+ // 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
+ };
+ }
+}
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index ab68bad..56c2ffc 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -15,7 +15,7 @@ namespace PlanViewer.App.Services;
/// Builds styled content for the Advice for Humans window.
/// Shared between MainWindow (file mode) and QuerySessionControl (query mode).
///
-internal static class AdviceContentBuilder
+internal static partial class AdviceContentBuilder
{
private static readonly SolidColorBrush HeaderBrush = new(Color.Parse("#4FA3FF"));
private static readonly SolidColorBrush CriticalBrush = new(Color.Parse("#E57373"));
@@ -429,252 +429,6 @@ public static StackPanel Build(string content, AnalysisResult? analysis, Action<
return panel;
}
- ///
- /// Walks all children recursively and replaces "Node N" text with clickable inline links.
- ///
- private static void MakeNodeRefsClickable(Panel panel, Action onNodeClick)
- {
- for (int i = 0; i < panel.Children.Count; i++)
- {
- var child = panel.Children[i];
-
- // Recurse into containers
- if (child is Panel innerPanel)
- {
- MakeNodeRefsClickable(innerPanel, onNodeClick);
- continue;
- }
- if (child is Border border)
- {
- if (border.Child is Panel borderPanel)
- {
- MakeNodeRefsClickable(borderPanel, onNodeClick);
- continue;
- }
- if (border.Child is SelectableTextBlock borderStb)
- {
- if (borderStb.Inlines?.Count > 0)
- ProcessInlines(borderStb, onNodeClick);
- else if (!string.IsNullOrEmpty(borderStb.Text) && NodeRefRegex.IsMatch(borderStb.Text))
- {
- var bText = borderStb.Text;
- var bFg = borderStb.Foreground;
- borderStb.Text = null;
- AddRunsWithNodeLinks(borderStb.Inlines!, bText, bFg, onNodeClick);
- WireNodeClickHandler(borderStb, onNodeClick);
- }
- continue;
- }
- }
- if (child is Expander expander && expander.Content is Panel expanderPanel)
- {
- MakeNodeRefsClickable(expanderPanel, onNodeClick);
- continue;
- }
-
- // Process SelectableTextBlock with Inlines
- if (child is SelectableTextBlock stb && stb.Inlines?.Count > 0)
- {
- ProcessInlines(stb, onNodeClick);
- continue;
- }
-
- // Process SelectableTextBlock with plain Text
- if (child is SelectableTextBlock stbPlain && stbPlain.Inlines?.Count == 0
- && !string.IsNullOrEmpty(stbPlain.Text) && NodeRefRegex.IsMatch(stbPlain.Text))
- {
- var text = stbPlain.Text;
- var fg = stbPlain.Foreground;
- stbPlain.Text = null;
- AddRunsWithNodeLinks(stbPlain.Inlines!, text, fg, onNodeClick);
- WireNodeClickHandler(stbPlain, onNodeClick);
- }
- }
- }
-
- ///
- /// Processes existing Inlines in a SelectableTextBlock, splitting any Run that
- /// contains "Node N" into segments with clickable links.
- ///
- private static void ProcessInlines(SelectableTextBlock stb, Action onNodeClick)
- {
- var inlines = stb.Inlines!;
- var snapshot = inlines.ToList();
- var changed = false;
-
- foreach (var inline in snapshot)
- {
- if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
- {
- changed = true;
- break;
- }
- }
-
- if (!changed) return;
-
- // Rebuild inlines
- var newInlines = new List();
- foreach (var inline in snapshot)
- {
- if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
- {
- var text = run.Text;
- int pos = 0;
- foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
- {
- if (m.Index > pos)
- newInlines.Add(new Run(text[pos..m.Index]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
-
- if (int.TryParse(m.Groups[1].Value, out var nodeId))
- {
- var linkRun = new Run(m.Value)
- {
- Foreground = LinkBrush,
- TextDecorations = Avalonia.Media.TextDecorations.Underline,
- FontWeight = run.FontWeight,
- FontSize = run.FontSize > 0 ? run.FontSize : double.NaN
- };
- newInlines.Add(linkRun);
- }
- else
- {
- newInlines.Add(new Run(m.Value) { Foreground = run.Foreground, FontWeight = run.FontWeight });
- }
- pos = m.Index + m.Length;
- }
- if (pos < text.Length)
- newInlines.Add(new Run(text[pos..]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
- }
- else
- {
- newInlines.Add(inline);
- }
- }
-
- inlines.Clear();
- foreach (var ni in newInlines)
- inlines.Add(ni);
-
- // Wire up PointerPressed on the TextBlock to detect clicks on link runs
- WireNodeClickHandler(stb, onNodeClick);
- }
-
- ///
- /// Splits plain text into Runs, making "Node N" references clickable.
- ///
- private static void AddRunsWithNodeLinks(InlineCollection inlines, string text, IBrush? defaultFg, Action onNodeClick)
- {
- int pos = 0;
- var stb = inlines.FirstOrDefault()?.Parent as SelectableTextBlock;
- foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
- {
- if (m.Index > pos)
- inlines.Add(new Run(text[pos..m.Index]) { Foreground = defaultFg });
-
- if (int.TryParse(m.Groups[1].Value, out _))
- {
- inlines.Add(new Run(m.Value)
- {
- Foreground = LinkBrush,
- TextDecorations = Avalonia.Media.TextDecorations.Underline
- });
- }
- else
- {
- inlines.Add(new Run(m.Value) { Foreground = defaultFg });
- }
- pos = m.Index + m.Length;
- }
- if (pos < text.Length)
- inlines.Add(new Run(text[pos..]) { Foreground = defaultFg });
-
- // Find the parent SelectableTextBlock to attach click handler
- // The inlines collection is owned by the SelectableTextBlock that called us
- // We need to wire it up after — caller should call WireNodeClickHandler separately
- }
-
- ///
- /// Attaches a PointerPressed handler to a SelectableTextBlock that detects clicks
- /// on underlined "Node N" text and invokes the callback.
- /// Uses Tunnel routing so the handler fires before SelectableTextBlock's
- /// built-in text selection consumes the event.
- ///
- private static void WireNodeClickHandler(SelectableTextBlock stb, Action onNodeClick)
- {
- stb.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (_, e) =>
- {
- var point = e.GetPosition(stb);
- var hit = stb.TextLayout.HitTestPoint(point);
- if (!hit.IsInside) return;
-
- var charIndex = hit.TextPosition;
-
- // Walk through inlines to find which Run the charIndex falls in
- int runStart = 0;
- foreach (var inline in stb.Inlines!)
- {
- if (inline is Run run && run.Text != null)
- {
- var runEnd = runStart + run.Text.Length;
- if (charIndex >= runStart && charIndex < runEnd)
- {
- if (run.TextDecorations == Avalonia.Media.TextDecorations.Underline
- && run.Foreground == LinkBrush)
- {
- var m = NodeRefRegex.Match(run.Text);
- if (m.Success && int.TryParse(m.Groups[1].Value, out var nodeId))
- {
- e.Handled = true;
-
- // Clear any text selection and release pointer capture
- // to prevent SelectableTextBlock from starting a selection drag
- stb.SelectionStart = 0;
- stb.SelectionEnd = 0;
- e.Pointer.Capture(null);
-
- onNodeClick(nodeId);
- }
- }
- return;
- }
- runStart = runEnd;
- }
- }
- }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
-
- // Change cursor on hover over link runs
- stb.PointerMoved += (_, e) =>
- {
- var point = e.GetPosition(stb);
- var hit = stb.TextLayout.HitTestPoint(point);
- if (!hit.IsInside)
- {
- stb.Cursor = Avalonia.Input.Cursor.Default;
- return;
- }
-
- var charIndex = hit.TextPosition;
- int runStart = 0;
- foreach (var inline in stb.Inlines!)
- {
- if (inline is Run run && run.Text != null)
- {
- var runEnd = runStart + run.Text.Length;
- if (charIndex >= runStart && charIndex < runEnd)
- {
- stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline
- && run.Foreground == LinkBrush
- ? HandCursor
- : Avalonia.Input.Cursor.Default;
- return;
- }
- runStart = runEnd;
- }
- }
- stb.Cursor = Avalonia.Input.Cursor.Default;
- };
- }
private static bool IsSubSectionLabel(string trimmed)
{
@@ -686,542 +440,5 @@ private static bool IsSubSectionLabel(string trimmed)
return label.Length < 30 && !label.Contains('=') && !label.Contains('(');
}
- private static SelectableTextBlock BuildSqlHighlightedLine(string line)
- {
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 1, 0, 1)
- };
- int pos = 0;
- var text = line.TrimStart();
- while (pos < text.Length)
- {
- if (!char.IsLetterOrDigit(text[pos]) && text[pos] != '_')
- {
- int start = pos;
- while (pos < text.Length && !char.IsLetterOrDigit(text[pos]) && text[pos] != '_')
- pos++;
- tb.Inlines!.Add(new Run(text[start..pos]) { Foreground = ValueBrush });
- continue;
- }
-
- int wordStart = pos;
- while (pos < text.Length && (char.IsLetterOrDigit(text[pos]) || text[pos] == '_'))
- pos++;
- var word = text[wordStart..pos];
-
- if (SqlKeywords.Contains(word))
- tb.Inlines!.Add(new Run(word) { Foreground = SqlKeywordBrush, FontWeight = FontWeight.SemiBold });
- else
- tb.Inlines!.Add(new Run(word) { Foreground = ValueBrush });
- }
-
- return tb;
- }
-
- ///
- /// Creates a warning line with a left accent border for better scannability.
- ///
- private static Border CreateWarningBlock(string line, SolidColorBrush severityBrush)
- {
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 3, 0, 3)
- };
-
- foreach (var tag in new[] { "[Critical]", "[Warning]", "[Info]" })
- {
- var idx = line.IndexOf(tag);
- if (idx >= 0)
- {
- var afterTag = line[(idx + tag.Length)..].TrimStart();
- // Extract "Type: Message" — show type in severity color, message in value
- var typeColon = afterTag.IndexOf(':');
- if (typeColon > 0)
- {
- var typeName = afterTag[..typeColon];
- var message = afterTag[(typeColon + 1)..].TrimStart();
- tb.Inlines!.Add(new Run(tag + " ")
- { Foreground = severityBrush, FontWeight = FontWeight.SemiBold });
- tb.Inlines.Add(new Run(typeName)
- { Foreground = severityBrush });
-
- // Split on unit separator (U+001F) — TextFormatter encodes \n as \x1F
- // so multi-line messages survive the top-level line split in Build().
- var messageParts = message.Split('\x1F');
- for (int p = 0; p < messageParts.Length; p++)
- {
- var part = messageParts[p].Trim();
- if (string.IsNullOrEmpty(part))
- continue;
-
- if (part.StartsWith("Predicate:"))
- {
- tb.Inlines.Add(new Run("\n" + part[..10])
- { Foreground = LabelBrush });
- tb.Inlines.Add(new Run(part[10..])
- { Foreground = CodeBrush });
- }
- else if (p == 0)
- {
- // First line: the main description
- tb.Inlines.Add(new Run("\n" + part)
- { Foreground = ValueBrush });
- }
- else if (part.StartsWith("\u2022 "))
- {
- // Bullet stats: bullet in muted, value in white
- tb.Inlines.Add(new Run("\n \u2022 ")
- { Foreground = MutedBrush });
- tb.Inlines.Add(new Run(part[2..])
- { Foreground = ValueBrush });
- }
- else if (part.StartsWith("CREATE ", StringComparison.OrdinalIgnoreCase)
- || part.StartsWith("ON ", StringComparison.OrdinalIgnoreCase)
- || part.StartsWith("INCLUDE ", StringComparison.OrdinalIgnoreCase)
- || part.StartsWith("WHERE ", StringComparison.OrdinalIgnoreCase))
- {
- // SQL DDL lines (CREATE INDEX, ON, INCLUDE, WHERE)
- tb.Inlines.Add(new Run("\n" + part)
- { Foreground = CodeBrush });
- }
- else
- {
- // Other detail lines
- tb.Inlines.Add(new Run("\n" + part)
- { Foreground = MutedBrush });
- }
- }
- }
- else
- {
- tb.Inlines!.Add(new Run(tag)
- { Foreground = severityBrush, FontWeight = FontWeight.SemiBold });
- tb.Inlines.Add(new Run(" " + afterTag)
- { Foreground = ValueBrush });
- }
-
- return new Border
- {
- BorderBrush = severityBrush,
- BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
- Padding = new Avalonia.Thickness(0),
- Margin = new Avalonia.Thickness(12, 4, 0, 4),
- Child = tb
- };
- }
- }
-
- tb.Text = line.TrimStart();
- tb.Foreground = severityBrush;
- return new Border
- {
- BorderBrush = severityBrush,
- BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
- Padding = new Avalonia.Thickness(0),
- Margin = new Avalonia.Thickness(12, 4, 0, 4),
- Child = tb
- };
- }
-
- private static SelectableTextBlock CreateOperatorLine(string line)
- {
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 2, 0, 0)
- };
-
- var trimmed = line.TrimStart();
- var parenIdx = trimmed.LastIndexOf('(');
-
- if (parenIdx > 0)
- {
- var opName = trimmed[..parenIdx].TrimEnd();
- var rest = trimmed[parenIdx..];
- tb.Inlines!.Add(new Run(opName) { Foreground = OperatorBrush, FontWeight = FontWeight.SemiBold });
- tb.Inlines.Add(new Run(" " + rest) { Foreground = MutedBrush });
- }
- else
- {
- tb.Text = trimmed;
- tb.Foreground = OperatorBrush;
- tb.FontWeight = FontWeight.SemiBold;
- }
-
- 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, plus a proportional bar.
- ///
- 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
- };
-
- // 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)
- tb.Inlines!.Add(new Run(", ") { Foreground = MutedBrush });
-
- var part = parts[i].Trim();
- // Extract percentage in parentheses at the end
- var pctStart = part.LastIndexOf('(');
- if (pctStart > 0 && part.EndsWith(")"))
- {
- var timePart = part[..pctStart].TrimEnd();
- var pctPart = part[pctStart..];
- var brush = ValueBrush;
- tb.Inlines!.Add(new Run(timePart) { Foreground = brush });
- 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 = ValueBrush;
- tb.Inlines!.Add(new Run(part) { Foreground = brush });
- }
- }
-
- 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 StackPanel CreateWaitStatLine(string waitName, string waitValue, double maxWaitMs)
- {
- var wrapper = new StackPanel
- {
- Margin = new Avalonia.Thickness(12, 1, 0, 1)
- };
-
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap
- };
-
- var waitBrush = GetWaitCategoryBrush(waitName);
- tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush });
- tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush });
-
- // Inline description label for the wait type
- var label = PlanAnalyzer.GetWaitLabel(waitName);
- if (!string.IsNullOrEmpty(label))
- tb.Inlines.Add(new Run(" " + label) { Foreground = MutedBrush, FontSize = 11 });
-
- 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
- if (waitType.StartsWith("SOS_SCHEDULER") || waitType.StartsWith("CXPACKET") ||
- waitType.StartsWith("CXCONSUMER") || waitType == "THREADPOOL" ||
- waitType.StartsWith("EXECSYNC"))
- return new SolidColorBrush(Color.Parse("#FFB347")); // orange
-
- // I/O-related
- if (waitType.StartsWith("PAGEIOLATCH") || waitType.StartsWith("WRITELOG") ||
- waitType.StartsWith("IO_COMPLETION") || waitType.StartsWith("ASYNC_IO"))
- return new SolidColorBrush(Color.Parse("#E57373")); // red
-
- // Lock/blocking
- if (waitType.StartsWith("LCK_") || waitType.StartsWith("LOCK"))
- return new SolidColorBrush(Color.Parse("#E57373")); // red
-
- // Memory
- if (waitType.StartsWith("RESOURCE_SEMAPHORE") || waitType.StartsWith("CMEMTHREAD"))
- return new SolidColorBrush(Color.Parse("#C792EA")); // purple
-
- // Network
- if (waitType.StartsWith("ASYNC_NETWORK"))
- return new SolidColorBrush(Color.Parse("#6BB5FF")); // blue
-
- 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));
- }
-
- // Wait profile classification
- if (stmt.WaitStats.Count > 0)
- {
- var totalMs = stmt.WaitStats.Sum(w => w.WaitTimeMs);
- if (totalMs > 0)
- {
- long ioMs = 0, cpuMs = 0, parallelMs = 0, lockMs = 0;
- foreach (var w in stmt.WaitStats)
- {
- var wt = w.WaitType.ToUpperInvariant();
- if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION"))
- ioMs += w.WaitTimeMs;
- else if (wt == "SOS_SCHEDULER_YIELD")
- cpuMs += w.WaitTimeMs;
- else if (wt.StartsWith("CX"))
- parallelMs += w.WaitTimeMs;
- else if (wt.StartsWith("LCK_"))
- lockMs += w.WaitTimeMs;
- }
-
- // Pick the dominant category (>= 30% of total)
- var categories = new List<(string label, long ms)>();
- if (ioMs * 100 / totalMs >= 30) categories.Add(("I/O", ioMs));
- if (cpuMs * 100 / totalMs >= 30) categories.Add(("CPU", cpuMs));
- if (parallelMs * 100 / totalMs >= 30) categories.Add(("parallelism", parallelMs));
- if (lockMs * 100 / totalMs >= 30) categories.Add(("lock contention", lockMs));
-
- if (categories.Count > 0)
- {
- var label = string.Join(" + ", categories.Select(c => c.label));
- items.Add(($"{label} bound ({totalMs:N0}ms total wait time)", InfoBrush));
- }
- }
- }
-
- // 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
- };
- }
}