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 - }; - } }