From 04ccefc7a6a1b082a7acedf7c0f07e7d732bbf27 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:43:11 -0400 Subject: [PATCH 1/7] Add "Powered by Performance Studio" line on landing page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/Pages/Index.razor | 1 + src/PlanViewer.Web/wwwroot/css/app.css | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 6aae734..3e9be24 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -6,6 +6,7 @@

Free SQL Server Query Plan Analysis

Paste or upload a .sqlplan file. Your plan XML never leaves your browser.

+

Powered by the same analysis engine in the Performance Studio app.

@if (errorMessage != null) { diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 95b0cb4..88fc17a 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -168,9 +168,25 @@ main { font-family: 'Montserrat', sans-serif; font-size: 1.05rem; font-weight: 600; + margin-bottom: 0.4rem; +} + +.powered-by { + color: var(--text-secondary); + font-size: 0.9rem; margin-bottom: 1.5rem; } +.powered-by a { + color: var(--accent); + text-decoration: none; + font-weight: 600; +} + +.powered-by a:hover { + text-decoration: underline; +} + /* === Input Area === */ .input-area { text-align: left; From 6c6c1f0e31035f6b3d755e8c9480c485a3fa22f6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:58:12 -0400 Subject: [PATCH 2/7] Add Darling Data favicon to web app Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/favicon.png | Bin 0 -> 39737 bytes src/PlanViewer.Web/wwwroot/index.html | 1 + 2 files changed, 1 insertion(+) create mode 100644 src/PlanViewer.Web/wwwroot/favicon.png diff --git a/src/PlanViewer.Web/wwwroot/favicon.png b/src/PlanViewer.Web/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..9c6db66abf67f052aaa496cf675178c8712ea1a6 GIT binary patch literal 39737 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 literal 0 HcmV?d00001 diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 543dff4..04ba38d 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,7 @@ Free SQL Server Query Plan Analysis — Darling Data + From 4453034c5ee19adc1fc32351b9ba478dc912a6a1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:03:01 -0400 Subject: [PATCH 3/7] Add Open Graph and Twitter Card meta tags for social sharing Uses the Darling Data barbell logo as hero image when shared on social media. Also adds meta description for SEO. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 04ba38d..432cfe7 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,16 @@ Free SQL Server Query Plan Analysis — Darling Data + + + + + + + + + + From ba2beeb03c7861942d23702eb27bb6c2f9b4562d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:49 -0400 Subject: [PATCH 4/7] Clarify OG description: in-browser, nothing to install Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 432cfe7..ebfd4ee 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -6,13 +6,13 @@ Free SQL Server Query Plan Analysis — Darling Data - + - + From 68ff836d372a3b7c73b27530c6fa7e0096a21daf Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:26:43 -0400 Subject: [PATCH 5/7] Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reason means something in the query blocks parallelism (scalar UDFs, table variable inserts, etc.) — that's worth a Warning, not Info. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index d89458d..af6a5a3 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -141,14 +141,16 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) _ => stmt.NonParallelPlanReason }; - // Only warn (not info) when the user explicitly forced serial execution - var isExplicit = stmt.NonParallelPlanReason is "MaxDOPSetToOne" or "QueryHintNoParallelSet"; + // Warn when the user forced serial or something in the query blocks parallelism. + // Info only for passive reasons (cost below threshold, edition limitation). + var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" + or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Serial Plan", Message = $"Query running serially: {reason}.", - Severity = isExplicit ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info }); } From 56150212e7f8f9ada43aef13bbc488e65339ed09 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:31:31 -0400 Subject: [PATCH 6/7] Expand Rule 3 to cover all NonParallelPlanReason values Adds human-readable messages for all 25 known reasons. Severity: - Warning: actionable reasons (UDFs, cursors, table variables, remote queries, trace flags, hints, DML OUTPUT, writeback variables) - Info: passive/environmental (cost below threshold, edition limits, memory-optimized tables, upgrade mode, index build edge cases) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 58 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index af6a5a3..c2b2fe6 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -133,18 +133,66 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) { var reason = stmt.NonParallelPlanReason switch { + // User/config forced serial "MaxDOPSetToOne" => "MAXDOP is set to 1", + "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", + + // Passive — optimizer chose serial, nothing wrong "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", + + // Edition/environment limitations "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", + "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", + "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", + "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", + "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", + + // Query constructs that block parallelism (actionable) "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", - "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", + "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", + "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", + "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", + "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", + "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", + "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", + "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", + + // Cursor limitations + "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", + "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", + "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", + + // Memory-optimized / natively compiled + "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", + "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", + "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", + + // Remote queries + "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", + "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", + _ => stmt.NonParallelPlanReason }; - // Warn when the user forced serial or something in the query blocks parallelism. - // Info only for passive reasons (cost below threshold, edition limitation). - var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" - or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; + // Actionable: user forced serial, or something in the query blocks parallelism + // that could potentially be rewritten. Info: passive (cost too low) or + // environmental (edition, upgrade, cursor type, memory-optimized). + var isActionable = stmt.NonParallelPlanReason is + "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" + or "CouldNotGenerateValidParallelPlan" + or "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "NonParallelizableIntrinsicFunction" + or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" + or "UpdatingWritebackVariable" + or "DMLQueryReturnsOutputToClient" + or "NoParallelCursorFetchByBookmark" + or "NoParallelDynamicCursor" + or "NoParallelFastForwardCursor" + or "NoParallelWithRemoteQuery" + or "NoRemoteParallelismForMatrix"; stmt.PlanWarnings.Add(new PlanWarning { From 714e406f86992c534d173d48152959de273092ac Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 13 May 2026 05:13:13 +0200 Subject: [PATCH 7/7] Split AdviceContentBuilder.cs into partial classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. AdviceContentBuilder.cs (1,227 lines) split into 4 partials: NodeLinks (243) - MakeNodeRefsClickable + ProcessInlines + AddRunsWithNodeLinks + WireNodeClickHandler Operators (251) - CreateOperatorLine/Group/TimingLine + CreateWarningBlock WaitStats (242) - CreateWaitStatLine + CreateMissingIndexImpactLine + ParseWaitMs + GetWaitCategoryBrush + CreateTriageSummaryCard Sql ( 36) - BuildSqlHighlightedLine Main file now 444 lines — brushes/fonts, PhysicalOperators & SqlKeywords sets, regex constants, the 3 Build() overloads (entry points), IsSubSectionLabel helper. Class made `internal static partial`. Build clean: 0 errors on PlanViewer.App. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AdviceContentBuilder.NodeLinks.cs | 266 ++++++ .../AdviceContentBuilder.Operators.cs | 274 ++++++ .../Services/AdviceContentBuilder.Sql.cs | 56 ++ .../AdviceContentBuilder.WaitStats.cs | 266 ++++++ .../Services/AdviceContentBuilder.cs | 785 +----------------- 5 files changed, 863 insertions(+), 784 deletions(-) create mode 100644 src/PlanViewer.App/Services/AdviceContentBuilder.NodeLinks.cs create mode 100644 src/PlanViewer.App/Services/AdviceContentBuilder.Operators.cs create mode 100644 src/PlanViewer.App/Services/AdviceContentBuilder.Sql.cs create mode 100644 src/PlanViewer.App/Services/AdviceContentBuilder.WaitStats.cs 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 - }; - } }