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 e76d9068a032176fdf39e2a808c627da6d8ca07f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 13 May 2026 04:55:48 +0200 Subject: [PATCH 7/7] Split PlanViewerControl.axaml.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. PlanViewerControl.axaml.cs (4,497 lines) split into 7 partials: Rendering (550) - RenderStatement/Nodes/Edges + edge tooltips Properties (1860) - ShowPropertiesPanel + all property/runtime/wait panels Tooltips (278) - BuildNodeTooltipContent + helpers Interaction (327) - Node_Click, Select, zoom, pointer events, save Statements (222) - statements grid panel Minimap (502) - minimap render/drag/resize/navigation Schema (347) - context-menu schema lookup + index/column formatting Main file (PlanViewerControl.axaml.cs) now 504 lines — fields, brushes, constructor, public API (LoadPlan/Clear/NavigateToNode), connection state, and PlanConnect/PlanDatabase event handlers. Build clean: 0 errors, 0 warnings on PlanViewer.sln. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.Interaction.cs | 327 ++ .../Controls/PlanViewerControl.Minimap.cs | 502 +++ .../Controls/PlanViewerControl.Properties.cs | 1860 ++++++++ .../Controls/PlanViewerControl.Rendering.cs | 550 +++ .../Controls/PlanViewerControl.Schema.cs | 347 ++ .../Controls/PlanViewerControl.Statements.cs | 222 + .../Controls/PlanViewerControl.Tooltips.cs | 278 ++ .../Controls/PlanViewerControl.axaml.cs | 3995 +---------------- 8 files changed, 4087 insertions(+), 3994 deletions(-) create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs new file mode 100644 index 0000000..0848235 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs @@ -0,0 +1,327 @@ +using System; +using System.IO; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void Node_Click(object? sender, PointerPressedEventArgs e) + { + if (sender is Border border + && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed + && _nodeBorderMap.TryGetValue(border, out var node)) + { + SelectNode(border, node); + e.Handled = true; + } + } + + private void SelectNode(Border border, PlanNode node) + { + // Deselect previous + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + } + + // Select new + _selectedNodeOriginalBorder = border.BorderBrush; + _selectedNodeOriginalThickness = border.BorderThickness; + _selectedNodeBorder = border; + border.BorderBrush = SelectionBrush; + border.BorderThickness = new Thickness(2); + + _selectedNode = node; + ShowPropertiesPanel(node); + UpdateMinimapSelection(node); + } + + private ContextMenu BuildNodeContextMenu(PlanNode node) + { + var menu = new ContextMenu(); + + var propsItem = new MenuItem { Header = "Properties" }; + propsItem.Click += (_, _) => + { + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node) + { + SelectNode(b, node); + break; + } + } + }; + menu.Items.Add(propsItem); + + menu.Items.Add(new Separator()); + + var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; + copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp); + menu.Items.Add(copyOpItem); + + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + var copyObjItem = new MenuItem { Header = "Copy Object Name" }; + copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!); + menu.Items.Add(copyObjItem); + } + + if (!string.IsNullOrEmpty(node.Predicate)) + { + var copyPredItem = new MenuItem { Header = "Copy Predicate" }; + copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!); + menu.Items.Add(copyPredItem); + } + + if (!string.IsNullOrEmpty(node.SeekPredicates)) + { + var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; + copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!); + menu.Items.Add(copySeekItem); + } + + // Schema lookup items (Show Indexes, Show Table Definition) + AddSchemaMenuItems(menu, node); + + return menu; + } + + private ContextMenu BuildCanvasContextMenu() + { + var menu = new ContextMenu(); + + // Zoom + var zoomInItem = new MenuItem { Header = "Zoom In" }; + zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); + menu.Items.Add(zoomInItem); + + var zoomOutItem = new MenuItem { Header = "Zoom Out" }; + zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); + menu.Items.Add(zoomOutItem); + + var fitItem = new MenuItem { Header = "Fit to View" }; + fitItem.Click += ZoomFit_Click; + menu.Items.Add(fitItem); + + menu.Items.Add(new Separator()); + + // Advice + var humanAdviceItem = new MenuItem { Header = "Human Advice" }; + humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(humanAdviceItem); + + var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; + robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(robotAdviceItem); + + menu.Items.Add(new Separator()); + + // Repro & Save + var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; + copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(copyReproItem); + + var saveItem = new MenuItem { Header = "Save .sqlplan" }; + saveItem.Click += SavePlan_Click; + menu.Items.Add(saveItem); + + return menu; + } + + private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard != null) + await topLevel.Clipboard.SetTextAsync(text); + } + + private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep); + + private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep); + + private void ZoomFit_Click(object? sender, RoutedEventArgs e) + { + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; + + var viewWidth = PlanScrollViewer.Bounds.Width; + var viewHeight = PlanScrollViewer.Bounds.Height; + if (viewWidth <= 0 || viewHeight <= 0) return; + + var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); + SetZoom(Math.Min(fitZoom, 1.0)); + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + } + + private void SetZoom(double level) + { + _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level)); + _zoomTransform.ScaleX = _zoomLevel; + _zoomTransform.ScaleY = _zoomLevel; + ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; + UpdateMinimapViewportBox(); + } + + /// + /// Sets the zoom level and adjusts the scroll offset so that the content point + /// under stays fixed in the viewport. + /// + private void SetZoomAtPoint(double level, Point viewportAnchor) + { + var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level)); + if (Math.Abs(newZoom - _zoomLevel) < 0.001) + return; + + // Content point under the anchor at the current zoom level + var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel; + var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel; + + // Apply the new zoom + SetZoom(newZoom); + + // Adjust offset so the same content point stays under the anchor + var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X); + var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); + UpdateMinimapViewportBox(); + }); + } + + private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + e.Handled = true; + var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep); + SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer)); + } + } + + private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) + { + // Don't intercept scrollbar interactions + if (IsScrollBarAtPoint(e)) + return; + + var point = e.GetCurrentPoint(PlanScrollViewer); + var isMiddle = point.Properties.IsMiddleButtonPressed; + var isLeft = point.Properties.IsLeftButtonPressed; + + // Middle mouse always pans; left-click pans only on empty canvas (not on nodes) + if (isMiddle || (isLeft && !IsNodeAtPoint(e))) + { + _isPanning = true; + _panStart = point.Position; + _panStartOffsetX = PlanScrollViewer.Offset.X; + _panStartOffsetY = PlanScrollViewer.Offset.Y; + PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll); + e.Pointer.Capture(PlanScrollViewer); + e.Handled = true; + } + } + + private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_isPanning) return; + + var current = e.GetPosition(PlanScrollViewer); + var dx = current.X - _panStart.X; + var dy = current.Y - _panStart.Y; + + var newX = Math.Max(0, _panStartOffsetX - dx); + var newY = Math.Max(0, _panStartOffsetY - dy); + + // Defer offset change so the ScrollViewer doesn't overwrite it during layout + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newX, newY); + UpdateMinimapViewportBox(); + }); + + e.Handled = true; + } + + private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isPanning) return; + _isPanning = false; + PlanScrollViewer.Cursor = Cursor.Default; + e.Pointer.Capture(null); + e.Handled = true; + } + + /// Check if the pointer event originated from a node Border. + private bool IsNodeAtPoint(PointerPressedEventArgs e) + { + // Walk up the visual tree from the source to see if we hit a node border + var source = e.Source as Control; + while (source != null && source != PlanCanvas) + { + if (source is Border b && _nodeBorderMap.ContainsKey(b)) + return true; + source = source.Parent as Control; + } + return false; + } + + /// Check if the pointer event originated from a ScrollBar. + private bool IsScrollBarAtPoint(PointerPressedEventArgs e) + { + var source = e.Source as Control; + while (source != null && source != PlanScrollViewer) + { + if (source is ScrollBar) + return true; + source = source.Parent as Control; + } + return false; + } + + private async void SavePlan_Click(object? sender, RoutedEventArgs e) + { + if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) return; + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save Plan", + DefaultExtension = "sqlplan", + SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan", + FileTypeChoices = new[] + { + new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } }, + new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } }, + new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } } + } + }); + + if (file != null) + { + try + { + await using var stream = await file.OpenWriteAsync(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(_currentPlan.RawXml); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}"); + CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; + } + } + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs new file mode 100644 index 0000000..0e4424c --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void MinimapToggle_Click(object? sender, RoutedEventArgs e) + { + if (MinimapPanel.IsVisible) + CloseMinimapPanel(); + else + OpenMinimapPanel(); + } + + private void MinimapClose_Click(object? sender, RoutedEventArgs e) + { + CloseMinimapPanel(); + } + + private void OpenMinimapPanel() + { + MinimapPanel.Width = _minimapWidth; + MinimapPanel.Height = _minimapHeight; + MinimapPanel.IsVisible = true; + RenderMinimap(); + } + + private void CloseMinimapPanel() + { + MinimapPanel.IsVisible = false; + _minimapDragging = false; + _minimapResizing = false; + } + + private void RenderMinimap() + { + MinimapCanvas.Children.Clear(); + _minimapNodeMap.Clear(); + _minimapViewportBox = null; + _minimapSelectedNode = null; + + // Guard: don't render if the panel was closed between a deferred post and execution + if (!MinimapPanel.IsVisible) return; + + if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) + return; + + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) + { + // Defer until layout is ready + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); + return; + } + + var scaleX = canvasW / PlanCanvas.Width; + var scaleY = canvasH / PlanCanvas.Height; + var scale = Math.Min(scaleX, scaleY); + + // Cache the non-expensive node border brush for this render cycle + _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg + ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B)) + : FindBrushResource("BorderBrush"); + + // Render branch areas with transparent colored backgrounds + RenderMinimapBranches(_currentStatement.RootNode, scale); + + // Render edges + var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); + RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit); + + // Render nodes + RenderMinimapNodes(_currentStatement.RootNode, scale); + + // Render viewport indicator + RenderMinimapViewportBox(scale); + + // Re-apply selection highlight if a node is selected + if (_selectedNode != null) + UpdateMinimapSelection(_selectedNode); + } + + private void RenderMinimapBranches(PlanNode root, double scale) + { + + for (int i = 0; i < root.Children.Count; i++) + { + var child = root.Children[i]; + var color = MinimapBranchColors[i % MinimapBranchColors.Length]; + + // Collect bounds of all nodes in this subtree + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); + + var rect = new Avalonia.Controls.Shapes.Rectangle + { + Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4, + Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4, + Fill = new SolidColorBrush(color), + RadiusX = 2, + RadiusY = 2 + }; + Canvas.SetLeft(rect, minX * scale - 2); + Canvas.SetTop(rect, minY * scale - 2); + MinimapCanvas.Children.Add(rect); + } + } + + private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY) + { + if (node.X < minX) minX = node.X; + if (node.Y < minY) minY = node.Y; + if (node.X > maxX) maxX = node.X; + var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node); + if (bottom > maxY) maxY = bottom; + + foreach (var child in node.Children) + CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); + } + + private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit) + { + foreach (var child in node.Children) + { + var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale; + var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale; + var childLeft = child.X * scale; + var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale; + var midX = (parentRight + childLeft) / 2; + + // Proportional thickness matching the plan viewer (logarithmic, scaled down) + var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; + var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); + var thickness = Math.Max(0.5, fullThickness * scale); + + var geometry = new PathGeometry(); + var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false }; + figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); + geometry.Figures!.Add(figure); + + var linkBrush = GetLinkColorBrush(child, divergenceLimit); + + var path = new AvaloniaPath + { + Data = geometry, + Stroke = linkBrush, + StrokeThickness = thickness, + StrokeJoin = PenLineJoin.Round + }; + MinimapCanvas.Children.Add(path); + + RenderMinimapEdges(child, scale, divergenceLimit); + } + } + + private void RenderMinimapNodes(PlanNode node, double scale) + { + var w = PlanLayoutEngine.NodeWidth * scale; + var h = PlanLayoutEngine.GetNodeHeight(node) * scale; + // Use theme background colors with transparency + var bgBrush = node.IsExpensive + ? MinimapExpensiveNodeBgBrush + : FindBrushResource("BackgroundLightBrush"); + var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache; + + var border = new Border + { + Width = Math.Max(4, w), + Height = Math.Max(4, h), + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(0.5), + CornerRadius = new CornerRadius(1) + }; + + // Show a small icon inside the node if space allows + var iconBitmap = IconHelper.LoadIcon(node.IconName); + if (iconBitmap != null) + { + var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16); + if (iconSize >= 6) + { + border.Child = new Image + { + Source = iconBitmap, + Width = iconSize, + Height = iconSize, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + } + } + + Canvas.SetLeft(border, node.X * scale); + Canvas.SetTop(border, node.Y * scale); + MinimapCanvas.Children.Add(border); + + _minimapNodeMap[border] = node; + + foreach (var child in node.Children) + RenderMinimapNodes(child, scale); + } + + private void RenderMinimapViewportBox(double scale) + { + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var contentW = PlanCanvas.Width * _zoomLevel; + var contentH = PlanCanvas.Height * _zoomLevel; + + var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale; + var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale; + var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale; + var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale; + + var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab + ? ab.Color + : Color.FromRgb(0x2E, 0xAE, 0xF1); + var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)); + var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B)); + + _minimapViewportBox = new Border + { + Width = Math.Max(4, boxW), + Height = Math.Max(4, boxH), + Background = themeBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1.5), + CornerRadius = new CornerRadius(1), + Cursor = new Cursor(StandardCursorType.SizeAll) + }; + Canvas.SetLeft(_minimapViewportBox, boxX); + Canvas.SetTop(_minimapViewportBox, boxY); + MinimapCanvas.Children.Add(_minimapViewportBox); + } + + private void UpdateMinimapViewportBox() + { + if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null) + return; + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; + + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) return; + + var scaleX = canvasW / PlanCanvas.Width; + var scaleY = canvasH / PlanCanvas.Height; + var scale = Math.Min(scaleX, scaleY); + + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var contentW = PlanCanvas.Width * _zoomLevel; + var contentH = PlanCanvas.Height * _zoomLevel; + + _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale); + _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale); + Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale); + Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale); + } + + private double GetMinimapScale() + { + if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1; + var canvasW = MinimapCanvas.Bounds.Width; + var canvasH = MinimapCanvas.Bounds.Height; + if (canvasW <= 0 || canvasH <= 0) return 1; + return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height); + } + + private void UpdateMinimapSelection(PlanNode node) + { + if (!MinimapPanel.IsVisible) return; + + // Reset previous selection highlight + if (_minimapSelectedNode != null) + { + var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode); + _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true } + ? OrangeRedBrush + : _minimapNodeBorderBrushCache; + _minimapSelectedNode.BorderThickness = new Thickness(0.5); + _minimapSelectedNode = null; + } + + // Find and highlight the new node + foreach (var (border, n) in _minimapNodeMap) + { + if (n == node) + { + border.BorderBrush = SelectionBrush; + border.BorderThickness = new Thickness(2); + _minimapSelectedNode = border; + break; + } + } + } + + private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(MinimapCanvas); + if (!point.Properties.IsLeftButtonPressed) return; + + var pos = point.Position; + var scale = GetMinimapScale(); + + // Check if clicking on a node (single click = center, double click = zoom) + if (e.ClickCount == 2) + { + // Double click: find node under pointer and zoom to it + var node = FindMinimapNodeAt(pos); + if (node != null) + { + ZoomToNode(node); + e.Handled = true; + return; + } + } + + if (e.ClickCount == 1) + { + // Check if over a minimap node for single-click centering + var node = FindMinimapNodeAt(pos); + if (node != null) + { + CenterOnNode(node); + e.Handled = true; + return; + } + } + + // Start viewport box drag + _minimapDragging = true; + + // Move viewport center to click position + ScrollPlanViewerToMinimapPoint(pos, scale); + + e.Pointer.Capture(MinimapCanvas); + e.Handled = true; + } + + private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_minimapDragging) return; + + var pos = e.GetPosition(MinimapCanvas); + var scale = GetMinimapScale(); + ScrollPlanViewerToMinimapPoint(pos, scale); + e.Handled = true; + } + + private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_minimapDragging) return; + _minimapDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + + private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale) + { + if (scale <= 0) return; + // Convert minimap coords to plan content coords + var contentX = minimapPoint.X / scale; + var contentY = minimapPoint.Y / scale; + + // Center the viewport on this content point + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2); + var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(offsetX, offsetY); + }); + } + + private PlanNode? FindMinimapNodeAt(Point pos) + { + foreach (var (border, node) in _minimapNodeMap) + { + var left = Canvas.GetLeft(border); + var top = Canvas.GetTop(border); + if (pos.X >= left && pos.X <= left + border.Width && + pos.Y >= top && pos.Y <= top + border.Height) + return node; + } + return null; + } + + private void CenterOnNode(PlanNode node) + { + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(node); + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; + var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; + centerX = Math.Max(0, centerX); + centerY = Math.Max(0, centerY); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(centerX, centerY); + }); + } + + private void ZoomToNode(PlanNode node) + { + var viewW = PlanScrollViewer.Bounds.Width; + var viewH = PlanScrollViewer.Bounds.Height; + if (viewW <= 0 || viewH <= 0) return; + + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(node); + + // Zoom so the node takes about 1/3 of the viewport + var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3)); + fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom)); + SetZoom(fitZoom); + + // Center on the node + var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; + var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY)); + }); + + // Also select the node in the plan + foreach (var (border, n) in _nodeBorderMap) + { + if (n == node) + { + SelectNode(border, node); + break; + } + } + } + + private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(MinimapPanel); + if (!point.Properties.IsLeftButtonPressed) return; + _minimapResizing = true; + _minimapResizeStart = point.Position; + _minimapResizeStartW = MinimapPanel.Width; + _minimapResizeStartH = MinimapPanel.Height; + e.Pointer.Capture((Control)sender!); + e.Handled = true; + } + + private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e) + { + if (!_minimapResizing) return; + var current = e.GetPosition(MinimapPanel); + var dx = current.X - _minimapResizeStart.X; + var dy = current.Y - _minimapResizeStart.Y; + var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx)); + var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy)); + MinimapPanel.Width = newW; + MinimapPanel.Height = newH; + _minimapWidth = newW; + _minimapHeight = newH; + e.Handled = true; + + // Re-render after resize + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background); + } + + private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_minimapResizing) return; + _minimapResizing = false; + e.Pointer.Capture(null); + e.Handled = true; + RenderMinimap(); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs new file mode 100644 index 0000000..24a05d4 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs @@ -0,0 +1,1860 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowPropertiesPanel(PlanNode node) + { + PropertiesContent.Children.Clear(); + _sectionLabelColumns.Clear(); + _currentSectionGrid = null; + _currentSectionRowIndex = 0; + + // Header + var headerText = node.PhysicalOp; + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + headerText += $" ({node.LogicalOp})"; + PropertiesHeader.Text = headerText; + PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; + + // === General Section === + AddPropertySection("General"); + AddPropertyRow("Physical Operation", node.PhysicalOp); + AddPropertyRow("Logical Operation", node.LogicalOp); + AddPropertyRow("Node ID", $"{node.NodeId}"); + if (!string.IsNullOrEmpty(node.ExecutionMode)) + AddPropertyRow("Execution Mode", node.ExecutionMode); + if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) + AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode); + AddPropertyRow("Parallel", node.Parallel ? "True" : "False"); + if (node.Partitioned) + AddPropertyRow("Partitioned", "True"); + if (node.EstimatedDOP > 0) + AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}"); + + // Scan/seek-related properties + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddPropertyRow("Ordered", node.Ordered ? "True" : "False"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddPropertyRow("Scan Direction", node.ScanDirection); + AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False"); + AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False"); + AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False"); + AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False"); + if (node.Lookup) + AddPropertyRow("Lookup", "True"); + if (node.DynamicSeek) + AddPropertyRow("Dynamic Seek", "True"); + } + + if (!string.IsNullOrEmpty(node.StorageType)) + AddPropertyRow("Storage", node.StorageType); + if (node.IsAdaptive) + AddPropertyRow("Adaptive", "True"); + if (node.SpillOccurredDetail) + AddPropertyRow("Spill Occurred", "True"); + + // === Object Section === + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddPropertySection("Object"); + AddPropertyRow("Full Name", node.FullObjectName, isCode: true); + if (!string.IsNullOrEmpty(node.ServerName)) + AddPropertyRow("Server", node.ServerName); + if (!string.IsNullOrEmpty(node.DatabaseName)) + AddPropertyRow("Database", node.DatabaseName); + if (!string.IsNullOrEmpty(node.ObjectAlias)) + AddPropertyRow("Alias", node.ObjectAlias); + if (!string.IsNullOrEmpty(node.IndexName)) + AddPropertyRow("Index", node.IndexName); + if (!string.IsNullOrEmpty(node.IndexKind)) + AddPropertyRow("Index Kind", node.IndexKind); + if (node.FilteredIndex) + AddPropertyRow("Filtered Index", "True"); + if (node.TableReferenceId > 0) + AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}"); + } + + // === Operator Details Section === + var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy) + || !string.IsNullOrEmpty(node.TopExpression) + || !string.IsNullOrEmpty(node.GroupBy) + || !string.IsNullOrEmpty(node.PartitionColumns) + || !string.IsNullOrEmpty(node.HashKeys) + || !string.IsNullOrEmpty(node.SegmentColumn) + || !string.IsNullOrEmpty(node.DefinedValues) + || !string.IsNullOrEmpty(node.OuterReferences) + || !string.IsNullOrEmpty(node.InnerSideJoinColumns) + || !string.IsNullOrEmpty(node.OuterSideJoinColumns) + || !string.IsNullOrEmpty(node.ActionColumn) + || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator + || node.SortDistinct || node.StartupExpression + || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch + || node.WithTies || node.Remoting || node.LocalParallelism + || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0 + || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0 + || !string.IsNullOrEmpty(node.ConstantScanValues) + || !string.IsNullOrEmpty(node.UdxUsedColumns); + + if (hasOperatorDetails) + { + AddPropertySection("Operator Details"); + if (!string.IsNullOrEmpty(node.OrderBy)) + AddPropertyRow("Order By", node.OrderBy, isCode: true); + if (!string.IsNullOrEmpty(node.TopExpression)) + { + var topText = node.TopExpression; + if (node.IsPercent) topText += " PERCENT"; + if (node.WithTies) topText += " WITH TIES"; + AddPropertyRow("Top", topText); + } + if (node.SortDistinct) + AddPropertyRow("Distinct Sort", "True"); + if (node.StartupExpression) + AddPropertyRow("Startup Expression", "True"); + if (node.NLOptimized) + AddPropertyRow("Optimized", "True"); + if (node.WithOrderedPrefetch) + AddPropertyRow("Ordered Prefetch", "True"); + if (node.WithUnorderedPrefetch) + AddPropertyRow("Unordered Prefetch", "True"); + if (node.BitmapCreator) + AddPropertyRow("Bitmap Creator", "True"); + if (node.Remoting) + AddPropertyRow("Remoting", "True"); + if (node.LocalParallelism) + AddPropertyRow("Local Parallelism", "True"); + if (!string.IsNullOrEmpty(node.GroupBy)) + AddPropertyRow("Group By", node.GroupBy, isCode: true); + if (!string.IsNullOrEmpty(node.PartitionColumns)) + AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeys)) + AddPropertyRow("Hash Keys", node.HashKeys, isCode: true); + if (!string.IsNullOrEmpty(node.OffsetExpression)) + AddPropertyRow("Offset", node.OffsetExpression); + if (node.TopRows > 0) + AddPropertyRow("Rows", $"{node.TopRows}"); + if (node.SpoolStack) + AddPropertyRow("Stack Spool", "True"); + if (node.PrimaryNodeId > 0) + AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}"); + if (node.DMLRequestSort) + AddPropertyRow("DML Request Sort", "True"); + if (node.NonClusteredIndexCount > 0) + { + AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}"); + foreach (var ixName in node.NonClusteredIndexNames) + AddPropertyRow("", ixName, isCode: true); + } + if (!string.IsNullOrEmpty(node.ActionColumn)) + AddPropertyRow("Action Column", node.ActionColumn, isCode: true); + if (!string.IsNullOrEmpty(node.SegmentColumn)) + AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true); + if (!string.IsNullOrEmpty(node.DefinedValues)) + AddPropertyRow("Defined Values", node.DefinedValues, isCode: true); + if (!string.IsNullOrEmpty(node.OuterReferences)) + AddPropertyRow("Outer References", node.OuterReferences, isCode: true); + if (!string.IsNullOrEmpty(node.InnerSideJoinColumns)) + AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); + if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) + AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); + if (node.PhysicalOp == "Merge Join") + AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); + else if (node.ManyToMany) + AddPropertyRow("Many to Many", "Yes"); + if (!string.IsNullOrEmpty(node.ConstantScanValues)) + AddPropertyRow("Values", node.ConstantScanValues, isCode: true); + if (!string.IsNullOrEmpty(node.UdxUsedColumns)) + AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true); + if (node.RowCount) + AddPropertyRow("Row Count", "True"); + if (node.ForceSeekColumnCount > 0) + AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}"); + if (!string.IsNullOrEmpty(node.PartitionId)) + AddPropertyRow("Partition Id", node.PartitionId, isCode: true); + if (node.IsStarJoin) + AddPropertyRow("Star Join Root", "True"); + if (!string.IsNullOrEmpty(node.StarJoinOperationType)) + AddPropertyRow("Star Join Type", node.StarJoinOperationType); + if (!string.IsNullOrEmpty(node.ProbeColumn)) + AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true); + if (node.InRow) + AddPropertyRow("In-Row", "True"); + if (node.ComputeSequence) + AddPropertyRow("Compute Sequence", "True"); + if (node.RollupHighestLevel > 0) + AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}"); + if (node.RollupLevels.Count > 0) + AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels)); + if (!string.IsNullOrEmpty(node.TvfParameters)) + AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true); + if (!string.IsNullOrEmpty(node.OriginalActionColumn)) + AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true); + if (!string.IsNullOrEmpty(node.TieColumns)) + AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true); + if (!string.IsNullOrEmpty(node.UdxName)) + AddPropertyRow("UDX Name", node.UdxName); + if (node.GroupExecuted) + AddPropertyRow("Group Executed", "True"); + if (node.RemoteDataAccess) + AddPropertyRow("Remote Data Access", "True"); + if (node.OptimizedHalloweenProtectionUsed) + AddPropertyRow("Halloween Protection", "True"); + if (node.StatsCollectionId > 0) + AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}"); + } + + // === Scalar UDFs === + if (node.ScalarUdfs.Count > 0) + { + AddPropertySection("Scalar UDFs"); + foreach (var udf in node.ScalarUdfs) + { + var udfDetail = udf.FunctionName; + if (udf.IsClrFunction) + { + udfDetail += " (CLR)"; + if (!string.IsNullOrEmpty(udf.ClrAssembly)) + udfDetail += $"\n Assembly: {udf.ClrAssembly}"; + if (!string.IsNullOrEmpty(udf.ClrClass)) + udfDetail += $"\n Class: {udf.ClrClass}"; + if (!string.IsNullOrEmpty(udf.ClrMethod)) + udfDetail += $"\n Method: {udf.ClrMethod}"; + } + AddPropertyRow("UDF", udfDetail, isCode: true); + } + } + + // === Named Parameters (IndexScan) === + if (node.NamedParameters.Count > 0) + { + AddPropertySection("Named Parameters"); + foreach (var np in node.NamedParameters) + AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true); + } + + // === Per-Operator Indexed Views === + if (node.OperatorIndexedViews.Count > 0) + { + AddPropertySection("Operator Indexed Views"); + foreach (var iv in node.OperatorIndexedViews) + AddPropertyRow("View", iv, isCode: true); + } + + // === Suggested Index (Eager Spool) === + if (!string.IsNullOrEmpty(node.SuggestedIndex)) + { + AddPropertySection("Suggested Index"); + AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true); + } + + // === Remote Operator === + if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource) + || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery)) + { + AddPropertySection("Remote Operator"); + if (!string.IsNullOrEmpty(node.RemoteDestination)) + AddPropertyRow("Destination", node.RemoteDestination); + if (!string.IsNullOrEmpty(node.RemoteSource)) + AddPropertyRow("Source", node.RemoteSource); + if (!string.IsNullOrEmpty(node.RemoteObject)) + AddPropertyRow("Object", node.RemoteObject, isCode: true); + if (!string.IsNullOrEmpty(node.RemoteQuery)) + AddPropertyRow("Query", node.RemoteQuery, isCode: true); + } + + // === Foreign Key References Section === + if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0) + { + AddPropertySection("Foreign Key References"); + if (node.ForeignKeyReferencesCount > 0) + AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}"); + if (node.NoMatchingIndexCount > 0) + AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}"); + if (node.PartialMatchingIndexCount > 0) + AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}"); + } + + // === Adaptive Join Section === + if (node.IsAdaptive) + { + AddPropertySection("Adaptive Join"); + if (!string.IsNullOrEmpty(node.EstimatedJoinType)) + AddPropertyRow("Est. Join Type", node.EstimatedJoinType); + if (!string.IsNullOrEmpty(node.ActualJoinType)) + AddPropertyRow("Actual Join Type", node.ActualJoinType); + if (node.AdaptiveThresholdRows > 0) + AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}"); + } + + // === Estimated Costs Section === + AddPropertySection("Estimated Costs"); + AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)"); + AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); + AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}"); + AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}"); + + // === Estimated Rows Section === + AddPropertySection("Estimated Rows"); + var estExecs = 1 + node.EstimateRebinds; + AddPropertyRow("Est. Executions", $"{estExecs:N0}"); + AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}"); + AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}"); + if (node.EstimatedRowsRead > 0) + AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}"); + if (node.EstimateRowsWithoutRowGoal > 0) + AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}"); + if (node.TableCardinality > 0) + AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}"); + AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B"); + AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}"); + AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}"); + + // === Actual Stats Section (if actual plan) === + if (node.HasActualStats) + { + AddPropertySection("Actual Statistics"); + AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true); + if (node.ActualRowsRead > 0) + { + AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true); + } + AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true); + if (node.ActualRebinds > 0) + AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}"); + if (node.ActualRewinds > 0) + AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}"); + + // Runtime partition summary + if (node.PartitionsAccessed > 0) + { + AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}"); + if (!string.IsNullOrEmpty(node.PartitionRanges)) + AddPropertyRow("Partition Ranges", node.PartitionRanges); + } + + // Timing + if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0 + || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0) + { + AddPropertySection("Actual Timing"); + if (node.ActualElapsedMs > 0) + { + AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true); + } + if (node.ActualCPUMs > 0) + { + AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true); + } + if (node.UdfElapsedTimeMs > 0) + AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms"); + if (node.UdfCpuTimeMs > 0) + AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms"); + } + + // I/O + var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0 + || node.ActualScans > 0 || node.ActualReadAheads > 0 + || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0; + if (hasIo) + { + AddPropertySection("Actual I/O"); + AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true); + if (node.ActualPhysicalReads > 0) + { + AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true); + } + if (node.ActualScans > 0) + { + AddPropertyRow("Scans", $"{node.ActualScans:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true); + } + if (node.ActualReadAheads > 0) + { + AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}"); + if (node.PerThreadStats.Count > 1) + foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0)) + AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true); + } + if (node.ActualSegmentReads > 0) + AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}"); + if (node.ActualSegmentSkips > 0) + AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}"); + } + + // LOB I/O + var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0 + || node.ActualLobReadAheads > 0; + if (hasLobIo) + { + AddPropertySection("Actual LOB I/O"); + if (node.ActualLobLogicalReads > 0) + AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}"); + if (node.ActualLobPhysicalReads > 0) + AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}"); + if (node.ActualLobReadAheads > 0) + AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}"); + } + } + + // === Predicates Section === + var hasPredicates = !string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate) + || !string.IsNullOrEmpty(node.HashKeysProbe) || !string.IsNullOrEmpty(node.HashKeysBuild) + || !string.IsNullOrEmpty(node.BuildResidual) || !string.IsNullOrEmpty(node.ProbeResidual) + || !string.IsNullOrEmpty(node.MergeResidual) || !string.IsNullOrEmpty(node.PassThru) + || !string.IsNullOrEmpty(node.SetPredicate) + || node.GuessedSelectivity; + if (hasPredicates) + { + AddPropertySection("Predicates"); + if (!string.IsNullOrEmpty(node.SeekPredicates)) + AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true); + if (!string.IsNullOrEmpty(node.Predicate)) + AddPropertyRow("Predicate", node.Predicate, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeysBuild)) + AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true); + if (!string.IsNullOrEmpty(node.HashKeysProbe)) + AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true); + if (!string.IsNullOrEmpty(node.BuildResidual)) + AddPropertyRow("Build Residual", node.BuildResidual, isCode: true); + if (!string.IsNullOrEmpty(node.ProbeResidual)) + AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true); + if (!string.IsNullOrEmpty(node.MergeResidual)) + AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true); + if (!string.IsNullOrEmpty(node.PassThru)) + AddPropertyRow("Pass Through", node.PassThru, isCode: true); + if (!string.IsNullOrEmpty(node.SetPredicate)) + AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true); + if (node.GuessedSelectivity) + AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)"); + } + + // === Output Columns === + if (!string.IsNullOrEmpty(node.OutputColumns)) + { + AddPropertySection("Output"); + AddPropertyRow("Columns", node.OutputColumns, isCode: true); + } + + // === Memory === + if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0 + || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0 + || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0) + { + AddPropertySection("Memory"); + if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB"); + if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB"); + if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB"); + if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB"); + if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB"); + if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB"); + if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}"); + if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}"); + } + + // === Root node only: statement-level sections === + if (node.Parent == null && _currentStatement != null) + { + var s = _currentStatement; + + // === Statement Text === + if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName)) + { + AddPropertySection("Statement"); + if (!string.IsNullOrEmpty(s.StatementText)) + AddPropertyRow("Text", s.StatementText, isCode: true); + if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText) + AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true); + if (!string.IsNullOrEmpty(s.StmtUseDatabaseName)) + AddPropertyRow("USE Database", s.StmtUseDatabaseName); + } + + // === Cursor Info === + if (!string.IsNullOrEmpty(s.CursorName)) + { + AddPropertySection("Cursor Info"); + AddPropertyRow("Cursor Name", s.CursorName); + if (!string.IsNullOrEmpty(s.CursorActualType)) + AddPropertyRow("Actual Type", s.CursorActualType); + if (!string.IsNullOrEmpty(s.CursorRequestedType)) + AddPropertyRow("Requested Type", s.CursorRequestedType); + if (!string.IsNullOrEmpty(s.CursorConcurrency)) + AddPropertyRow("Concurrency", s.CursorConcurrency); + AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False"); + } + + // === Statement Memory Grant === + if (s.MemoryGrant != null) + { + var mg = s.MemoryGrant; + AddPropertySection("Memory Grant Info"); + AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB"); + AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB"); + AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB"); + AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB"); + AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB"); + AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB"); + AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB"); + if (mg.GrantWaitTimeMs > 0) + AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms"); + if (mg.LastRequestedMemoryKB > 0) + AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB"); + if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted)) + AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted); + } + + // === Statement Info === + AddPropertySection("Statement Info"); + if (!string.IsNullOrEmpty(s.StatementOptmLevel)) + AddPropertyRow("Optimization Level", s.StatementOptmLevel); + if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason)) + AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason); + if (s.CardinalityEstimationModelVersion > 0) + AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}"); + if (s.DegreeOfParallelism > 0) + AddPropertyRow("DOP", $"{s.DegreeOfParallelism}"); + if (s.EffectiveDOP > 0) + AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}"); + if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted)) + AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted); + if (!string.IsNullOrEmpty(s.NonParallelPlanReason)) + AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason); + if (s.MaxQueryMemoryKB > 0) + AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB"); + if (s.QueryPlanMemoryGrantKB > 0) + AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB"); + AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms"); + AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms"); + AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB"); + if (s.CachedPlanSizeKB > 0) + AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB"); + AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False"); + AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False"); + AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False"); + AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}"); + if (!string.IsNullOrEmpty(s.QueryHash)) + AddPropertyRow("Query Hash", s.QueryHash, isCode: true); + if (!string.IsNullOrEmpty(s.QueryPlanHash)) + AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true); + if (!string.IsNullOrEmpty(s.StatementSqlHandle)) + AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true); + AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}"); + AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}"); + + // Plan Guide + if (!string.IsNullOrEmpty(s.PlanGuideName)) + { + AddPropertyRow("Plan Guide", s.PlanGuideName); + if (!string.IsNullOrEmpty(s.PlanGuideDB)) + AddPropertyRow("Plan Guide DB", s.PlanGuideDB); + } + if (s.UsePlan) + AddPropertyRow("USE PLAN", "True"); + + // Query Store Hints + if (s.QueryStoreStatementHintId > 0) + { + AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}"); + if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText)) + AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true); + if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource)) + AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource); + } + + // === Feature Flags === + if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs + || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0 + || s.QueryVariantID > 0) + { + AddPropertySection("Feature Flags"); + if (s.ContainsInterleavedExecutionCandidates) + AddPropertyRow("Interleaved Execution", "True"); + if (s.ContainsInlineScalarTsqlUdfs) + AddPropertyRow("Inline Scalar UDFs", "True"); + if (s.ContainsLedgerTables) + AddPropertyRow("Ledger Tables", "True"); + if (s.ExclusiveProfileTimeActive) + AddPropertyRow("Exclusive Profile Time", "True"); + if (s.QueryCompilationReplay > 0) + AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}"); + if (s.QueryVariantID > 0) + AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}"); + } + + // === PSP Dispatcher === + if (s.Dispatcher != null) + { + AddPropertySection("PSP Dispatcher"); + if (!string.IsNullOrEmpty(s.DispatcherPlanHandle)) + AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true); + foreach (var psp in s.Dispatcher.ParameterSensitivePredicates) + { + var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]"; + var predText = psp.PredicateText ?? ""; + AddPropertyRow("Predicate", $"{predText} {range}", isCode: true); + foreach (var stat in psp.Statistics) + { + var statLabel = !string.IsNullOrEmpty(stat.TableName) + ? $" {stat.TableName}.{stat.StatisticsName}" + : $" {stat.StatisticsName}"; + AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true); + } + } + foreach (var opt in s.Dispatcher.OptionalParameterPredicates) + { + if (!string.IsNullOrEmpty(opt.PredicateText)) + AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true); + } + } + + // === Cardinality Feedback === + if (s.CardinalityFeedback.Count > 0) + { + AddPropertySection("Cardinality Feedback"); + foreach (var cf in s.CardinalityFeedback) + AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}"); + } + + // === Optimization Replay === + if (!string.IsNullOrEmpty(s.OptimizationReplayScript)) + { + AddPropertySection("Optimization Replay"); + AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true); + } + + // === Template Plan Guide === + if (!string.IsNullOrEmpty(s.TemplatePlanGuideName)) + { + AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName); + if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB)) + AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB); + } + + // === Handles === + if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle)) + { + AddPropertySection("Handles"); + if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle)) + AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true); + if (!string.IsNullOrEmpty(s.BatchSqlHandle)) + AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true); + } + + // === Set Options === + if (s.SetOptions != null) + { + var so = s.SetOptions; + AddPropertySection("Set Options"); + AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False"); + AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False"); + AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False"); + AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False"); + AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False"); + AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False"); + AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False"); + } + + // === Optimizer Hardware Properties === + if (s.HardwareProperties != null) + { + var hw = s.HardwareProperties; + AddPropertySection("Hardware Properties"); + AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB"); + AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}"); + AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}"); + if (hw.MaxCompileMemory > 0) + AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB"); + } + + // === Plan Version === + if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build))) + { + AddPropertySection("Plan Version"); + if (!string.IsNullOrEmpty(_currentPlan.BuildVersion)) + AddPropertyRow("Build Version", _currentPlan.BuildVersion); + if (!string.IsNullOrEmpty(_currentPlan.Build)) + AddPropertyRow("Build", _currentPlan.Build); + if (_currentPlan.ClusteredMode) + AddPropertyRow("Clustered Mode", "True"); + } + + // === Optimizer Stats Usage === + if (s.StatsUsage.Count > 0) + { + AddPropertySection("Statistics Used"); + foreach (var stat in s.StatsUsage) + { + var statLabel = !string.IsNullOrEmpty(stat.TableName) + ? $"{stat.TableName}.{stat.StatisticsName}" + : stat.StatisticsName; + var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%"; + if (!string.IsNullOrEmpty(stat.LastUpdate)) + statDetail += $", Updated: {stat.LastUpdate}"; + AddPropertyRow(statLabel, statDetail); + } + } + + // === Parameters === + if (s.Parameters.Count > 0) + { + AddPropertySection("Parameters"); + foreach (var p in s.Parameters) + { + var paramText = p.DataType; + if (!string.IsNullOrEmpty(p.CompiledValue)) + paramText += $", Compiled: {p.CompiledValue}"; + if (!string.IsNullOrEmpty(p.RuntimeValue)) + paramText += $", Runtime: {p.RuntimeValue}"; + AddPropertyRow(p.Name, paramText); + } + } + + // === Query Time Stats (actual plans) === + if (s.QueryTimeStats != null) + { + AddPropertySection("Query Time Stats"); + AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms"); + AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms"); + if (s.QueryUdfCpuTimeMs > 0) + AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms"); + if (s.QueryUdfElapsedTimeMs > 0) + AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms"); + } + + // === Thread Stats (actual plans) === + if (s.ThreadStats != null) + { + AddPropertySection("Thread Stats"); + AddPropertyRow("Branches", $"{s.ThreadStats.Branches}"); + AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}"); + var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads); + if (totalReserved > 0) + { + AddPropertyRow("Reserved Threads", $"{totalReserved}"); + if (totalReserved > s.ThreadStats.UsedThreads) + AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}"); + } + foreach (var res in s.ThreadStats.Reservations) + AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved"); + } + + // === Wait Stats (actual plans) === + if (s.WaitStats.Count > 0) + { + AddPropertySection("Wait Stats"); + foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs)) + AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)"); + } + + // === Trace Flags === + if (s.TraceFlags.Count > 0) + { + AddPropertySection("Trace Flags"); + foreach (var tf in s.TraceFlags) + { + var tfLabel = $"TF {tf.Value}"; + var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}"; + AddPropertyRow(tfLabel, tfDetail); + } + } + + // === Indexed Views === + if (s.IndexedViews.Count > 0) + { + AddPropertySection("Indexed Views"); + foreach (var iv in s.IndexedViews) + AddPropertyRow("View", iv, isCode: true); + } + + // === Plan-Level Warnings === + if (s.PlanWarnings.Count > 0) + { + var planWarningsPanel = new StackPanel(); + var sortedPlanWarnings = s.PlanWarnings + .OrderByDescending(w => w.MaxBenefitPercent ?? -1) + .ThenByDescending(w => w.Severity) + .ThenBy(w => w.WarningType); + foreach (var w in sortedPlanWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + var legacyTag = w.IsLegacy ? " [legacy]" : ""; + var planWarnHeader = w.MaxBenefitPercent.HasValue + ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" + : $"\u26A0 {w.WarningType}{legacyTag}"; + warnPanel.Children.Add(new TextBlock + { + Text = planWarnHeader, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + if (!string.IsNullOrEmpty(w.ActionableFix)) + { + warnPanel.Children.Add(new TextBlock + { + Text = w.ActionableFix, + FontSize = 11, + FontStyle = FontStyle.Italic, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 2, 0, 0) + }); + } + planWarningsPanel.Children.Add(warnPanel); + } + + var planWarningsExpander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = "Plan Warnings", + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = planWarningsPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(planWarningsExpander); + } + + // === Missing Indexes === + if (s.MissingIndexes.Count > 0) + { + AddPropertySection("Missing Indexes"); + foreach (var mi in s.MissingIndexes) + { + AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%"); + if (!string.IsNullOrEmpty(mi.CreateStatement)) + AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true); + } + } + } + + // === Warnings === + if (node.HasWarnings) + { + var warningsPanel = new StackPanel(); + var sortedNodeWarnings = node.Warnings + .OrderByDescending(w => w.MaxBenefitPercent ?? -1) + .ThenByDescending(w => w.Severity) + .ThenBy(w => w.WarningType); + foreach (var w in sortedNodeWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + var nodeLegacyTag = w.IsLegacy ? " [legacy]" : ""; + var nodeWarnHeader = w.MaxBenefitPercent.HasValue + ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" + : $"\u26A0 {w.WarningType}{nodeLegacyTag}"; + warnPanel.Children.Add(new TextBlock + { + Text = nodeWarnHeader, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + warningsPanel.Children.Add(warnPanel); + } + + var warningsExpander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = "Warnings", + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = warningsPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(warningsExpander); + } + + // Show the panel + _propertiesColumn.Width = new GridLength(320); + _splitterColumn.Width = new GridLength(5); + PropertiesSplitter.IsVisible = true; + PropertiesPanel.IsVisible = true; + } + + private void AddPropertySection(string title) + { + var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) }; + _sectionLabelColumns.Add(labelCol); + + // Sync column widths across sections when user drags the GridSplitter + labelCol.PropertyChanged += (_, args) => + { + if (args.Property.Name != "Width" || _isSyncingColumnWidth) return; + _isSyncingColumnWidth = true; + _propertyLabelWidth = labelCol.Width.Value; + foreach (var col in _sectionLabelColumns) + { + if (col != labelCol) + col.Width = labelCol.Width; + } + _isSyncingColumnWidth = false; + }; + + var sectionGrid = new Grid + { + Margin = new Thickness(6, 0, 6, 0) + }; + sectionGrid.ColumnDefinitions.Add(labelCol); + sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) }); + sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + _currentSectionGrid = sectionGrid; + _currentSectionRowIndex = 0; + + var expander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = title, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = sectionGrid, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + PropertiesContent.Children.Add(expander); + } + + private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) + { + if (_currentSectionGrid == null) return; + + var row = _currentSectionRowIndex++; + _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var labelBlock = new TextBlock + { + Text = label, + FontSize = indent ? 10 : 11, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Top, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(indent ? 16 : 4, 2, 0, 2) + }; + Grid.SetColumn(labelBlock, 0); + Grid.SetRow(labelBlock, row); + _currentSectionGrid.Children.Add(labelBlock); + + // GridSplitter in column 1 (only in first row per section) + if (row == 0) + { + var splitter = new GridSplitter + { + Width = 4, + Background = Brushes.Transparent, + Foreground = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast) + }; + Grid.SetColumn(splitter, 1); + Grid.SetRow(splitter, 0); + Grid.SetRowSpan(splitter, 100); // span all rows + _currentSectionGrid.Children.Add(splitter); + } + + var valueBox = new TextBox + { + Text = value, + FontSize = indent ? 10 : 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + IsReadOnly = true, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + Margin = new Thickness(0, 2, 4, 2), + VerticalAlignment = VerticalAlignment.Top + }; + if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBox, 2); + Grid.SetRow(valueBox, row); + _currentSectionGrid.Children.Add(valueBox); + } + + private void CloseProperties_Click(object? sender, RoutedEventArgs e) + { + ClosePropertiesPanel(); + } + + private void ClosePropertiesPanel() + { + PropertiesPanel.IsVisible = false; + PropertiesSplitter.IsVisible = false; + _propertiesColumn.Width = new GridLength(0); + _splitterColumn.Width = new GridLength(0); + + // Deselect node + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + _selectedNodeBorder = null; + } + } + + private void ShowMissingIndexes(List indexes) + { + MissingIndexContent.Children.Clear(); + + if (indexes.Count > 0) + { + // Update expander header with count + MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; + + // Build each missing index row manually (no ItemsControl template binding) + foreach (var mi in indexes) + { + var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; + + var headerRow = new StackPanel { Orientation = Orientation.Horizontal }; + headerRow.Children.Add(new TextBlock + { + Text = mi.Table, + FontWeight = FontWeight.SemiBold, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $" \u2014 Impact: ", + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $"{mi.Impact:F1}%", + Foreground = new SolidColorBrush(Color.Parse("#FFB347")), + FontSize = 12 + }); + itemPanel.Children.Add(headerRow); + + if (!string.IsNullOrEmpty(mi.CreateStatement)) + { + itemPanel.Children.Add(new SelectableTextBlock + { + Text = mi.CreateStatement, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(12, 2, 0, 0) + }); + } + + MissingIndexContent.Children.Add(itemPanel); + } + + MissingIndexEmpty.IsVisible = false; + } + else + { + MissingIndexHeader.Text = "Missing Index Suggestions"; + MissingIndexEmpty.IsVisible = true; + } + } + + private void ShowParameters(PlanStatement statement) + { + ParametersContent.Children.Clear(); + ParametersEmpty.IsVisible = false; + + var parameters = statement.Parameters; + + if (parameters.Count == 0) + { + var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); + if (localVars.Count > 0) + { + ParametersHeader.Text = "Parameters"; + AddParameterAnnotation( + $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML", + "#FFB347"); + } + else + { + ParametersHeader.Text = "Parameters"; + ParametersEmpty.IsVisible = true; + } + return; + } + + ParametersHeader.Text = $"Parameters ({parameters.Count})"; + + var allCompiledNull = parameters.All(p => p.CompiledValue == null); + var hasCompiled = parameters.Any(p => p.CompiledValue != null); + var hasRuntime = parameters.Any(p => p.RuntimeValue != null); + + // Build a 4-column grid: Name | Data Type | Compiled | Runtime + // Only show Compiled/Runtime columns if at least one param has that value + var colDef = "Auto,Auto"; // Name, DataType always shown + int compiledCol = -1, runtimeCol = -1; + int nextCol = 2; + if (hasCompiled) + { + colDef += ",*"; + compiledCol = nextCol++; + } + if (hasRuntime) + { + colDef += ",*"; + runtimeCol = nextCol++; + } + // If neither compiled nor runtime, still add one value column for "?" + if (!hasCompiled && !hasRuntime) + { + colDef += ",*"; + compiledCol = nextCol++; + } + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) }; + int rowIndex = 0; + + // Header row + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold); + AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold); + if (compiledCol >= 0) + AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold); + if (runtimeCol >= 0) + AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold); + rowIndex++; + + foreach (var param in parameters) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + // Name + AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold); + + // Data type + AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB"); + + // Compiled value + if (compiledCol >= 0) + { + var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?"); + var compiledColor = param.CompiledValue != null ? "#E4E6EB" + : allCompiledNull ? "#E4E6EB" : "#E57373"; + AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor); + } + + // Runtime value — amber if it differs from compiled + if (runtimeCol >= 0) + { + var runtimeText = param.RuntimeValue ?? ""; + var sniffed = param.RuntimeValue != null + && param.CompiledValue != null + && param.RuntimeValue != param.CompiledValue; + var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB"; + var tooltip = sniffed + ? "Runtime value differs from compiled — possible parameter sniffing" + : null; + AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip); + } + + rowIndex++; + } + + ParametersContent.Children.Add(grid); + + // Annotations + if (allCompiledNull && parameters.Count > 0) + { + var hasOptimizeForUnknown = statement.StatementText + .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) + && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); + + if (hasOptimizeForUnknown) + { + AddParameterAnnotation( + "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values", + "#6BB5FF"); + } + else + { + AddParameterAnnotation( + "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", + "#FFB347"); + } + } + + var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); + if (unresolved.Count > 0) + { + AddParameterAnnotation( + $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list", + "#FFB347"); + } + } + + private static void AddParamCell(Grid grid, int row, int col, string text, string color, + FontWeight fontWeight = default, string? tooltip = null) + { + var tb = new TextBlock + { + Text = text, + FontSize = 11, + FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight, + Foreground = new SolidColorBrush(Color.Parse(color)), + Margin = new Thickness(0, 2, 10, 2), + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 200 + }; + // Name and DataType columns are short — no need for max width + if (col <= 1) + tb.MaxWidth = double.PositiveInfinity; + if (tooltip != null) + ToolTip.SetTip(tb, tooltip); + else if (text.Length > 30) + ToolTip.SetTip(tb, text); + Grid.SetRow(tb, row); + Grid.SetColumn(tb, col); + grid.Children.Add(tb); + } + + private void AddParameterAnnotation(string text, string color) + { + ParametersContent.Children.Add(new TextBlock + { + Text = text, + FontSize = 11, + FontStyle = FontStyle.Italic, + Foreground = new SolidColorBrush(Color.Parse(color)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 6, 0, 0) + }); + } + + private static List FindUnresolvedVariables(string queryText, List parameters, + PlanNode? rootNode = null) + { + var unresolved = new List(); + if (string.IsNullOrEmpty(queryText)) + return unresolved; + + var extractedNames = new HashSet( + parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + + // Collect table variable names from the plan tree so we don't misreport them as local variables + var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (rootNode != null) + CollectTableVariableNames(rootNode, tableVarNames); + + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in matches) + { + var varName = match.Value; + if (seenVars.Contains(varName) || extractedNames.Contains(varName)) + continue; + if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase)) + continue; + if (tableVarNames.Contains(varName)) + continue; + + seenVars.Add(varName); + unresolved.Add(varName); + } + + return unresolved; + } + + private static void CollectTableVariableNames(PlanNode node, HashSet names) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + // ObjectName is like "@t.c" — extract the table variable name "@t" + var dotIdx = node.ObjectName.IndexOf('.'); + var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName; + names.Add(tvName); + } + foreach (var child in node.Children) + CollectTableVariableNames(child, names); + } + + /// + /// Computes own CPU time for a node by subtracting child times in row mode. + /// Batch mode reports own time directly; row mode is cumulative from leaves up. + /// + private static long GetOwnCpuMs(PlanNode node) + { + if (node.ActualCPUMs <= 0) return 0; + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (mode == "Batch") return node.ActualCPUMs; + var childSum = GetChildCpuMsSum(node); + return Math.Max(0, node.ActualCPUMs - childSum); + } + + /// + /// Computes own elapsed time for a node by subtracting child times in row mode. + /// + private static long GetOwnElapsedMs(PlanNode node) + { + if (node.ActualElapsedMs <= 0) return 0; + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (mode == "Batch") return node.ActualElapsedMs; + + // Exchange operators: Thread 0 is the coordinator whose elapsed time is the + // wall clock for the entire parallel branch — not the operator's own work. + if (IsExchangeOperator(node)) + { + // If we have worker thread data, use max of worker threads + var workerMax = node.PerThreadStats + .Where(t => t.ThreadId > 0) + .Select(t => t.ActualElapsedMs) + .DefaultIfEmpty(0) + .Max(); + if (workerMax > 0) + { + var childSum = GetChildElapsedMsSum(node); + return Math.Max(0, workerMax - childSum); + } + // Thread 0 only (coordinator) — exchange does negligible own work + return 0; + } + + var childElapsedSum = GetChildElapsedMsSum(node); + return Math.Max(0, node.ActualElapsedMs - childElapsedSum); + } + + private static bool IsExchangeOperator(PlanNode node) => + node.PhysicalOp == "Parallelism" + || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams"; + + private static long GetChildCpuMsSum(PlanNode node) + { + long sum = 0; + foreach (var child in node.Children) + { + if (child.ActualCPUMs > 0) + sum += child.ActualCPUMs; + else + sum += GetChildCpuMsSum(child); // skip through transparent operators + } + return sum; + } + + private static long GetChildElapsedMsSum(PlanNode node) + { + long sum = 0; + foreach (var child in node.Children) + { + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + { + // Exchange: take max of children (parallel branches) + sum += child.Children + .Where(c => c.ActualElapsedMs > 0) + .Select(c => c.ActualElapsedMs) + .DefaultIfEmpty(0) + .Max(); + } + else if (child.ActualElapsedMs > 0) + { + sum += child.ActualElapsedMs; + } + else + { + sum += GetChildElapsedMsSum(child); // skip through transparent operators + } + } + return sum; + } + + private void ShowWaitStats(List waits, List benefits, bool isActualPlan) + { + WaitStatsContent.Children.Clear(); + + if (waits.Count == 0) + { + WaitStatsHeader.Text = "Wait Stats"; + WaitStatsEmpty.Text = isActualPlan + ? "No wait stats recorded" + : "No wait stats (estimated plan)"; + WaitStatsEmpty.IsVisible = true; + return; + } + + WaitStatsEmpty.IsVisible = false; + + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in benefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); + var maxWait = sorted[0].WaitTimeMs; + var totalWait = sorted.Sum(w => w.WaitTimeMs); + + // Update expander header with total + WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; + + // Build a single Grid for all rows so columns align + // Name, bar, duration, and benefit columns + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") + }; + for (int i = 0; i < sorted.Count; i++) + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + for (int i = 0; i < sorted.Count; i++) + { + var w = sorted[i]; + var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0; + var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType)); + + // Wait type name — colored by category + var nameText = new TextBlock + { + Text = w.WaitType, + FontSize = 12, + Foreground = new SolidColorBrush(Color.Parse(color)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 10, 2) + }; + Grid.SetRow(nameText, i); + Grid.SetColumn(nameText, 0); + grid.Children.Add(nameText); + + // Bar — semi-transparent category color, compact proportional indicator + var barColor = Color.Parse(color); + var colorBar = new Border + { + Width = Math.Max(4, barFraction * 60), + Height = 14, + Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)), + CornerRadius = new CornerRadius(2), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + Grid.SetRow(colorBar, i); + Grid.SetColumn(colorBar, 1); + grid.Children.Add(colorBar); + + // Duration text + var durationText = new TextBlock + { + Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", + FontSize = 12, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + Grid.SetRow(durationText, i); + Grid.SetColumn(durationText, 2); + grid.Children.Add(durationText); + + // Benefit % (if available) + if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) + { + var benefitText = new TextBlock + { + Text = $"up to {benefitPct:N0}%", + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#8b949e")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(benefitText, i); + Grid.SetColumn(benefitText, 3); + grid.Children.Add(benefitText); + } + } + + WaitStatsContent.Children.Add(grid); + + } + + private void ShowRuntimeSummary(PlanStatement statement) + { + RuntimeSummaryContent.Children.Clear(); + + var labelColor = "#E4E6EB"; + var valueColor = "#E4E6EB"; + + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*") + }; + int rowIndex = 0; + + void AddRow(string label, string value, string? color = null) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + var labelText = new TextBlock + { + Text = label, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(labelColor)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 8, 1) + }; + Grid.SetRow(labelText, rowIndex); + Grid.SetColumn(labelText, 0); + grid.Children.Add(labelText); + + var valueText = new TextBlock + { + Text = value, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)), + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(valueText, rowIndex); + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + + rowIndex++; + } + + // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%. + // Loosened per Joe's feedback (#215 C1): for memory grants, moderate + // utilization (e.g. 60%) is fine — operators can spill near their max, + // so we shouldn't flag anything above a real over-grant threshold. + static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" + : pct >= 20 ? "#FFB347" : "#E57373"; + + // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), + // any operator spilled (orange), otherwise tier by utilization. + static string MemoryGrantColor(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "#E57373"; + if (hasSpill) return "#FFB347"; + if (pctUsed >= 40) return "#E4E6EB"; + if (pctUsed >= 20) return "#FFB347"; + return "#E57373"; + } + + // E7: rename the panel title for estimated plans + var isEstimated = statement.QueryTimeStats == null; + RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; + + var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); + + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. + + if (statement.QueryTimeStats != null) + { + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + if (statement.QueryTimeStats.ElapsedTimeMs > 0) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + AddRow("CPU:Elapsed", ratio.ToString("N2")); + } + } + + // DOP + parallelism efficiency + if (statement.DegreeOfParallelism > 0) + { + var dopText = statement.DegreeOfParallelism.ToString(); + string? dopColor = null; + if (statement.QueryTimeStats != null && + statement.QueryTimeStats.ElapsedTimeMs > 0 && + statement.QueryTimeStats.CpuTimeMs > 0 && + statement.DegreeOfParallelism > 1) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0); + efficiency = Math.Max(0.0, efficiency); + dopText += $" ({efficiency:N0}% efficient)"; + dopColor = EfficiencyColor(efficiency); + } + AddRow("DOP", dopText, dopColor); + } + else if (statement.NonParallelPlanReason != null) + AddRow("Serial", statement.NonParallelPlanReason); + + if (statement.QueryTimeStats != null) + { + AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); + if (statement.QueryUdfCpuTimeMs > 0) + AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); + if (statement.QueryUdfElapsedTimeMs > 0) + AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + } + + // Compile stats (category B plan-level property) + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Memory grant — color per new tiers, spill indicator if any operator spilled + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); + var spillTag = hasSpillInTree ? " ⚠ spill" : ""; + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // Thread stats + if (statement.ThreadStats != null) + { + var ts = statement.ThreadStats; + AddRow("Branches", ts.Branches.ToString()); + var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads); + if (totalReserved > 0) + { + var threadPct = (double)ts.UsedThreads / totalReserved * 100; + var threadColor = EfficiencyColor(threadPct); + var threadText = ts.UsedThreads == totalReserved + ? $"{ts.UsedThreads} used ({totalReserved} reserved)" + : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; + AddRow("Threads", threadText, threadColor); + } + else + { + AddRow("Threads", $"{ts.UsedThreads} used"); + } + } + + // Optimization + CE model + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + if (grid.Children.Count > 0) + { + RuntimeSummaryContent.Children.Add(grid); + RuntimeSummaryEmpty.IsVisible = false; + } + else + { + RuntimeSummaryEmpty.IsVisible = true; + } + ShowServerContext(); + } + + private void ShowServerContext() + { + ServerContextContent.Children.Clear(); + if (_serverMetadata == null) + { + ServerContextEmpty.IsVisible = true; + ServerContextBorder.IsVisible = true; + return; + } + + ServerContextEmpty.IsVisible = false; + + var m = _serverMetadata; + var fgColor = "#E4E6EB"; + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; + int rowIndex = 0; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var lb = new TextBlock + { + Text = label, FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(fgColor)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 8, 1) + }; + Grid.SetRow(lb, rowIndex); + Grid.SetColumn(lb, 0); + grid.Children.Add(lb); + + var vb = new TextBlock + { + Text = value, FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(fgColor)), + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(vb, rowIndex); + Grid.SetColumn(vb, 1); + grid.Children.Add(vb); + rowIndex++; + } + + // Server name + edition + var edition = m.Edition; + if (edition != null) + { + var idx = edition.IndexOf(" (64-bit)"); + if (idx > 0) edition = edition[..idx]; + } + var serverLine = m.ServerName ?? "Unknown"; + if (edition != null) serverLine += $" ({edition})"; + if (m.ProductVersion != null) serverLine += $", {m.ProductVersion}"; + AddRow("Server", serverLine); + + // Hardware + if (m.CpuCount > 0) + AddRow("Hardware", $"{m.CpuCount} CPUs, {m.PhysicalMemoryMB:N0} MB RAM"); + + // Instance settings + AddRow("MAXDOP", m.MaxDop.ToString()); + AddRow("Cost threshold", m.CostThresholdForParallelism.ToString()); + AddRow("Max memory", $"{m.MaxServerMemoryMB:N0} MB"); + + // Database + if (m.Database != null) + AddRow("Database", $"{m.Database.Name} (compat {m.Database.CompatibilityLevel})"); + + ServerContextContent.Children.Add(grid); + ServerContextBorder.IsVisible = true; + } + + private void UpdateInsightsHeader() + { + InsightsPanel.IsVisible = true; + InsightsHeader.Text = " Plan Insights"; + } + + private static string GetWaitCategory(string waitType) + { + if (waitType.StartsWith("SOS_SCHEDULER_YIELD") || + waitType.StartsWith("CXPACKET") || + waitType.StartsWith("CXCONSUMER") || + waitType.StartsWith("CXSYNC_PORT") || + waitType.StartsWith("CXSYNC_CONSUMER")) + return "CPU"; + + if (waitType.StartsWith("PAGEIOLATCH") || + waitType.StartsWith("WRITELOG") || + waitType.StartsWith("IO_COMPLETION") || + waitType.StartsWith("ASYNC_IO_COMPLETION")) + return "I/O"; + + if (waitType.StartsWith("LCK_M_")) + return "Lock"; + + if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD") + return "Memory"; + + if (waitType == "ASYNC_NETWORK_IO") + return "Network"; + + return "Other"; + } + + private static string GetWaitCategoryColor(string category) + { + return category switch + { + "CPU" => "#4FA3FF", + "I/O" => "#FFB347", + "Lock" => "#E57373", + "Memory" => "#9B59B6", + "Network" => "#2ECC71", + _ => "#6BB5FF" + }; + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs new file mode 100644 index 0000000..9956c40 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -0,0 +1,550 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical) + { + total += node.Warnings.Count; + critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical); + foreach (var child in node.Children) + CountNodeWarnings(child, ref total, ref critical); + } + + private void RenderStatement(PlanStatement statement) + { + _currentStatement = statement; + PlanCanvas.Children.Clear(); + _nodeBorderMap.Clear(); + _selectedNodeBorder = null; + _selectedNode = null; + + if (statement.RootNode == null) return; + + // Layout + PlanLayoutEngine.Layout(statement); + var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode); + PlanCanvas.Width = width; + PlanCanvas.Height = height; + + // Render edges first (behind nodes) + var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); + RenderEdges(statement.RootNode, divergenceLimit); + + // Render nodes — pass total warning count to root node for badge + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count); + + // Update banners + ShowMissingIndexes(statement.MissingIndexes); + ShowParameters(statement); + ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); + ShowRuntimeSummary(statement); + UpdateInsightsHeader(); + + // Scroll to top-left so the plan root is immediately visible + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + + // Canvas-level context menu (zoom, advice, repro, save) + // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable + PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); + + CostText.Text = ""; + + // Update minimap if visible + if (MinimapPanel.IsVisible) + Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); + } + + private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1) + { + var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount); + Canvas.SetLeft(visual, node.X); + Canvas.SetTop(visual, node.Y); + PlanCanvas.Children.Add(visual); + + foreach (var child in node.Children) + RenderNodes(child, divergenceLimit); + } + + private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1) + { + var isExpensive = node.IsExpensive; + + var bgBrush = isExpensive + ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) + : FindBrushResource("BackgroundLightBrush"); + + var borderBrush = isExpensive + ? OrangeRedBrush + : FindBrushResource("BorderBrush"); + + var border = new Border + { + Width = PlanLayoutEngine.NodeWidth, + MinHeight = PlanLayoutEngine.NodeHeightMin, + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(isExpensive ? 2 : 1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = new Cursor(StandardCursorType.Hand) + }; + + // Map border to node (replaces WPF Tag) + _nodeBorderMap[border] = node; + + // Tooltip — root node gets all collected warnings so the tooltip shows them + if (totalWarningCount > 0) + { + var allWarnings = new List(); + if (_currentStatement != null) + allWarnings.AddRange(_currentStatement.PlanWarnings); + CollectWarnings(node, allWarnings); + ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings)); + } + else + { + ToolTip.SetTip(border, BuildNodeTooltipContent(node)); + } + + // Click to select + show properties + border.PointerPressed += Node_Click; + + // Right-click context menu + border.ContextMenu = BuildNodeContextMenu(node); + + var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + + // Icon row: icon + optional warning/parallel indicators + var iconRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center + }; + + var iconBitmap = IconHelper.LoadIcon(node.IconName); + if (iconBitmap != null) + { + iconRow.Children.Add(new Image + { + Source = iconBitmap, + Width = 32, + Height = 32, + Margin = new Thickness(0, 0, 0, 2) + }); + } + + // Warning indicator badge (orange triangle with !) + if (node.HasWarnings) + { + var warnBadge = new Grid + { + Width = 20, Height = 20, + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + warnBadge.Children.Add(new AvaloniaPath + { + Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"), + Fill = OrangeBrush + }); + warnBadge.Children.Add(new TextBlock + { + Text = "!", + FontSize = 12, + FontWeight = FontWeight.ExtraBold, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 3, 0, 0) + }); + iconRow.Children.Add(warnBadge); + } + + // Parallel indicator badge (amber circle with arrows) + if (node.Parallel) + { + var parBadge = new Grid + { + Width = 20, Height = 20, + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + parBadge.Children.Add(new Ellipse + { + Width = 20, Height = 20, + Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07)) + }); + parBadge.Children.Add(new TextBlock + { + Text = "\u21C6", + FontSize = 12, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }); + iconRow.Children.Add(parBadge); + } + + // Nonclustered index count badge (modification operators maintaining multiple NC indexes) + if (node.NonClusteredIndexCount > 0) + { + var ncBadge = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(4, 1), + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"+{node.NonClusteredIndexCount} NC", + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + } + }; + iconRow.Children.Add(ncBadge); + } + + stack.Children.Add(iconRow); + + // Operator name + var fgBrush = FindBrushResource("ForegroundBrush"); + + // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc. + var opLabel = node.PhysicalOp; + if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) + && node.LogicalOp != "Parallelism") + { + opLabel = $"Parallelism\n({node.LogicalOp})"; + } + stack.Children.Add(new TextBlock + { + Text = opLabel, + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = fgBrush, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors + IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush + : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush + : fgBrush; + + stack.Children.Add(new TextBlock + { + Text = $"Cost: {node.CostPercent}%", + FontSize = 10, + Foreground = costColor, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Actual plan stats: elapsed time, CPU time, and row counts + if (node.HasActualStats) + { + // Compute own time (subtract children in row mode) + var ownElapsedMs = GetOwnElapsedMs(node); + var ownCpuMs = GetOwnCpuMs(node); + + // Elapsed time -- color based on own time, not cumulative + var ownElapsedSec = ownElapsedMs / 1000.0; + IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush + : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"{ownElapsedSec:F3}s", + FontSize = 10, + Foreground = elapsedBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // CPU time -- color based on own time + var ownCpuSec = ownCpuMs / 1000.0; + IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush + : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"CPU: {ownCpuSec:F3}s", + FontSize = 10, + Foreground = cpuBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit + var estRows = node.EstimateRows; + var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; + var accuracy = estRows > 0 + ? $" ({accuracyRatio * 100:F0}%)" + : ""; + stack.Children.Add(new TextBlock + { + Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", + FontSize = 10, + Foreground = rowBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16 + }); + } + + // Object name -- show full object name, wrap if needed + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objBlock = new TextBlock + { + Text = node.FullObjectName ?? node.ObjectName, + FontSize = 10, + Foreground = fgBrush, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }; + stack.Children.Add(objBlock); + } + + // Total warning count badge on root node + if (totalWarningCount > 0) + { + var badgeRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 2, 0, 0) + }; + badgeRow.Children.Add(new TextBlock + { + Text = "\u26A0", + FontSize = 13, + Foreground = OrangeBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0) + }); + badgeRow.Children.Add(new TextBlock + { + Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}", + FontSize = 12, + FontWeight = FontWeight.SemiBold, + Foreground = OrangeBrush, + VerticalAlignment = VerticalAlignment.Center + }); + stack.Children.Add(badgeRow); + } + + border.Child = stack; + return border; + } + + private void RenderEdges(PlanNode node, double divergenceLimit) + { + foreach (var child in node.Children) + { + var path = CreateElbowConnector(node, child, divergenceLimit); + PlanCanvas.Children.Add(path); + + RenderEdges(child, divergenceLimit); + } + } + + /// + /// Returns a color brush for a link based on the accuracy ratio of the child node. + /// Only applies to actual plans; estimated plans use the default edge brush. + /// + private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit) + { + if (!child.HasActualStats) + return EdgeBrush; + + divergenceLimit = Math.Max(2.0, divergenceLimit); + var estRows = child.EstimateRows; + var accuracyRatio = estRows > 0 + ? child.ActualRows / estRows + : (child.ActualRows > 0 ? double.MaxValue : 1.0); + + // Within the neutral band — keep default color + if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) + return EdgeBrush; + + // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated) + if (accuracyRatio > divergenceLimit) + { + if (accuracyRatio >= divergenceLimit * 100) + return LinkFluoRedBrush; + if (accuracyRatio >= divergenceLimit * 10) + return LinkFluoOrangeBrush; + return LinkLightOrangeBrush; + } + + // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated) + if (accuracyRatio < 1.0 / (divergenceLimit * 100)) + return LinkFluoBlueBrush; + if (accuracyRatio < 1.0 / (divergenceLimit * 10)) + return LinkLightBlueBrush; + return LinkBlueBrush; + } + + private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit) + { + var parentRight = parent.X + PlanLayoutEngine.NodeWidth; + var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2; + var childLeft = child.X; + var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2; + + // Arrow thickness based on row estimate (logarithmic) + var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; + var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); + + var midX = (parentRight + childLeft) / 2; + + var geometry = new PathGeometry(); + var figure = new PathFigure + { + StartPoint = new Point(parentRight, parentCenterY), + IsClosed = false + }; + figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); + figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); + geometry.Figures!.Add(figure); + + var linkBrush = GetLinkColorBrush(child, divergenceLimit); + + var path = new AvaloniaPath + { + Data = geometry, + Stroke = linkBrush, + StrokeThickness = thickness, + StrokeJoin = PenLineJoin.Round + }; + ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); + return path; + } + + private object BuildEdgeTooltipContent(PlanNode child) + { + var panel = new StackPanel { MinWidth = 240 }; + + void AddRow(string label, string value) + { + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + var lbl = new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), + FontSize = 12, + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetColumn(lbl, 0); + Grid.SetColumn(val, 1); + row.Children.Add(lbl); + row.Children.Add(val); + panel.Children.Add(row); + } + + if (child.HasActualStats) + AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); + + AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); + + var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; + var estimatedRowsAllExec = child.EstimateRows * executions; + AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); + + if (child.EstimatedRowSize > 0) + { + AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); + var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; + AddRow("Estimated Data Size", FormatBytes(dataSize)); + } + + return new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6), + CornerRadius = new CornerRadius(4), + Child = panel + }; + } + + private static string FormatBytes(double bytes) + { + if (bytes < 1024) return $"{bytes:N0} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; + return $"{bytes / (1024L * 1024 * 1024):N1} GB"; + } + + private static string FormatBenefitPercent(double pct) => + pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; + + private static bool HasSpillInPlanTree(PlanNode node) + { + foreach (var w in node.Warnings) + if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInPlanTree(child)) return true; + return false; + } + + private static void CollectWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectWarnings(child, warnings); + } + + private IBrush FindBrushResource(string key) + { + if (this.TryFindResource(key, out var resource) && resource is IBrush brush) + return brush; + + // Fallback brushes in case resources are not found + return key switch + { + "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)), + "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)), + "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + _ => Brushes.White + }; + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs new file mode 100644 index 0000000..a765092 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private static bool IsTempObject(string objectName) + { + // #temp tables, ##global temp, @table variables, internal worktables + return objectName.Contains('#') || objectName.Contains('@') + || objectName.Contains("worktable", StringComparison.OrdinalIgnoreCase) + || objectName.Contains("worksort", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDataAccessOperator(PlanNode node) + { + var op = node.PhysicalOp; + if (string.IsNullOrEmpty(op)) return false; + + // Modification operators and data access operators reference objects + return op.Contains("Scan", StringComparison.OrdinalIgnoreCase) + || op.Contains("Seek", StringComparison.OrdinalIgnoreCase) + || op.Contains("Lookup", StringComparison.OrdinalIgnoreCase) + || op.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || op.Contains("Update", StringComparison.OrdinalIgnoreCase) + || op.Contains("Delete", StringComparison.OrdinalIgnoreCase) + || op.Contains("Spool", StringComparison.OrdinalIgnoreCase); + } + + private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) + { + if (string.IsNullOrEmpty(node.ObjectName) || IsTempObject(node.ObjectName)) + return; + if (!IsDataAccessOperator(node)) + return; + + var objectName = node.ObjectName; + + menu.Items.Add(new Separator()); + + var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" }; + showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName, + async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); + menu.Items.Add(showIndexes); + + var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" }; + showTableDef.Click += async (_, _) => await FetchAndShowSchemaAsync("Table", objectName, + async cs => + { + var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName); + var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName); + return FormatColumns(objectName, columns, indexes); + }); + menu.Items.Add(showTableDef); + + // Disable schema items when no connection + menu.Opening += (_, _) => + { + var enabled = ConnectionString != null; + showIndexes.IsEnabled = enabled; + showTableDef.IsEnabled = enabled; + }; + } + + private async System.Threading.Tasks.Task FetchAndShowSchemaAsync( + string kind, string objectName, Func> fetch) + { + if (ConnectionString == null) return; + + try + { + var content = await fetch(ConnectionString); + ShowSchemaResult($"{kind} — {objectName}", content); + } + catch (Exception ex) + { + ShowSchemaResult($"Error — {objectName}", $"-- Error: {ex.Message}"); + } + } + + private void ShowSchemaResult(string title, string content) + { + var editor = new AvaloniaEdit.TextEditor + { + Text = content, + IsReadOnly = true, + FontFamily = new FontFamily("Consolas, Menlo, monospace"), + FontSize = 13, + ShowLineNumbers = true, + Background = FindBrushResource("BackgroundBrush"), + Foreground = FindBrushResource("ForegroundBrush"), + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(4) + }; + + // SQL syntax highlighting + var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); + var tm = editor.InstallTextMate(registryOptions); + tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + + // Context menu + var copyItem = new MenuItem { Header = "Copy" }; + copyItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + var sel = editor.TextArea.Selection; + if (!sel.IsEmpty) + await clipboard.SetTextAsync(sel.GetText()); + }; + var copyAllItem = new MenuItem { Header = "Copy All" }; + copyAllItem.Click += async (_, _) => + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard == null) return; + await clipboard.SetTextAsync(editor.Text); + }; + var selectAllItem = new MenuItem { Header = "Select All" }; + selectAllItem.Click += (_, _) => editor.SelectAll(); + editor.TextArea.ContextMenu = new ContextMenu + { + Items = { copyItem, copyAllItem, new Separator(), selectAllItem } + }; + + // Show in a popup window + var window = new Window + { + Title = $"Performance Studio — {title}", + Width = 700, + Height = 500, + MinWidth = 400, + MinHeight = 200, + Background = FindBrushResource("BackgroundBrush"), + Foreground = FindBrushResource("ForegroundBrush"), + Content = editor + }; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is Window parentWindow) + { + window.Icon = parentWindow.Icon; + window.Show(parentWindow); + } + else + { + window.Show(); + } + } + + private static string FormatIndexes(string objectName, IReadOnlyList indexes) + { + if (indexes.Count == 0) + return $"-- No indexes found on {objectName}"; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"-- Indexes on {objectName}"); + sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows"); + sb.AppendLine(); + + foreach (var ix in indexes) + { + if (ix.IsDisabled) + sb.AppendLine("-- ** DISABLED **"); + + sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}"); + + var withOptions = BuildWithOptions(ix); + var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null + ? $"ON [{ix.PartitionScheme}]([{ix.PartitionColumn}])" + : null; + + if (ix.IsPrimaryKey) + { + var clustered = IsClusteredType(ix) ? "CLUSTERED" : "NONCLUSTERED"; + sb.AppendLine($"ALTER TABLE {objectName}"); + sb.AppendLine($"ADD CONSTRAINT [{ix.IndexName}]"); + sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})"); + if (withOptions.Count > 0) + { + sb.AppendLine(); + sb.Append($" WITH ({string.Join(", ", withOptions)})"); + } + if (onPartition != null) + { + sb.AppendLine(); + sb.Append($" {onPartition}"); + } + sb.AppendLine(";"); + } + else if (IsColumnstore(ix)) + { + var clustered = ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) + ? "NONCLUSTERED " : "CLUSTERED "; + sb.Append($"CREATE {clustered}COLUMNSTORE INDEX [{ix.IndexName}]"); + sb.AppendLine($" ON {objectName}"); + if (ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(ix.KeyColumns)) + sb.AppendLine($"({ix.KeyColumns})"); + var csOptions = BuildColumnstoreWithOptions(ix); + if (csOptions.Count > 0) + sb.AppendLine($"WITH ({string.Join(", ", csOptions)})"); + if (onPartition != null) + sb.AppendLine(onPartition); + TrimTrailingNewline(sb); + sb.AppendLine(";"); + } + else + { + var unique = ix.IsUnique ? "UNIQUE " : ""; + var clustered = IsClusteredType(ix) ? "CLUSTERED " : "NONCLUSTERED "; + sb.Append($"CREATE {unique}{clustered}INDEX [{ix.IndexName}]"); + sb.AppendLine($" ON {objectName}"); + sb.AppendLine($"({ix.KeyColumns})"); + if (!string.IsNullOrEmpty(ix.IncludeColumns)) + sb.AppendLine($"INCLUDE ({ix.IncludeColumns})"); + if (!string.IsNullOrEmpty(ix.FilterDefinition)) + sb.AppendLine($"WHERE {ix.FilterDefinition}"); + if (withOptions.Count > 0) + sb.AppendLine($"WITH ({string.Join(", ", withOptions)})"); + if (onPartition != null) + sb.AppendLine(onPartition); + TrimTrailingNewline(sb); + sb.AppendLine(";"); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes) + { + if (columns.Count == 0) + return $"-- No columns found for {objectName}"; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"CREATE TABLE {objectName}"); + sb.AppendLine("("); + + var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); + + for (int i = 0; i < columns.Count; i++) + { + var col = columns[i]; + var isLast = i == columns.Count - 1; + + sb.Append($" [{col.ColumnName}] "); + + if (col.IsComputed && col.ComputedDefinition != null) + { + sb.Append($"AS {col.ComputedDefinition}"); + } + else + { + sb.Append(col.DataType); + if (col.IsIdentity) + sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})"); + sb.Append(col.IsNullable ? " NULL" : " NOT NULL"); + if (col.DefaultValue != null) + sb.Append($" DEFAULT {col.DefaultValue}"); + } + + sb.AppendLine(!isLast || pkIndex != null ? "," : ""); + } + + if (pkIndex != null) + { + var clustered = IsClusteredType(pkIndex) ? "CLUSTERED " : "NONCLUSTERED "; + sb.AppendLine($" CONSTRAINT [{pkIndex.IndexName}]"); + sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})"); + var pkOptions = BuildWithOptions(pkIndex); + if (pkOptions.Count > 0) + { + sb.AppendLine(); + sb.Append($" WITH ({string.Join(", ", pkOptions)})"); + } + sb.AppendLine(); + } + + sb.Append(")"); + + var clusteredIx = indexes.FirstOrDefault(ix => IsClusteredType(ix) && !IsColumnstore(ix)); + if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null) + { + sb.AppendLine(); + sb.Append($"ON [{clusteredIx.PartitionScheme}]([{clusteredIx.PartitionColumn}])"); + } + + sb.AppendLine(";"); + return sb.ToString(); + } + + private static bool IsClusteredType(IndexInfo ix) => + ix.IndexType.Contains("CLUSTERED", StringComparison.OrdinalIgnoreCase) + && !ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase); + + private static bool IsColumnstore(IndexInfo ix) => + ix.IndexType.Contains("COLUMNSTORE", StringComparison.OrdinalIgnoreCase); + + private static List BuildWithOptions(IndexInfo ix) + { + var options = new List(); + if (ix.FillFactor > 0 && ix.FillFactor != 100) + options.Add($"FILLFACTOR = {ix.FillFactor}"); + if (ix.IsPadded) + options.Add("PAD_INDEX = ON"); + if (!ix.AllowRowLocks) + options.Add("ALLOW_ROW_LOCKS = OFF"); + if (!ix.AllowPageLocks) + options.Add("ALLOW_PAGE_LOCKS = OFF"); + if (!string.Equals(ix.DataCompression, "NONE", StringComparison.OrdinalIgnoreCase)) + options.Add($"DATA_COMPRESSION = {ix.DataCompression}"); + return options; + } + + private static List BuildColumnstoreWithOptions(IndexInfo ix) + { + var options = new List(); + if (ix.FillFactor > 0 && ix.FillFactor != 100) + options.Add($"FILLFACTOR = {ix.FillFactor}"); + if (ix.IsPadded) + options.Add("PAD_INDEX = ON"); + return options; + } + + private static void TrimTrailingNewline(System.Text.StringBuilder sb) + { + if (sb.Length > 0 && sb[sb.Length - 1] == '\n') sb.Length--; + if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--; + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs new file mode 100644 index 0000000..a03c712 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void PopulateStatementsGrid(List statements) + { + StatementsHeader.Text = $"Statements ({statements.Count})"; + + var hasActualTimes = statements.Any(s => s.QueryTimeStats != null && + (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0)); + var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0); + + // Build columns + StatementsGrid.Columns.Clear(); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "#", + Binding = new Avalonia.Data.Binding("Index"), + Width = new DataGridLength(40), + IsReadOnly = true + }); + + var queryTemplate = new FuncDataTemplate((row, _) => + { + if (row == null) return new TextBlock(); + var tb = new TextBlock + { + Text = row.QueryText, + TextWrapping = TextWrapping.Wrap, + MaxHeight = 80, + FontSize = 11, + Margin = new Thickness(4, 2) + }; + ToolTip.SetTip(tb, new TextBlock + { + Text = row.FullQueryText, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 600, + FontFamily = new FontFamily("Consolas"), + FontSize = 11 + }); + return tb; + }, supportsRecycling: false); + + StatementsGrid.Columns.Add(new DataGridTemplateColumn + { + Header = "Query", + CellTemplate = queryTemplate, + Width = new DataGridLength(250), + IsReadOnly = true + }); + + if (hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "CPU", + Binding = new Avalonia.Data.Binding("CpuDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.CpuMs) + }); + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Elapsed", + Binding = new Avalonia.Data.Binding("ElapsedDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.ElapsedMs) + }); + } + + if (hasUdf) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "UDF", + Binding = new Avalonia.Data.Binding("UdfDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + CustomSortComparer = new LongComparer(r => r.UdfMs) + }); + } + + if (!hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Est. Cost", + Binding = new Avalonia.Data.Binding("CostDisplay"), + Width = new DataGridLength(80), + IsReadOnly = true, + CustomSortComparer = new DoubleComparer(r => r.EstCost) + }); + } + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Critical", + Binding = new Avalonia.Data.Binding("Critical"), + Width = new DataGridLength(60), + IsReadOnly = true + }); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Warnings", + Binding = new Avalonia.Data.Binding("Warnings"), + Width = new DataGridLength(70), + IsReadOnly = true + }); + + // Build rows + var rows = new List(); + for (int i = 0; i < statements.Count; i++) + { + var stmt = statements[i]; + var allWarnings = stmt.PlanWarnings.ToList(); + if (stmt.RootNode != null) + CollectNodeWarnings(stmt.RootNode, allWarnings); + + var fullText = stmt.StatementText; + if (string.IsNullOrWhiteSpace(fullText)) + fullText = $"Statement {i + 1}"; + var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText; + + rows.Add(new StatementRow + { + Index = i + 1, + QueryText = displayText, + FullQueryText = fullText, + CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0, + ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0, + UdfMs = stmt.QueryUdfElapsedTimeMs, + EstCost = stmt.StatementSubTreeCost, + Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning), + Statement = stmt + }); + } + + StatementsGrid.ItemsSource = rows; + } + + private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (StatementsGrid.SelectedItem is StatementRow row) + RenderStatement(row.Statement); + } + + private async void CopyStatementText_Click(object? sender, RoutedEventArgs e) + { + if (StatementsGrid.SelectedItem is not StatementRow row) return; + var text = row.Statement.StatementText; + if (string.IsNullOrEmpty(text)) return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard != null) + await topLevel.Clipboard.SetTextAsync(text); + } + + private void OpenInEditor_Click(object? sender, RoutedEventArgs e) + { + if (StatementsGrid.SelectedItem is not StatementRow row) return; + var text = row.Statement.StatementText; + if (string.IsNullOrEmpty(text)) return; + + OpenInEditorRequested?.Invoke(this, text); + } + + private static void CollectNodeWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectNodeWarnings(child, warnings); + } + + private void ToggleStatements_Click(object? sender, RoutedEventArgs e) + { + if (StatementsPanel.IsVisible) + CloseStatementsPanel(); + else + ShowStatementsPanel(); + } + + private void CloseStatements_Click(object? sender, RoutedEventArgs e) + { + CloseStatementsPanel(); + } + + private void ShowStatementsPanel() + { + _statementsColumn.Width = new GridLength(450); + _statementsSplitterColumn.Width = new GridLength(5); + StatementsSplitter.IsVisible = true; + StatementsPanel.IsVisible = true; + StatementsButton.IsVisible = true; + StatementsButtonSeparator.IsVisible = true; + } + + private void CloseStatementsPanel() + { + StatementsPanel.IsVisible = false; + StatementsSplitter.IsVisible = false; + _statementsColumn.Width = new GridLength(0); + _statementsSplitterColumn.Width = new GridLength(0); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs new file mode 100644 index 0000000..841283f --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null) + { + var tipBorder = new Border + { + Background = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(12), + MaxWidth = 500 + }; + + var stack = new StackPanel(); + + // Header + var headerText = node.PhysicalOp; + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) + headerText += $" ({node.LogicalOp})"; + stack.Children.Add(new TextBlock + { + Text = headerText, + FontWeight = FontWeight.Bold, + FontSize = 13, + Foreground = TooltipFgBrush, + Margin = new Thickness(0, 0, 0, 8) + }); + + // Cost + AddTooltipSection(stack, "Costs"); + AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})"); + AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); + + // Rows + AddTooltipSection(stack, "Rows"); + AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}"); + if (node.HasActualStats) + { + AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}"); + if (node.ActualRowsRead > 0) + AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}"); + AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}"); + } + + // Rebinds/Rewinds (spools and other operators with rebind/rewind data) + if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0 + || node.ActualRebinds > 0 || node.ActualRewinds > 0) + { + AddTooltipSection(stack, "Rebinds / Rewinds"); + // Always show both estimated values when section is visible + AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}"); + AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}"); + if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}"); + if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}"); + } + + // I/O and CPU estimates + if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0) + { + AddTooltipSection(stack, "Estimates"); + if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}"); + if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}"); + if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B"); + } + + // Actual I/O + if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0)) + { + AddTooltipSection(stack, "Actual I/O"); + AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}"); + if (node.ActualPhysicalReads > 0) + AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}"); + if (node.ActualScans > 0) + AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}"); + if (node.ActualReadAheads > 0) + AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}"); + } + + // Actual timing + if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0)) + { + AddTooltipSection(stack, "Timing"); + if (node.ActualElapsedMs > 0) + AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); + if (node.ActualCPUMs > 0) + AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms"); + } + + // Parallelism + if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType)) + { + AddTooltipSection(stack, "Parallelism"); + if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes"); + if (!string.IsNullOrEmpty(node.ExecutionMode)) + AddTooltipRow(stack, "Execution Mode", node.ExecutionMode); + if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) + AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode); + if (!string.IsNullOrEmpty(node.PartitioningType)) + AddTooltipRow(stack, "Partitioning", node.PartitioningType); + } + + // Object + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + AddTooltipSection(stack, "Object"); + AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true); + if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddTooltipRow(stack, "Scan Direction", node.ScanDirection); + } + else if (!string.IsNullOrEmpty(node.ObjectName)) + { + AddTooltipSection(stack, "Object"); + AddTooltipRow(stack, "Name", node.ObjectName, isCode: true); + if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); + if (!string.IsNullOrEmpty(node.ScanDirection)) + AddTooltipRow(stack, "Scan Direction", node.ScanDirection); + } + + // NC index maintenance count + if (node.NonClusteredIndexCount > 0) + AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames)); + + // Operator details (key items only in tooltip) + var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy) + || !string.IsNullOrEmpty(node.TopExpression) + || !string.IsNullOrEmpty(node.GroupBy) + || !string.IsNullOrEmpty(node.OuterReferences); + if (hasTooltipDetails) + { + AddTooltipSection(stack, "Details"); + if (!string.IsNullOrEmpty(node.OrderBy)) + AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true); + if (!string.IsNullOrEmpty(node.TopExpression)) + AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression); + if (!string.IsNullOrEmpty(node.GroupBy)) + AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true); + if (!string.IsNullOrEmpty(node.OuterReferences)) + AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true); + } + + // Predicates + if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)) + { + AddTooltipSection(stack, "Predicates"); + if (!string.IsNullOrEmpty(node.SeekPredicates)) + AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true); + if (!string.IsNullOrEmpty(node.Predicate)) + AddTooltipRow(stack, "Residual", node.Predicate, isCode: true); + } + + // Output columns + if (!string.IsNullOrEmpty(node.OutputColumns)) + { + AddTooltipSection(stack, "Output"); + AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true); + } + + // Warnings — use allWarnings (all nodes) for root, node.Warnings for others + var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null); + if (warnings != null && warnings.Count > 0) + { + stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) }); + + if (allWarnings != null) + { + // Root node: show distinct warning type names only, sorted by max benefit + var distinct = warnings + .GroupBy(w => w.WarningType) + .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(), + MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1))) + .OrderByDescending(g => g.MaxBenefit) + .ThenByDescending(g => g.MaxSeverity) + .ThenBy(g => g.Type); + + foreach (var (type, severity, count, maxBenefit) in distinct) + { + var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" + : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : ""; + var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}"; + stack.Children.Add(new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.Parse(warnColor)), + FontSize = 11, + Margin = new Thickness(0, 2, 0, 0) + }); + } + } + else + { + // Individual node: show full warning messages + foreach (var w in warnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + stack.Children.Add(new TextBlock + { + Text = $"\u26A0 {w.WarningType}: {w.Message}", + Foreground = new SolidColorBrush(Color.Parse(warnColor)), + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0) + }); + } + } + } + + // Footer hint + stack.Children.Add(new TextBlock + { + Text = "Click to view full properties", + FontSize = 10, + FontStyle = FontStyle.Italic, + Foreground = TooltipFgBrush, + Margin = new Thickness(0, 8, 0, 0) + }); + + tipBorder.Child = stack; + return tipBorder; + } + + private static void AddTooltipSection(StackPanel parent, string title) + { + parent.Children.Add(new TextBlock + { + Text = title, + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = SectionHeaderBrush, + Margin = new Thickness(0, 6, 0, 2) + }); + } + + private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) + { + var row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Margin = new Thickness(0, 1, 0, 1) + }; + var labelBlock = new TextBlock + { + Text = $"{label}: ", + Foreground = TooltipFgBrush, + FontSize = 11, + MinWidth = 120, + VerticalAlignment = VerticalAlignment.Top + }; + Grid.SetColumn(labelBlock, 0); + row.Children.Add(labelBlock); + + var valueBlock = new TextBlock + { + Text = value, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap + }; + if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBlock, 1); + row.Children.Add(valueBlock); + parent.Children.Add(row); + } +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index a2d77e3..f6925f4 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -378,3254 +378,9 @@ public void Clear() CloseMinimapPanel(); } - private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical) - { - total += node.Warnings.Count; - critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical); - foreach (var child in node.Children) - CountNodeWarnings(child, ref total, ref critical); - } - - private void RenderStatement(PlanStatement statement) - { - _currentStatement = statement; - PlanCanvas.Children.Clear(); - _nodeBorderMap.Clear(); - _selectedNodeBorder = null; - _selectedNode = null; - - if (statement.RootNode == null) return; - - // Layout - PlanLayoutEngine.Layout(statement); - var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode); - PlanCanvas.Width = width; - PlanCanvas.Height = height; - - // Render edges first (behind nodes) - var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); - RenderEdges(statement.RootNode, divergenceLimit); - - // Render nodes — pass total warning count to root node for badge - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count); - - // Update banners - ShowMissingIndexes(statement.MissingIndexes); - ShowParameters(statement); - ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); - ShowRuntimeSummary(statement); - UpdateInsightsHeader(); - - // Scroll to top-left so the plan root is immediately visible - PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); - - // Canvas-level context menu (zoom, advice, repro, save) - // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable - PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); - - CostText.Text = ""; - - // Update minimap if visible - if (MinimapPanel.IsVisible) - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); - } - - #region Node Rendering - - private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1) - { - var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount); - Canvas.SetLeft(visual, node.X); - Canvas.SetTop(visual, node.Y); - PlanCanvas.Children.Add(visual); - - foreach (var child in node.Children) - RenderNodes(child, divergenceLimit); - } - - private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1) - { - var isExpensive = node.IsExpensive; - - var bgBrush = isExpensive - ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) - : FindBrushResource("BackgroundLightBrush"); - - var borderBrush = isExpensive - ? OrangeRedBrush - : FindBrushResource("BorderBrush"); - - var border = new Border - { - Width = PlanLayoutEngine.NodeWidth, - MinHeight = PlanLayoutEngine.NodeHeightMin, - Background = bgBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(isExpensive ? 2 : 1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 4, 6, 4), - Cursor = new Cursor(StandardCursorType.Hand) - }; - - // Map border to node (replaces WPF Tag) - _nodeBorderMap[border] = node; - - // Tooltip — root node gets all collected warnings so the tooltip shows them - if (totalWarningCount > 0) - { - var allWarnings = new List(); - if (_currentStatement != null) - allWarnings.AddRange(_currentStatement.PlanWarnings); - CollectWarnings(node, allWarnings); - ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings)); - } - else - { - ToolTip.SetTip(border, BuildNodeTooltipContent(node)); - } - - // Click to select + show properties - border.PointerPressed += Node_Click; - - // Right-click context menu - border.ContextMenu = BuildNodeContextMenu(node); - - var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; - - // Icon row: icon + optional warning/parallel indicators - var iconRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Center - }; - - var iconBitmap = IconHelper.LoadIcon(node.IconName); - if (iconBitmap != null) - { - iconRow.Children.Add(new Image - { - Source = iconBitmap, - Width = 32, - Height = 32, - Margin = new Thickness(0, 0, 0, 2) - }); - } - - // Warning indicator badge (orange triangle with !) - if (node.HasWarnings) - { - var warnBadge = new Grid - { - Width = 20, Height = 20, - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center - }; - warnBadge.Children.Add(new AvaloniaPath - { - Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"), - Fill = OrangeBrush - }); - warnBadge.Children.Add(new TextBlock - { - Text = "!", - FontSize = 12, - FontWeight = FontWeight.ExtraBold, - Foreground = Brushes.White, - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 3, 0, 0) - }); - iconRow.Children.Add(warnBadge); - } - - // Parallel indicator badge (amber circle with arrows) - if (node.Parallel) - { - var parBadge = new Grid - { - Width = 20, Height = 20, - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center - }; - parBadge.Children.Add(new Ellipse - { - Width = 20, Height = 20, - Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07)) - }); - parBadge.Children.Add(new TextBlock - { - Text = "\u21C6", - FontSize = 12, - FontWeight = FontWeight.Bold, - Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }); - iconRow.Children.Add(parBadge); - } - - // Nonclustered index count badge (modification operators maintaining multiple NC indexes) - if (node.NonClusteredIndexCount > 0) - { - var ncBadge = new Border - { - Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(4, 1), - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = $"+{node.NonClusteredIndexCount} NC", - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = Brushes.White - } - }; - iconRow.Children.Add(ncBadge); - } - - stack.Children.Add(iconRow); - - // Operator name - var fgBrush = FindBrushResource("ForegroundBrush"); - - // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc. - var opLabel = node.PhysicalOp; - if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) - && node.LogicalOp != "Parallelism") - { - opLabel = $"Parallelism\n({node.LogicalOp})"; - } - stack.Children.Add(new TextBlock - { - Text = opLabel, - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = fgBrush, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors - IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush - : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush - : fgBrush; - - stack.Children.Add(new TextBlock - { - Text = $"Cost: {node.CostPercent}%", - FontSize = 10, - Foreground = costColor, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Actual plan stats: elapsed time, CPU time, and row counts - if (node.HasActualStats) - { - // Compute own time (subtract children in row mode) - var ownElapsedMs = GetOwnElapsedMs(node); - var ownCpuMs = GetOwnCpuMs(node); - - // Elapsed time -- color based on own time, not cumulative - var ownElapsedSec = ownElapsedMs / 1000.0; - IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush - : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"{ownElapsedSec:F3}s", - FontSize = 10, - Foreground = elapsedBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // CPU time -- color based on own time - var ownCpuSec = ownCpuMs / 1000.0; - IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush - : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"CPU: {ownCpuSec:F3}s", - FontSize = 10, - Foreground = cpuBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit - var estRows = node.EstimateRows; - var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); - IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = estRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; - stack.Children.Add(new TextBlock - { - Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", - FontSize = 10, - Foreground = rowBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16 - }); - } - - // Object name -- show full object name, wrap if needed - if (!string.IsNullOrEmpty(node.ObjectName)) - { - var objBlock = new TextBlock - { - Text = node.FullObjectName ?? node.ObjectName, - FontSize = 10, - Foreground = fgBrush, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }; - stack.Children.Add(objBlock); - } - - // Total warning count badge on root node - if (totalWarningCount > 0) - { - var badgeRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 2, 0, 0) - }; - badgeRow.Children.Add(new TextBlock - { - Text = "\u26A0", - FontSize = 13, - Foreground = OrangeBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0) - }); - badgeRow.Children.Add(new TextBlock - { - Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}", - FontSize = 12, - FontWeight = FontWeight.SemiBold, - Foreground = OrangeBrush, - VerticalAlignment = VerticalAlignment.Center - }); - stack.Children.Add(badgeRow); - } - - border.Child = stack; - return border; - } - - #endregion - - #region Edge Rendering - - private void RenderEdges(PlanNode node, double divergenceLimit) - { - foreach (var child in node.Children) - { - var path = CreateElbowConnector(node, child, divergenceLimit); - PlanCanvas.Children.Add(path); - - RenderEdges(child, divergenceLimit); - } - } - - /// - /// Returns a color brush for a link based on the accuracy ratio of the child node. - /// Only applies to actual plans; estimated plans use the default edge brush. - /// - private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit) - { - if (!child.HasActualStats) - return EdgeBrush; - - divergenceLimit = Math.Max(2.0, divergenceLimit); - var estRows = child.EstimateRows; - var accuracyRatio = estRows > 0 - ? child.ActualRows / estRows - : (child.ActualRows > 0 ? double.MaxValue : 1.0); - - // Within the neutral band — keep default color - if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) - return EdgeBrush; - - // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated) - if (accuracyRatio > divergenceLimit) - { - if (accuracyRatio >= divergenceLimit * 100) - return LinkFluoRedBrush; - if (accuracyRatio >= divergenceLimit * 10) - return LinkFluoOrangeBrush; - return LinkLightOrangeBrush; - } - - // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated) - if (accuracyRatio < 1.0 / (divergenceLimit * 100)) - return LinkFluoBlueBrush; - if (accuracyRatio < 1.0 / (divergenceLimit * 10)) - return LinkLightBlueBrush; - return LinkBlueBrush; - } - - private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit) - { - var parentRight = parent.X + PlanLayoutEngine.NodeWidth; - var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2; - var childLeft = child.X; - var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2; - - // Arrow thickness based on row estimate (logarithmic) - var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; - var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); - - var midX = (parentRight + childLeft) / 2; - - var geometry = new PathGeometry(); - var figure = new PathFigure - { - StartPoint = new Point(parentRight, parentCenterY), - IsClosed = false - }; - figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); - geometry.Figures!.Add(figure); - - var linkBrush = GetLinkColorBrush(child, divergenceLimit); - - var path = new AvaloniaPath - { - Data = geometry, - Stroke = linkBrush, - StrokeThickness = thickness, - StrokeJoin = PenLineJoin.Round - }; - ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); - return path; - } - - private object BuildEdgeTooltipContent(PlanNode child) - { - var panel = new StackPanel { MinWidth = 240 }; - - void AddRow(string label, string value) - { - var row = new Grid(); - row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); - row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); - var lbl = new TextBlock - { - Text = label, - Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), - FontSize = 12, - Margin = new Thickness(0, 1, 12, 1) - }; - var val = new TextBlock - { - Text = value, - Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), - FontSize = 12, - FontWeight = FontWeight.SemiBold, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetColumn(lbl, 0); - Grid.SetColumn(val, 1); - row.Children.Add(lbl); - row.Children.Add(val); - panel.Children.Add(row); - } - - if (child.HasActualStats) - AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); - - AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); - - var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; - var estimatedRowsAllExec = child.EstimateRows * executions; - AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); - - if (child.EstimatedRowSize > 0) - { - AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); - var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; - AddRow("Estimated Data Size", FormatBytes(dataSize)); - } - - return new Border - { - Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6), - CornerRadius = new CornerRadius(4), - Child = panel - }; - } - - private static string FormatBytes(double bytes) - { - if (bytes < 1024) return $"{bytes:N0} B"; - if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; - if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; - return $"{bytes / (1024L * 1024 * 1024):N1} GB"; - } - - private static string FormatBenefitPercent(double pct) => - pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; - - private static bool HasSpillInPlanTree(PlanNode node) - { - foreach (var w in node.Warnings) - if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; - foreach (var child in node.Children) - if (HasSpillInPlanTree(child)) return true; - return false; - } - - #endregion - - #region Node Selection & Properties Panel - - private void Node_Click(object? sender, PointerPressedEventArgs e) - { - if (sender is Border border - && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed - && _nodeBorderMap.TryGetValue(border, out var node)) - { - SelectNode(border, node); - e.Handled = true; - } - } - - private void SelectNode(Border border, PlanNode node) - { - // Deselect previous - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - } - - // Select new - _selectedNodeOriginalBorder = border.BorderBrush; - _selectedNodeOriginalThickness = border.BorderThickness; - _selectedNodeBorder = border; - border.BorderBrush = SelectionBrush; - border.BorderThickness = new Thickness(2); - - _selectedNode = node; - ShowPropertiesPanel(node); - UpdateMinimapSelection(node); - } - - private ContextMenu BuildNodeContextMenu(PlanNode node) - { - var menu = new ContextMenu(); - - var propsItem = new MenuItem { Header = "Properties" }; - propsItem.Click += (_, _) => - { - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node) - { - SelectNode(b, node); - break; - } - } - }; - menu.Items.Add(propsItem); - - menu.Items.Add(new Separator()); - - var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; - copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp); - menu.Items.Add(copyOpItem); - - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - var copyObjItem = new MenuItem { Header = "Copy Object Name" }; - copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!); - menu.Items.Add(copyObjItem); - } - - if (!string.IsNullOrEmpty(node.Predicate)) - { - var copyPredItem = new MenuItem { Header = "Copy Predicate" }; - copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!); - menu.Items.Add(copyPredItem); - } - - if (!string.IsNullOrEmpty(node.SeekPredicates)) - { - var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; - copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!); - menu.Items.Add(copySeekItem); - } - - // Schema lookup items (Show Indexes, Show Table Definition) - AddSchemaMenuItems(menu, node); - - return menu; - } - - private ContextMenu BuildCanvasContextMenu() - { - var menu = new ContextMenu(); - - // Zoom - var zoomInItem = new MenuItem { Header = "Zoom In" }; - zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); - menu.Items.Add(zoomInItem); - - var zoomOutItem = new MenuItem { Header = "Zoom Out" }; - zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); - menu.Items.Add(zoomOutItem); - - var fitItem = new MenuItem { Header = "Fit to View" }; - fitItem.Click += ZoomFit_Click; - menu.Items.Add(fitItem); - - menu.Items.Add(new Separator()); - - // Advice - var humanAdviceItem = new MenuItem { Header = "Human Advice" }; - humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(humanAdviceItem); - - var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; - robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(robotAdviceItem); - - menu.Items.Add(new Separator()); - - // Repro & Save - var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; - copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(copyReproItem); - - var saveItem = new MenuItem { Header = "Save .sqlplan" }; - saveItem.Click += SavePlan_Click; - menu.Items.Add(saveItem); - - return menu; - } - - private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) - { - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard != null) - await topLevel.Clipboard.SetTextAsync(text); - } - - private void ShowPropertiesPanel(PlanNode node) - { - PropertiesContent.Children.Clear(); - _sectionLabelColumns.Clear(); - _currentSectionGrid = null; - _currentSectionRowIndex = 0; - - // Header - var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) - headerText += $" ({node.LogicalOp})"; - PropertiesHeader.Text = headerText; - PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; - - // === General Section === - AddPropertySection("General"); - AddPropertyRow("Physical Operation", node.PhysicalOp); - AddPropertyRow("Logical Operation", node.LogicalOp); - AddPropertyRow("Node ID", $"{node.NodeId}"); - if (!string.IsNullOrEmpty(node.ExecutionMode)) - AddPropertyRow("Execution Mode", node.ExecutionMode); - if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) - AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode); - AddPropertyRow("Parallel", node.Parallel ? "True" : "False"); - if (node.Partitioned) - AddPropertyRow("Partitioned", "True"); - if (node.EstimatedDOP > 0) - AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}"); - - // Scan/seek-related properties - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddPropertyRow("Ordered", node.Ordered ? "True" : "False"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddPropertyRow("Scan Direction", node.ScanDirection); - AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False"); - AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False"); - AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False"); - AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False"); - if (node.Lookup) - AddPropertyRow("Lookup", "True"); - if (node.DynamicSeek) - AddPropertyRow("Dynamic Seek", "True"); - } - - if (!string.IsNullOrEmpty(node.StorageType)) - AddPropertyRow("Storage", node.StorageType); - if (node.IsAdaptive) - AddPropertyRow("Adaptive", "True"); - if (node.SpillOccurredDetail) - AddPropertyRow("Spill Occurred", "True"); - - // === Object Section === - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddPropertySection("Object"); - AddPropertyRow("Full Name", node.FullObjectName, isCode: true); - if (!string.IsNullOrEmpty(node.ServerName)) - AddPropertyRow("Server", node.ServerName); - if (!string.IsNullOrEmpty(node.DatabaseName)) - AddPropertyRow("Database", node.DatabaseName); - if (!string.IsNullOrEmpty(node.ObjectAlias)) - AddPropertyRow("Alias", node.ObjectAlias); - if (!string.IsNullOrEmpty(node.IndexName)) - AddPropertyRow("Index", node.IndexName); - if (!string.IsNullOrEmpty(node.IndexKind)) - AddPropertyRow("Index Kind", node.IndexKind); - if (node.FilteredIndex) - AddPropertyRow("Filtered Index", "True"); - if (node.TableReferenceId > 0) - AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}"); - } - - // === Operator Details Section === - var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy) - || !string.IsNullOrEmpty(node.TopExpression) - || !string.IsNullOrEmpty(node.GroupBy) - || !string.IsNullOrEmpty(node.PartitionColumns) - || !string.IsNullOrEmpty(node.HashKeys) - || !string.IsNullOrEmpty(node.SegmentColumn) - || !string.IsNullOrEmpty(node.DefinedValues) - || !string.IsNullOrEmpty(node.OuterReferences) - || !string.IsNullOrEmpty(node.InnerSideJoinColumns) - || !string.IsNullOrEmpty(node.OuterSideJoinColumns) - || !string.IsNullOrEmpty(node.ActionColumn) - || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator - || node.SortDistinct || node.StartupExpression - || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch - || node.WithTies || node.Remoting || node.LocalParallelism - || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0 - || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0 - || !string.IsNullOrEmpty(node.ConstantScanValues) - || !string.IsNullOrEmpty(node.UdxUsedColumns); - - if (hasOperatorDetails) - { - AddPropertySection("Operator Details"); - if (!string.IsNullOrEmpty(node.OrderBy)) - AddPropertyRow("Order By", node.OrderBy, isCode: true); - if (!string.IsNullOrEmpty(node.TopExpression)) - { - var topText = node.TopExpression; - if (node.IsPercent) topText += " PERCENT"; - if (node.WithTies) topText += " WITH TIES"; - AddPropertyRow("Top", topText); - } - if (node.SortDistinct) - AddPropertyRow("Distinct Sort", "True"); - if (node.StartupExpression) - AddPropertyRow("Startup Expression", "True"); - if (node.NLOptimized) - AddPropertyRow("Optimized", "True"); - if (node.WithOrderedPrefetch) - AddPropertyRow("Ordered Prefetch", "True"); - if (node.WithUnorderedPrefetch) - AddPropertyRow("Unordered Prefetch", "True"); - if (node.BitmapCreator) - AddPropertyRow("Bitmap Creator", "True"); - if (node.Remoting) - AddPropertyRow("Remoting", "True"); - if (node.LocalParallelism) - AddPropertyRow("Local Parallelism", "True"); - if (!string.IsNullOrEmpty(node.GroupBy)) - AddPropertyRow("Group By", node.GroupBy, isCode: true); - if (!string.IsNullOrEmpty(node.PartitionColumns)) - AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeys)) - AddPropertyRow("Hash Keys", node.HashKeys, isCode: true); - if (!string.IsNullOrEmpty(node.OffsetExpression)) - AddPropertyRow("Offset", node.OffsetExpression); - if (node.TopRows > 0) - AddPropertyRow("Rows", $"{node.TopRows}"); - if (node.SpoolStack) - AddPropertyRow("Stack Spool", "True"); - if (node.PrimaryNodeId > 0) - AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}"); - if (node.DMLRequestSort) - AddPropertyRow("DML Request Sort", "True"); - if (node.NonClusteredIndexCount > 0) - { - AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}"); - foreach (var ixName in node.NonClusteredIndexNames) - AddPropertyRow("", ixName, isCode: true); - } - if (!string.IsNullOrEmpty(node.ActionColumn)) - AddPropertyRow("Action Column", node.ActionColumn, isCode: true); - if (!string.IsNullOrEmpty(node.SegmentColumn)) - AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true); - if (!string.IsNullOrEmpty(node.DefinedValues)) - AddPropertyRow("Defined Values", node.DefinedValues, isCode: true); - if (!string.IsNullOrEmpty(node.OuterReferences)) - AddPropertyRow("Outer References", node.OuterReferences, isCode: true); - if (!string.IsNullOrEmpty(node.InnerSideJoinColumns)) - AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); - if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) - AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); - if (node.PhysicalOp == "Merge Join") - AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); - else if (node.ManyToMany) - AddPropertyRow("Many to Many", "Yes"); - if (!string.IsNullOrEmpty(node.ConstantScanValues)) - AddPropertyRow("Values", node.ConstantScanValues, isCode: true); - if (!string.IsNullOrEmpty(node.UdxUsedColumns)) - AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true); - if (node.RowCount) - AddPropertyRow("Row Count", "True"); - if (node.ForceSeekColumnCount > 0) - AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}"); - if (!string.IsNullOrEmpty(node.PartitionId)) - AddPropertyRow("Partition Id", node.PartitionId, isCode: true); - if (node.IsStarJoin) - AddPropertyRow("Star Join Root", "True"); - if (!string.IsNullOrEmpty(node.StarJoinOperationType)) - AddPropertyRow("Star Join Type", node.StarJoinOperationType); - if (!string.IsNullOrEmpty(node.ProbeColumn)) - AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true); - if (node.InRow) - AddPropertyRow("In-Row", "True"); - if (node.ComputeSequence) - AddPropertyRow("Compute Sequence", "True"); - if (node.RollupHighestLevel > 0) - AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}"); - if (node.RollupLevels.Count > 0) - AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels)); - if (!string.IsNullOrEmpty(node.TvfParameters)) - AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true); - if (!string.IsNullOrEmpty(node.OriginalActionColumn)) - AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true); - if (!string.IsNullOrEmpty(node.TieColumns)) - AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true); - if (!string.IsNullOrEmpty(node.UdxName)) - AddPropertyRow("UDX Name", node.UdxName); - if (node.GroupExecuted) - AddPropertyRow("Group Executed", "True"); - if (node.RemoteDataAccess) - AddPropertyRow("Remote Data Access", "True"); - if (node.OptimizedHalloweenProtectionUsed) - AddPropertyRow("Halloween Protection", "True"); - if (node.StatsCollectionId > 0) - AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}"); - } - - // === Scalar UDFs === - if (node.ScalarUdfs.Count > 0) - { - AddPropertySection("Scalar UDFs"); - foreach (var udf in node.ScalarUdfs) - { - var udfDetail = udf.FunctionName; - if (udf.IsClrFunction) - { - udfDetail += " (CLR)"; - if (!string.IsNullOrEmpty(udf.ClrAssembly)) - udfDetail += $"\n Assembly: {udf.ClrAssembly}"; - if (!string.IsNullOrEmpty(udf.ClrClass)) - udfDetail += $"\n Class: {udf.ClrClass}"; - if (!string.IsNullOrEmpty(udf.ClrMethod)) - udfDetail += $"\n Method: {udf.ClrMethod}"; - } - AddPropertyRow("UDF", udfDetail, isCode: true); - } - } - - // === Named Parameters (IndexScan) === - if (node.NamedParameters.Count > 0) - { - AddPropertySection("Named Parameters"); - foreach (var np in node.NamedParameters) - AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true); - } - - // === Per-Operator Indexed Views === - if (node.OperatorIndexedViews.Count > 0) - { - AddPropertySection("Operator Indexed Views"); - foreach (var iv in node.OperatorIndexedViews) - AddPropertyRow("View", iv, isCode: true); - } - - // === Suggested Index (Eager Spool) === - if (!string.IsNullOrEmpty(node.SuggestedIndex)) - { - AddPropertySection("Suggested Index"); - AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true); - } - - // === Remote Operator === - if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource) - || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery)) - { - AddPropertySection("Remote Operator"); - if (!string.IsNullOrEmpty(node.RemoteDestination)) - AddPropertyRow("Destination", node.RemoteDestination); - if (!string.IsNullOrEmpty(node.RemoteSource)) - AddPropertyRow("Source", node.RemoteSource); - if (!string.IsNullOrEmpty(node.RemoteObject)) - AddPropertyRow("Object", node.RemoteObject, isCode: true); - if (!string.IsNullOrEmpty(node.RemoteQuery)) - AddPropertyRow("Query", node.RemoteQuery, isCode: true); - } - - // === Foreign Key References Section === - if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0) - { - AddPropertySection("Foreign Key References"); - if (node.ForeignKeyReferencesCount > 0) - AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}"); - if (node.NoMatchingIndexCount > 0) - AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}"); - if (node.PartialMatchingIndexCount > 0) - AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}"); - } - - // === Adaptive Join Section === - if (node.IsAdaptive) - { - AddPropertySection("Adaptive Join"); - if (!string.IsNullOrEmpty(node.EstimatedJoinType)) - AddPropertyRow("Est. Join Type", node.EstimatedJoinType); - if (!string.IsNullOrEmpty(node.ActualJoinType)) - AddPropertyRow("Actual Join Type", node.ActualJoinType); - if (node.AdaptiveThresholdRows > 0) - AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}"); - } - - // === Estimated Costs Section === - AddPropertySection("Estimated Costs"); - AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)"); - AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); - AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}"); - AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}"); - - // === Estimated Rows Section === - AddPropertySection("Estimated Rows"); - var estExecs = 1 + node.EstimateRebinds; - AddPropertyRow("Est. Executions", $"{estExecs:N0}"); - AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}"); - AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}"); - if (node.EstimatedRowsRead > 0) - AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}"); - if (node.EstimateRowsWithoutRowGoal > 0) - AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}"); - if (node.TableCardinality > 0) - AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}"); - AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B"); - AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}"); - AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}"); - - // === Actual Stats Section (if actual plan) === - if (node.HasActualStats) - { - AddPropertySection("Actual Statistics"); - AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true); - if (node.ActualRowsRead > 0) - { - AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true); - } - AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true); - if (node.ActualRebinds > 0) - AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}"); - if (node.ActualRewinds > 0) - AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}"); - - // Runtime partition summary - if (node.PartitionsAccessed > 0) - { - AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}"); - if (!string.IsNullOrEmpty(node.PartitionRanges)) - AddPropertyRow("Partition Ranges", node.PartitionRanges); - } - - // Timing - if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0 - || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0) - { - AddPropertySection("Actual Timing"); - if (node.ActualElapsedMs > 0) - { - AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true); - } - if (node.ActualCPUMs > 0) - { - AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true); - } - if (node.UdfElapsedTimeMs > 0) - AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms"); - if (node.UdfCpuTimeMs > 0) - AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms"); - } - - // I/O - var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0 - || node.ActualScans > 0 || node.ActualReadAheads > 0 - || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0; - if (hasIo) - { - AddPropertySection("Actual I/O"); - AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true); - if (node.ActualPhysicalReads > 0) - { - AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true); - } - if (node.ActualScans > 0) - { - AddPropertyRow("Scans", $"{node.ActualScans:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true); - } - if (node.ActualReadAheads > 0) - { - AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}"); - if (node.PerThreadStats.Count > 1) - foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0)) - AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true); - } - if (node.ActualSegmentReads > 0) - AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}"); - if (node.ActualSegmentSkips > 0) - AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}"); - } - - // LOB I/O - var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0 - || node.ActualLobReadAheads > 0; - if (hasLobIo) - { - AddPropertySection("Actual LOB I/O"); - if (node.ActualLobLogicalReads > 0) - AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}"); - if (node.ActualLobPhysicalReads > 0) - AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}"); - if (node.ActualLobReadAheads > 0) - AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}"); - } - } - - // === Predicates Section === - var hasPredicates = !string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate) - || !string.IsNullOrEmpty(node.HashKeysProbe) || !string.IsNullOrEmpty(node.HashKeysBuild) - || !string.IsNullOrEmpty(node.BuildResidual) || !string.IsNullOrEmpty(node.ProbeResidual) - || !string.IsNullOrEmpty(node.MergeResidual) || !string.IsNullOrEmpty(node.PassThru) - || !string.IsNullOrEmpty(node.SetPredicate) - || node.GuessedSelectivity; - if (hasPredicates) - { - AddPropertySection("Predicates"); - if (!string.IsNullOrEmpty(node.SeekPredicates)) - AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true); - if (!string.IsNullOrEmpty(node.Predicate)) - AddPropertyRow("Predicate", node.Predicate, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeysBuild)) - AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true); - if (!string.IsNullOrEmpty(node.HashKeysProbe)) - AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true); - if (!string.IsNullOrEmpty(node.BuildResidual)) - AddPropertyRow("Build Residual", node.BuildResidual, isCode: true); - if (!string.IsNullOrEmpty(node.ProbeResidual)) - AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true); - if (!string.IsNullOrEmpty(node.MergeResidual)) - AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true); - if (!string.IsNullOrEmpty(node.PassThru)) - AddPropertyRow("Pass Through", node.PassThru, isCode: true); - if (!string.IsNullOrEmpty(node.SetPredicate)) - AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true); - if (node.GuessedSelectivity) - AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)"); - } - - // === Output Columns === - if (!string.IsNullOrEmpty(node.OutputColumns)) - { - AddPropertySection("Output"); - AddPropertyRow("Columns", node.OutputColumns, isCode: true); - } - - // === Memory === - if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0 - || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0 - || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0) - { - AddPropertySection("Memory"); - if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB"); - if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB"); - if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB"); - if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB"); - if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB"); - if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB"); - if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}"); - if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}"); - } - - // === Root node only: statement-level sections === - if (node.Parent == null && _currentStatement != null) - { - var s = _currentStatement; - - // === Statement Text === - if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName)) - { - AddPropertySection("Statement"); - if (!string.IsNullOrEmpty(s.StatementText)) - AddPropertyRow("Text", s.StatementText, isCode: true); - if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText) - AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true); - if (!string.IsNullOrEmpty(s.StmtUseDatabaseName)) - AddPropertyRow("USE Database", s.StmtUseDatabaseName); - } - - // === Cursor Info === - if (!string.IsNullOrEmpty(s.CursorName)) - { - AddPropertySection("Cursor Info"); - AddPropertyRow("Cursor Name", s.CursorName); - if (!string.IsNullOrEmpty(s.CursorActualType)) - AddPropertyRow("Actual Type", s.CursorActualType); - if (!string.IsNullOrEmpty(s.CursorRequestedType)) - AddPropertyRow("Requested Type", s.CursorRequestedType); - if (!string.IsNullOrEmpty(s.CursorConcurrency)) - AddPropertyRow("Concurrency", s.CursorConcurrency); - AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False"); - } - - // === Statement Memory Grant === - if (s.MemoryGrant != null) - { - var mg = s.MemoryGrant; - AddPropertySection("Memory Grant Info"); - AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB"); - AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB"); - AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB"); - AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB"); - AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB"); - AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB"); - AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB"); - if (mg.GrantWaitTimeMs > 0) - AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms"); - if (mg.LastRequestedMemoryKB > 0) - AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB"); - if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted)) - AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted); - } - - // === Statement Info === - AddPropertySection("Statement Info"); - if (!string.IsNullOrEmpty(s.StatementOptmLevel)) - AddPropertyRow("Optimization Level", s.StatementOptmLevel); - if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason)) - AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason); - if (s.CardinalityEstimationModelVersion > 0) - AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}"); - if (s.DegreeOfParallelism > 0) - AddPropertyRow("DOP", $"{s.DegreeOfParallelism}"); - if (s.EffectiveDOP > 0) - AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}"); - if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted)) - AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted); - if (!string.IsNullOrEmpty(s.NonParallelPlanReason)) - AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason); - if (s.MaxQueryMemoryKB > 0) - AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB"); - if (s.QueryPlanMemoryGrantKB > 0) - AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB"); - AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms"); - AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms"); - AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB"); - if (s.CachedPlanSizeKB > 0) - AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB"); - AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False"); - AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False"); - AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False"); - AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}"); - if (!string.IsNullOrEmpty(s.QueryHash)) - AddPropertyRow("Query Hash", s.QueryHash, isCode: true); - if (!string.IsNullOrEmpty(s.QueryPlanHash)) - AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true); - if (!string.IsNullOrEmpty(s.StatementSqlHandle)) - AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true); - AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}"); - AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}"); - - // Plan Guide - if (!string.IsNullOrEmpty(s.PlanGuideName)) - { - AddPropertyRow("Plan Guide", s.PlanGuideName); - if (!string.IsNullOrEmpty(s.PlanGuideDB)) - AddPropertyRow("Plan Guide DB", s.PlanGuideDB); - } - if (s.UsePlan) - AddPropertyRow("USE PLAN", "True"); - - // Query Store Hints - if (s.QueryStoreStatementHintId > 0) - { - AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}"); - if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText)) - AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true); - if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource)) - AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource); - } - - // === Feature Flags === - if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs - || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0 - || s.QueryVariantID > 0) - { - AddPropertySection("Feature Flags"); - if (s.ContainsInterleavedExecutionCandidates) - AddPropertyRow("Interleaved Execution", "True"); - if (s.ContainsInlineScalarTsqlUdfs) - AddPropertyRow("Inline Scalar UDFs", "True"); - if (s.ContainsLedgerTables) - AddPropertyRow("Ledger Tables", "True"); - if (s.ExclusiveProfileTimeActive) - AddPropertyRow("Exclusive Profile Time", "True"); - if (s.QueryCompilationReplay > 0) - AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}"); - if (s.QueryVariantID > 0) - AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}"); - } - - // === PSP Dispatcher === - if (s.Dispatcher != null) - { - AddPropertySection("PSP Dispatcher"); - if (!string.IsNullOrEmpty(s.DispatcherPlanHandle)) - AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true); - foreach (var psp in s.Dispatcher.ParameterSensitivePredicates) - { - var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]"; - var predText = psp.PredicateText ?? ""; - AddPropertyRow("Predicate", $"{predText} {range}", isCode: true); - foreach (var stat in psp.Statistics) - { - var statLabel = !string.IsNullOrEmpty(stat.TableName) - ? $" {stat.TableName}.{stat.StatisticsName}" - : $" {stat.StatisticsName}"; - AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true); - } - } - foreach (var opt in s.Dispatcher.OptionalParameterPredicates) - { - if (!string.IsNullOrEmpty(opt.PredicateText)) - AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true); - } - } - - // === Cardinality Feedback === - if (s.CardinalityFeedback.Count > 0) - { - AddPropertySection("Cardinality Feedback"); - foreach (var cf in s.CardinalityFeedback) - AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}"); - } - - // === Optimization Replay === - if (!string.IsNullOrEmpty(s.OptimizationReplayScript)) - { - AddPropertySection("Optimization Replay"); - AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true); - } - - // === Template Plan Guide === - if (!string.IsNullOrEmpty(s.TemplatePlanGuideName)) - { - AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName); - if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB)) - AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB); - } - - // === Handles === - if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle)) - { - AddPropertySection("Handles"); - if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle)) - AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true); - if (!string.IsNullOrEmpty(s.BatchSqlHandle)) - AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true); - } - - // === Set Options === - if (s.SetOptions != null) - { - var so = s.SetOptions; - AddPropertySection("Set Options"); - AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False"); - AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False"); - AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False"); - AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False"); - AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False"); - AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False"); - AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False"); - } - - // === Optimizer Hardware Properties === - if (s.HardwareProperties != null) - { - var hw = s.HardwareProperties; - AddPropertySection("Hardware Properties"); - AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB"); - AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}"); - AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}"); - if (hw.MaxCompileMemory > 0) - AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB"); - } - - // === Plan Version === - if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build))) - { - AddPropertySection("Plan Version"); - if (!string.IsNullOrEmpty(_currentPlan.BuildVersion)) - AddPropertyRow("Build Version", _currentPlan.BuildVersion); - if (!string.IsNullOrEmpty(_currentPlan.Build)) - AddPropertyRow("Build", _currentPlan.Build); - if (_currentPlan.ClusteredMode) - AddPropertyRow("Clustered Mode", "True"); - } - - // === Optimizer Stats Usage === - if (s.StatsUsage.Count > 0) - { - AddPropertySection("Statistics Used"); - foreach (var stat in s.StatsUsage) - { - var statLabel = !string.IsNullOrEmpty(stat.TableName) - ? $"{stat.TableName}.{stat.StatisticsName}" - : stat.StatisticsName; - var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%"; - if (!string.IsNullOrEmpty(stat.LastUpdate)) - statDetail += $", Updated: {stat.LastUpdate}"; - AddPropertyRow(statLabel, statDetail); - } - } - - // === Parameters === - if (s.Parameters.Count > 0) - { - AddPropertySection("Parameters"); - foreach (var p in s.Parameters) - { - var paramText = p.DataType; - if (!string.IsNullOrEmpty(p.CompiledValue)) - paramText += $", Compiled: {p.CompiledValue}"; - if (!string.IsNullOrEmpty(p.RuntimeValue)) - paramText += $", Runtime: {p.RuntimeValue}"; - AddPropertyRow(p.Name, paramText); - } - } - - // === Query Time Stats (actual plans) === - if (s.QueryTimeStats != null) - { - AddPropertySection("Query Time Stats"); - AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms"); - AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms"); - if (s.QueryUdfCpuTimeMs > 0) - AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms"); - if (s.QueryUdfElapsedTimeMs > 0) - AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms"); - } - - // === Thread Stats (actual plans) === - if (s.ThreadStats != null) - { - AddPropertySection("Thread Stats"); - AddPropertyRow("Branches", $"{s.ThreadStats.Branches}"); - AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}"); - var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads); - if (totalReserved > 0) - { - AddPropertyRow("Reserved Threads", $"{totalReserved}"); - if (totalReserved > s.ThreadStats.UsedThreads) - AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}"); - } - foreach (var res in s.ThreadStats.Reservations) - AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved"); - } - - // === Wait Stats (actual plans) === - if (s.WaitStats.Count > 0) - { - AddPropertySection("Wait Stats"); - foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs)) - AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)"); - } - - // === Trace Flags === - if (s.TraceFlags.Count > 0) - { - AddPropertySection("Trace Flags"); - foreach (var tf in s.TraceFlags) - { - var tfLabel = $"TF {tf.Value}"; - var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}"; - AddPropertyRow(tfLabel, tfDetail); - } - } - - // === Indexed Views === - if (s.IndexedViews.Count > 0) - { - AddPropertySection("Indexed Views"); - foreach (var iv in s.IndexedViews) - AddPropertyRow("View", iv, isCode: true); - } - - // === Plan-Level Warnings === - if (s.PlanWarnings.Count > 0) - { - var planWarningsPanel = new StackPanel(); - var sortedPlanWarnings = s.PlanWarnings - .OrderByDescending(w => w.MaxBenefitPercent ?? -1) - .ThenByDescending(w => w.Severity) - .ThenBy(w => w.WarningType); - foreach (var w in sortedPlanWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - var legacyTag = w.IsLegacy ? " [legacy]" : ""; - var planWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}{legacyTag}"; - warnPanel.Children.Add(new TextBlock - { - Text = planWarnHeader, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - if (!string.IsNullOrEmpty(w.ActionableFix)) - { - warnPanel.Children.Add(new TextBlock - { - Text = w.ActionableFix, - FontSize = 11, - FontStyle = FontStyle.Italic, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 2, 0, 0) - }); - } - planWarningsPanel.Children.Add(warnPanel); - } - - var planWarningsExpander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = "Plan Warnings", - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = planWarningsPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(planWarningsExpander); - } - - // === Missing Indexes === - if (s.MissingIndexes.Count > 0) - { - AddPropertySection("Missing Indexes"); - foreach (var mi in s.MissingIndexes) - { - AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%"); - if (!string.IsNullOrEmpty(mi.CreateStatement)) - AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true); - } - } - } - - // === Warnings === - if (node.HasWarnings) - { - var warningsPanel = new StackPanel(); - var sortedNodeWarnings = node.Warnings - .OrderByDescending(w => w.MaxBenefitPercent ?? -1) - .ThenByDescending(w => w.Severity) - .ThenBy(w => w.WarningType); - foreach (var w in sortedNodeWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - var nodeLegacyTag = w.IsLegacy ? " [legacy]" : ""; - var nodeWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}{nodeLegacyTag}"; - warnPanel.Children.Add(new TextBlock - { - Text = nodeWarnHeader, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - warningsPanel.Children.Add(warnPanel); - } - - var warningsExpander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = "Warnings", - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = warningsPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(warningsExpander); - } - - // Show the panel - _propertiesColumn.Width = new GridLength(320); - _splitterColumn.Width = new GridLength(5); - PropertiesSplitter.IsVisible = true; - PropertiesPanel.IsVisible = true; - } - - private void AddPropertySection(string title) - { - var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) }; - _sectionLabelColumns.Add(labelCol); - - // Sync column widths across sections when user drags the GridSplitter - labelCol.PropertyChanged += (_, args) => - { - if (args.Property.Name != "Width" || _isSyncingColumnWidth) return; - _isSyncingColumnWidth = true; - _propertyLabelWidth = labelCol.Width.Value; - foreach (var col in _sectionLabelColumns) - { - if (col != labelCol) - col.Width = labelCol.Width; - } - _isSyncingColumnWidth = false; - }; - - var sectionGrid = new Grid - { - Margin = new Thickness(6, 0, 6, 0) - }; - sectionGrid.ColumnDefinitions.Add(labelCol); - sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) }); - sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - _currentSectionGrid = sectionGrid; - _currentSectionRowIndex = 0; - - var expander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = title, - FontWeight = FontWeight.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = sectionGrid, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch - }; - PropertiesContent.Children.Add(expander); - } - - private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) - { - if (_currentSectionGrid == null) return; - - var row = _currentSectionRowIndex++; - _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - var labelBlock = new TextBlock - { - Text = label, - FontSize = indent ? 10 : 11, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Top, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(indent ? 16 : 4, 2, 0, 2) - }; - Grid.SetColumn(labelBlock, 0); - Grid.SetRow(labelBlock, row); - _currentSectionGrid.Children.Add(labelBlock); - - // GridSplitter in column 1 (only in first row per section) - if (row == 0) - { - var splitter = new GridSplitter - { - Width = 4, - Background = Brushes.Transparent, - Foreground = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast) - }; - Grid.SetColumn(splitter, 1); - Grid.SetRow(splitter, 0); - Grid.SetRowSpan(splitter, 100); // span all rows - _currentSectionGrid.Children.Add(splitter); - } - - var valueBox = new TextBox - { - Text = value, - FontSize = indent ? 10 : 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - IsReadOnly = true, - BorderThickness = new Thickness(0), - Background = Brushes.Transparent, - Padding = new Thickness(0), - Margin = new Thickness(0, 2, 4, 2), - VerticalAlignment = VerticalAlignment.Top - }; - if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBox, 2); - Grid.SetRow(valueBox, row); - _currentSectionGrid.Children.Add(valueBox); - } - - private void CloseProperties_Click(object? sender, RoutedEventArgs e) - { - ClosePropertiesPanel(); - } - - private void ClosePropertiesPanel() - { - PropertiesPanel.IsVisible = false; - PropertiesSplitter.IsVisible = false; - _propertiesColumn.Width = new GridLength(0); - _splitterColumn.Width = new GridLength(0); - - // Deselect node - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - _selectedNodeBorder = null; - } - } - - #endregion - - #region Tooltips - - private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null) - { - var tipBorder = new Border - { - Background = TooltipBgBrush, - BorderBrush = TooltipBorderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(12), - MaxWidth = 500 - }; - - var stack = new StackPanel(); - - // Header - var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) - && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) - headerText += $" ({node.LogicalOp})"; - stack.Children.Add(new TextBlock - { - Text = headerText, - FontWeight = FontWeight.Bold, - FontSize = 13, - Foreground = TooltipFgBrush, - Margin = new Thickness(0, 0, 0, 8) - }); - - // Cost - AddTooltipSection(stack, "Costs"); - AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})"); - AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}"); - - // Rows - AddTooltipSection(stack, "Rows"); - AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}"); - if (node.HasActualStats) - { - AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}"); - if (node.ActualRowsRead > 0) - AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}"); - AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}"); - } - - // Rebinds/Rewinds (spools and other operators with rebind/rewind data) - if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0 - || node.ActualRebinds > 0 || node.ActualRewinds > 0) - { - AddTooltipSection(stack, "Rebinds / Rewinds"); - // Always show both estimated values when section is visible - AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}"); - AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}"); - if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}"); - if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}"); - } - - // I/O and CPU estimates - if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0) - { - AddTooltipSection(stack, "Estimates"); - if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}"); - if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}"); - if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B"); - } - - // Actual I/O - if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0)) - { - AddTooltipSection(stack, "Actual I/O"); - AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}"); - if (node.ActualPhysicalReads > 0) - AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}"); - if (node.ActualScans > 0) - AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}"); - if (node.ActualReadAheads > 0) - AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}"); - } - - // Actual timing - if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0)) - { - AddTooltipSection(stack, "Timing"); - if (node.ActualElapsedMs > 0) - AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms"); - if (node.ActualCPUMs > 0) - AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms"); - } - - // Parallelism - if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType)) - { - AddTooltipSection(stack, "Parallelism"); - if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes"); - if (!string.IsNullOrEmpty(node.ExecutionMode)) - AddTooltipRow(stack, "Execution Mode", node.ExecutionMode); - if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode) - AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode); - if (!string.IsNullOrEmpty(node.PartitioningType)) - AddTooltipRow(stack, "Partitioning", node.PartitioningType); - } - - // Object - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - AddTooltipSection(stack, "Object"); - AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true); - if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddTooltipRow(stack, "Scan Direction", node.ScanDirection); - } - else if (!string.IsNullOrEmpty(node.ObjectName)) - { - AddTooltipSection(stack, "Object"); - AddTooltipRow(stack, "Name", node.ObjectName, isCode: true); - if (node.Ordered) AddTooltipRow(stack, "Ordered", "True"); - if (!string.IsNullOrEmpty(node.ScanDirection)) - AddTooltipRow(stack, "Scan Direction", node.ScanDirection); - } - - // NC index maintenance count - if (node.NonClusteredIndexCount > 0) - AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames)); - - // Operator details (key items only in tooltip) - var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy) - || !string.IsNullOrEmpty(node.TopExpression) - || !string.IsNullOrEmpty(node.GroupBy) - || !string.IsNullOrEmpty(node.OuterReferences); - if (hasTooltipDetails) - { - AddTooltipSection(stack, "Details"); - if (!string.IsNullOrEmpty(node.OrderBy)) - AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true); - if (!string.IsNullOrEmpty(node.TopExpression)) - AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression); - if (!string.IsNullOrEmpty(node.GroupBy)) - AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true); - if (!string.IsNullOrEmpty(node.OuterReferences)) - AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true); - } - - // Predicates - if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)) - { - AddTooltipSection(stack, "Predicates"); - if (!string.IsNullOrEmpty(node.SeekPredicates)) - AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true); - if (!string.IsNullOrEmpty(node.Predicate)) - AddTooltipRow(stack, "Residual", node.Predicate, isCode: true); - } - - // Output columns - if (!string.IsNullOrEmpty(node.OutputColumns)) - { - AddTooltipSection(stack, "Output"); - AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true); - } - - // Warnings — use allWarnings (all nodes) for root, node.Warnings for others - var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null); - if (warnings != null && warnings.Count > 0) - { - stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) }); - - if (allWarnings != null) - { - // Root node: show distinct warning type names only, sorted by max benefit - var distinct = warnings - .GroupBy(w => w.WarningType) - .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(), - MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1))) - .OrderByDescending(g => g.MaxBenefit) - .ThenByDescending(g => g.MaxSeverity) - .ThenBy(g => g.Type); - - foreach (var (type, severity, count, maxBenefit) in distinct) - { - var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" - : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : ""; - var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}"; - stack.Children.Add(new TextBlock - { - Text = label, - Foreground = new SolidColorBrush(Color.Parse(warnColor)), - FontSize = 11, - Margin = new Thickness(0, 2, 0, 0) - }); - } - } - else - { - // Individual node: show full warning messages - foreach (var w in warnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - stack.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}: {w.Message}", - Foreground = new SolidColorBrush(Color.Parse(warnColor)), - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 2, 0, 0) - }); - } - } - } - - // Footer hint - stack.Children.Add(new TextBlock - { - Text = "Click to view full properties", - FontSize = 10, - FontStyle = FontStyle.Italic, - Foreground = TooltipFgBrush, - Margin = new Thickness(0, 8, 0, 0) - }); - - tipBorder.Child = stack; - return tipBorder; - } - - private static void AddTooltipSection(StackPanel parent, string title) - { - parent.Children.Add(new TextBlock - { - Text = title, - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = SectionHeaderBrush, - Margin = new Thickness(0, 6, 0, 2) - }); - } - - private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) - { - var row = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - Margin = new Thickness(0, 1, 0, 1) - }; - var labelBlock = new TextBlock - { - Text = $"{label}: ", - Foreground = TooltipFgBrush, - FontSize = 11, - MinWidth = 120, - VerticalAlignment = VerticalAlignment.Top - }; - Grid.SetColumn(labelBlock, 0); - row.Children.Add(labelBlock); - - var valueBlock = new TextBlock - { - Text = value, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap - }; - if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBlock, 1); - row.Children.Add(valueBlock); - parent.Children.Add(row); - } - - #endregion - - #region Banners - - private void ShowMissingIndexes(List indexes) - { - MissingIndexContent.Children.Clear(); - - if (indexes.Count > 0) - { - // Update expander header with count - MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; - - // Build each missing index row manually (no ItemsControl template binding) - foreach (var mi in indexes) - { - var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; - - var headerRow = new StackPanel { Orientation = Orientation.Horizontal }; - headerRow.Children.Add(new TextBlock - { - Text = mi.Table, - FontWeight = FontWeight.SemiBold, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $" \u2014 Impact: ", - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $"{mi.Impact:F1}%", - Foreground = new SolidColorBrush(Color.Parse("#FFB347")), - FontSize = 12 - }); - itemPanel.Children.Add(headerRow); - - if (!string.IsNullOrEmpty(mi.CreateStatement)) - { - itemPanel.Children.Add(new SelectableTextBlock - { - Text = mi.CreateStatement, - FontFamily = new FontFamily("Consolas"), - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(12, 2, 0, 0) - }); - } - - MissingIndexContent.Children.Add(itemPanel); - } - - MissingIndexEmpty.IsVisible = false; - } - else - { - MissingIndexHeader.Text = "Missing Index Suggestions"; - MissingIndexEmpty.IsVisible = true; - } - } - - private void ShowParameters(PlanStatement statement) - { - ParametersContent.Children.Clear(); - ParametersEmpty.IsVisible = false; - - var parameters = statement.Parameters; - - if (parameters.Count == 0) - { - var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); - if (localVars.Count > 0) - { - ParametersHeader.Text = "Parameters"; - AddParameterAnnotation( - $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML", - "#FFB347"); - } - else - { - ParametersHeader.Text = "Parameters"; - ParametersEmpty.IsVisible = true; - } - return; - } - - ParametersHeader.Text = $"Parameters ({parameters.Count})"; - - var allCompiledNull = parameters.All(p => p.CompiledValue == null); - var hasCompiled = parameters.Any(p => p.CompiledValue != null); - var hasRuntime = parameters.Any(p => p.RuntimeValue != null); - - // Build a 4-column grid: Name | Data Type | Compiled | Runtime - // Only show Compiled/Runtime columns if at least one param has that value - var colDef = "Auto,Auto"; // Name, DataType always shown - int compiledCol = -1, runtimeCol = -1; - int nextCol = 2; - if (hasCompiled) - { - colDef += ",*"; - compiledCol = nextCol++; - } - if (hasRuntime) - { - colDef += ",*"; - runtimeCol = nextCol++; - } - // If neither compiled nor runtime, still add one value column for "?" - if (!hasCompiled && !hasRuntime) - { - colDef += ",*"; - compiledCol = nextCol++; - } - - var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) }; - int rowIndex = 0; - - // Header row - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold); - AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold); - if (compiledCol >= 0) - AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold); - if (runtimeCol >= 0) - AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold); - rowIndex++; - - foreach (var param in parameters) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - // Name - AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold); - - // Data type - AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB"); - - // Compiled value - if (compiledCol >= 0) - { - var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?"); - var compiledColor = param.CompiledValue != null ? "#E4E6EB" - : allCompiledNull ? "#E4E6EB" : "#E57373"; - AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor); - } - - // Runtime value — amber if it differs from compiled - if (runtimeCol >= 0) - { - var runtimeText = param.RuntimeValue ?? ""; - var sniffed = param.RuntimeValue != null - && param.CompiledValue != null - && param.RuntimeValue != param.CompiledValue; - var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB"; - var tooltip = sniffed - ? "Runtime value differs from compiled — possible parameter sniffing" - : null; - AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip); - } - - rowIndex++; - } - - ParametersContent.Children.Add(grid); - - // Annotations - if (allCompiledNull && parameters.Count > 0) - { - var hasOptimizeForUnknown = statement.StatementText - .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) - && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); - - if (hasOptimizeForUnknown) - { - AddParameterAnnotation( - "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values", - "#6BB5FF"); - } - else - { - AddParameterAnnotation( - "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", - "#FFB347"); - } - } - - var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); - if (unresolved.Count > 0) - { - AddParameterAnnotation( - $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list", - "#FFB347"); - } - } - - private static void AddParamCell(Grid grid, int row, int col, string text, string color, - FontWeight fontWeight = default, string? tooltip = null) - { - var tb = new TextBlock - { - Text = text, - FontSize = 11, - FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight, - Foreground = new SolidColorBrush(Color.Parse(color)), - Margin = new Thickness(0, 2, 10, 2), - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = 200 - }; - // Name and DataType columns are short — no need for max width - if (col <= 1) - tb.MaxWidth = double.PositiveInfinity; - if (tooltip != null) - ToolTip.SetTip(tb, tooltip); - else if (text.Length > 30) - ToolTip.SetTip(tb, text); - Grid.SetRow(tb, row); - Grid.SetColumn(tb, col); - grid.Children.Add(tb); - } - - private void AddParameterAnnotation(string text, string color) - { - ParametersContent.Children.Add(new TextBlock - { - Text = text, - FontSize = 11, - FontStyle = FontStyle.Italic, - Foreground = new SolidColorBrush(Color.Parse(color)), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 6, 0, 0) - }); - } - - private static List FindUnresolvedVariables(string queryText, List parameters, - PlanNode? rootNode = null) - { - var unresolved = new List(); - if (string.IsNullOrEmpty(queryText)) - return unresolved; - - var extractedNames = new HashSet( - parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); - - // Collect table variable names from the plan tree so we don't misreport them as local variables - var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase); - if (rootNode != null) - CollectTableVariableNames(rootNode, tableVarNames); - - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); - var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (Match match in matches) - { - var varName = match.Value; - if (seenVars.Contains(varName) || extractedNames.Contains(varName)) - continue; - if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase)) - continue; - if (tableVarNames.Contains(varName)) - continue; - - seenVars.Add(varName); - unresolved.Add(varName); - } - - return unresolved; - } - - private static void CollectTableVariableNames(PlanNode node, HashSet names) - { - if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) - { - // ObjectName is like "@t.c" — extract the table variable name "@t" - var dotIdx = node.ObjectName.IndexOf('.'); - var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName; - names.Add(tvName); - } - foreach (var child in node.Children) - CollectTableVariableNames(child, names); - } - - private static void CollectWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectWarnings(child, warnings); - } - - /// - /// Computes own CPU time for a node by subtracting child times in row mode. - /// Batch mode reports own time directly; row mode is cumulative from leaves up. - /// - private static long GetOwnCpuMs(PlanNode node) - { - if (node.ActualCPUMs <= 0) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualCPUMs; - var childSum = GetChildCpuMsSum(node); - return Math.Max(0, node.ActualCPUMs - childSum); - } - - /// - /// Computes own elapsed time for a node by subtracting child times in row mode. - /// - private static long GetOwnElapsedMs(PlanNode node) - { - if (node.ActualElapsedMs <= 0) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualElapsedMs; - - // Exchange operators: Thread 0 is the coordinator whose elapsed time is the - // wall clock for the entire parallel branch — not the operator's own work. - if (IsExchangeOperator(node)) - { - // If we have worker thread data, use max of worker threads - var workerMax = node.PerThreadStats - .Where(t => t.ThreadId > 0) - .Select(t => t.ActualElapsedMs) - .DefaultIfEmpty(0) - .Max(); - if (workerMax > 0) - { - var childSum = GetChildElapsedMsSum(node); - return Math.Max(0, workerMax - childSum); - } - // Thread 0 only (coordinator) — exchange does negligible own work - return 0; - } - - var childElapsedSum = GetChildElapsedMsSum(node); - return Math.Max(0, node.ActualElapsedMs - childElapsedSum); - } - - private static bool IsExchangeOperator(PlanNode node) => - node.PhysicalOp == "Parallelism" - || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams"; - - private static long GetChildCpuMsSum(PlanNode node) - { - long sum = 0; - foreach (var child in node.Children) - { - if (child.ActualCPUMs > 0) - sum += child.ActualCPUMs; - else - sum += GetChildCpuMsSum(child); // skip through transparent operators - } - return sum; - } - - private static long GetChildElapsedMsSum(PlanNode node) - { - long sum = 0; - foreach (var child in node.Children) - { - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - { - // Exchange: take max of children (parallel branches) - sum += child.Children - .Where(c => c.ActualElapsedMs > 0) - .Select(c => c.ActualElapsedMs) - .DefaultIfEmpty(0) - .Max(); - } - else if (child.ActualElapsedMs > 0) - { - sum += child.ActualElapsedMs; - } - else - { - sum += GetChildElapsedMsSum(child); // skip through transparent operators - } - } - return sum; - } - - private void ShowWaitStats(List waits, List benefits, bool isActualPlan) - { - WaitStatsContent.Children.Clear(); - - if (waits.Count == 0) - { - WaitStatsHeader.Text = "Wait Stats"; - WaitStatsEmpty.Text = isActualPlan - ? "No wait stats recorded" - : "No wait stats (estimated plan)"; - WaitStatsEmpty.IsVisible = true; - return; - } - - WaitStatsEmpty.IsVisible = false; - - // Build benefit lookup - var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var wb in benefits) - benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; - - var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); - var maxWait = sorted[0].WaitTimeMs; - var totalWait = sorted.Sum(w => w.WaitTimeMs); - - // Update expander header with total - WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; - - // Build a single Grid for all rows so columns align - // Name, bar, duration, and benefit columns - var grid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") - }; - for (int i = 0; i < sorted.Count; i++) - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - for (int i = 0; i < sorted.Count; i++) - { - var w = sorted[i]; - var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0; - var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType)); - - // Wait type name — colored by category - var nameText = new TextBlock - { - Text = w.WaitType, - FontSize = 12, - Foreground = new SolidColorBrush(Color.Parse(color)), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 10, 2) - }; - Grid.SetRow(nameText, i); - Grid.SetColumn(nameText, 0); - grid.Children.Add(nameText); - - // Bar — semi-transparent category color, compact proportional indicator - var barColor = Color.Parse(color); - var colorBar = new Border - { - Width = Math.Max(4, barFraction * 60), - Height = 14, - Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)), - CornerRadius = new CornerRadius(2), - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 8, 2) - }; - Grid.SetRow(colorBar, i); - Grid.SetColumn(colorBar, 1); - grid.Children.Add(colorBar); - - // Duration text - var durationText = new TextBlock - { - Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", - FontSize = 12, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 8, 2) - }; - Grid.SetRow(durationText, i); - Grid.SetColumn(durationText, 2); - grid.Children.Add(durationText); - - // Benefit % (if available) - if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) - { - var benefitText = new TextBlock - { - Text = $"up to {benefitPct:N0}%", - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse("#8b949e")), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) - }; - Grid.SetRow(benefitText, i); - Grid.SetColumn(benefitText, 3); - grid.Children.Add(benefitText); - } - } - - WaitStatsContent.Children.Add(grid); - - } - - private void ShowRuntimeSummary(PlanStatement statement) - { - RuntimeSummaryContent.Children.Clear(); - - var labelColor = "#E4E6EB"; - var valueColor = "#E4E6EB"; - - var grid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*") - }; - int rowIndex = 0; - - void AddRow(string label, string value, string? color = null) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - var labelText = new TextBlock - { - Text = label, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(labelColor)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) - }; - Grid.SetRow(labelText, rowIndex); - Grid.SetColumn(labelText, 0); - grid.Children.Add(labelText); - - var valueText = new TextBlock - { - Text = value, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)), - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(valueText, rowIndex); - Grid.SetColumn(valueText, 1); - grid.Children.Add(valueText); - - rowIndex++; - } - - // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%. - // Loosened per Joe's feedback (#215 C1): for memory grants, moderate - // utilization (e.g. 60%) is fine — operators can spill near their max, - // so we shouldn't flag anything above a real over-grant threshold. - static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" - : pct >= 20 ? "#FFB347" : "#E57373"; - - // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), - // any operator spilled (orange), otherwise tier by utilization. - static string MemoryGrantColor(double pctUsed, bool hasSpill) - { - if (pctUsed > 100) return "#E57373"; - if (hasSpill) return "#FFB347"; - if (pctUsed >= 40) return "#E4E6EB"; - if (pctUsed >= 20) return "#FFB347"; - return "#E57373"; - } - - // E7: rename the panel title for estimated plans - var isEstimated = statement.QueryTimeStats == null; - RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; - - var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); - - // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. - // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. - - if (statement.QueryTimeStats != null) - { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - if (statement.QueryTimeStats.ElapsedTimeMs > 0) - { - long externalWaitMs = 0; - foreach (var w in statement.WaitStats) - if (BenefitScorer.IsExternalWait(w.WaitType)) - externalWaitMs += w.WaitTimeMs; - var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); - var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; - AddRow("CPU:Elapsed", ratio.ToString("N2")); - } - } - - // DOP + parallelism efficiency - if (statement.DegreeOfParallelism > 0) - { - var dopText = statement.DegreeOfParallelism.ToString(); - string? dopColor = null; - if (statement.QueryTimeStats != null && - statement.QueryTimeStats.ElapsedTimeMs > 0 && - statement.QueryTimeStats.CpuTimeMs > 0 && - statement.DegreeOfParallelism > 1) - { - long externalWaitMs = 0; - foreach (var w in statement.WaitStats) - if (BenefitScorer.IsExternalWait(w.WaitType)) - externalWaitMs += w.WaitTimeMs; - var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); - var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; - var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0); - efficiency = Math.Max(0.0, efficiency); - dopText += $" ({efficiency:N0}% efficient)"; - dopColor = EfficiencyColor(efficiency); - } - AddRow("DOP", dopText, dopColor); - } - else if (statement.NonParallelPlanReason != null) - AddRow("Serial", statement.NonParallelPlanReason); - - if (statement.QueryTimeStats != null) - { - AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); - if (statement.QueryUdfCpuTimeMs > 0) - AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); - if (statement.QueryUdfElapsedTimeMs > 0) - AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); - } - - // Compile stats (category B plan-level property) - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Memory grant — color per new tiers, spill indicator if any operator spilled - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); - var spillTag = hasSpillInTree ? " ⚠ spill" : ""; - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); - } - - // Thread stats - if (statement.ThreadStats != null) - { - var ts = statement.ThreadStats; - AddRow("Branches", ts.Branches.ToString()); - var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads); - if (totalReserved > 0) - { - var threadPct = (double)ts.UsedThreads / totalReserved * 100; - var threadColor = EfficiencyColor(threadPct); - var threadText = ts.UsedThreads == totalReserved - ? $"{ts.UsedThreads} used ({totalReserved} reserved)" - : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; - AddRow("Threads", threadText, threadColor); - } - else - { - AddRow("Threads", $"{ts.UsedThreads} used"); - } - } - - // Optimization + CE model - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - if (grid.Children.Count > 0) - { - RuntimeSummaryContent.Children.Add(grid); - RuntimeSummaryEmpty.IsVisible = false; - } - else - { - RuntimeSummaryEmpty.IsVisible = true; - } - ShowServerContext(); - } - - private void ShowServerContext() - { - ServerContextContent.Children.Clear(); - if (_serverMetadata == null) - { - ServerContextEmpty.IsVisible = true; - ServerContextBorder.IsVisible = true; - return; - } - - ServerContextEmpty.IsVisible = false; - - var m = _serverMetadata; - var fgColor = "#E4E6EB"; - - var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; - int rowIndex = 0; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - var lb = new TextBlock - { - Text = label, FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(fgColor)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) - }; - Grid.SetRow(lb, rowIndex); - Grid.SetColumn(lb, 0); - grid.Children.Add(lb); - - var vb = new TextBlock - { - Text = value, FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(fgColor)), - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(vb, rowIndex); - Grid.SetColumn(vb, 1); - grid.Children.Add(vb); - rowIndex++; - } - - // Server name + edition - var edition = m.Edition; - if (edition != null) - { - var idx = edition.IndexOf(" (64-bit)"); - if (idx > 0) edition = edition[..idx]; - } - var serverLine = m.ServerName ?? "Unknown"; - if (edition != null) serverLine += $" ({edition})"; - if (m.ProductVersion != null) serverLine += $", {m.ProductVersion}"; - AddRow("Server", serverLine); - - // Hardware - if (m.CpuCount > 0) - AddRow("Hardware", $"{m.CpuCount} CPUs, {m.PhysicalMemoryMB:N0} MB RAM"); - - // Instance settings - AddRow("MAXDOP", m.MaxDop.ToString()); - AddRow("Cost threshold", m.CostThresholdForParallelism.ToString()); - AddRow("Max memory", $"{m.MaxServerMemoryMB:N0} MB"); - - // Database - if (m.Database != null) - AddRow("Database", $"{m.Database.Name} (compat {m.Database.CompatibilityLevel})"); - - ServerContextContent.Children.Add(grid); - ServerContextBorder.IsVisible = true; - } - - private void UpdateInsightsHeader() - { - InsightsPanel.IsVisible = true; - InsightsHeader.Text = " Plan Insights"; - } - - private static string GetWaitCategory(string waitType) - { - if (waitType.StartsWith("SOS_SCHEDULER_YIELD") || - waitType.StartsWith("CXPACKET") || - waitType.StartsWith("CXCONSUMER") || - waitType.StartsWith("CXSYNC_PORT") || - waitType.StartsWith("CXSYNC_CONSUMER")) - return "CPU"; - - if (waitType.StartsWith("PAGEIOLATCH") || - waitType.StartsWith("WRITELOG") || - waitType.StartsWith("IO_COMPLETION") || - waitType.StartsWith("ASYNC_IO_COMPLETION")) - return "I/O"; - - if (waitType.StartsWith("LCK_M_")) - return "Lock"; - - if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD") - return "Memory"; - - if (waitType == "ASYNC_NETWORK_IO") - return "Network"; - - return "Other"; - } - - private static string GetWaitCategoryColor(string category) - { - return category switch - { - "CPU" => "#4FA3FF", - "I/O" => "#FFB347", - "Lock" => "#E57373", - "Memory" => "#9B59B6", - "Network" => "#2ECC71", - _ => "#6BB5FF" - }; - } - - #endregion - - #region Zoom - - private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep); - private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep); - - private void ZoomFit_Click(object? sender, RoutedEventArgs e) - { - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; - - var viewWidth = PlanScrollViewer.Bounds.Width; - var viewHeight = PlanScrollViewer.Bounds.Height; - if (viewWidth <= 0 || viewHeight <= 0) return; - - var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); - SetZoom(Math.Min(fitZoom, 1.0)); - PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); - } - - private void SetZoom(double level) - { - _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level)); - _zoomTransform.ScaleX = _zoomLevel; - _zoomTransform.ScaleY = _zoomLevel; - ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; - UpdateMinimapViewportBox(); - } - - /// - /// Sets the zoom level and adjusts the scroll offset so that the content point - /// under stays fixed in the viewport. - /// - private void SetZoomAtPoint(double level, Point viewportAnchor) - { - var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level)); - if (Math.Abs(newZoom - _zoomLevel) < 0.001) - return; - - // Content point under the anchor at the current zoom level - var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel; - var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel; - - // Apply the new zoom - SetZoom(newZoom); - - // Adjust offset so the same content point stays under the anchor - var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X); - var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); - UpdateMinimapViewportBox(); - }); - } - - private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) - { - if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) - { - e.Handled = true; - var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep); - SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer)); - } - } - - private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) - { - // Don't intercept scrollbar interactions - if (IsScrollBarAtPoint(e)) - return; - - var point = e.GetCurrentPoint(PlanScrollViewer); - var isMiddle = point.Properties.IsMiddleButtonPressed; - var isLeft = point.Properties.IsLeftButtonPressed; - - // Middle mouse always pans; left-click pans only on empty canvas (not on nodes) - if (isMiddle || (isLeft && !IsNodeAtPoint(e))) - { - _isPanning = true; - _panStart = point.Position; - _panStartOffsetX = PlanScrollViewer.Offset.X; - _panStartOffsetY = PlanScrollViewer.Offset.Y; - PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll); - e.Pointer.Capture(PlanScrollViewer); - e.Handled = true; - } - } - - private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_isPanning) return; - - var current = e.GetPosition(PlanScrollViewer); - var dx = current.X - _panStart.X; - var dy = current.Y - _panStart.Y; - - var newX = Math.Max(0, _panStartOffsetX - dx); - var newY = Math.Max(0, _panStartOffsetY - dy); - - // Defer offset change so the ScrollViewer doesn't overwrite it during layout - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(newX, newY); - UpdateMinimapViewportBox(); - }); - - e.Handled = true; - } - - private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_isPanning) return; - _isPanning = false; - PlanScrollViewer.Cursor = Cursor.Default; - e.Pointer.Capture(null); - e.Handled = true; - } - - /// Check if the pointer event originated from a node Border. - private bool IsNodeAtPoint(PointerPressedEventArgs e) - { - // Walk up the visual tree from the source to see if we hit a node border - var source = e.Source as Control; - while (source != null && source != PlanCanvas) - { - if (source is Border b && _nodeBorderMap.ContainsKey(b)) - return true; - source = source.Parent as Control; - } - return false; - } - - /// Check if the pointer event originated from a ScrollBar. - private bool IsScrollBarAtPoint(PointerPressedEventArgs e) - { - var source = e.Source as Control; - while (source != null && source != PlanScrollViewer) - { - if (source is ScrollBar) - return true; - source = source.Parent as Control; - } - return false; - } - - #endregion - - #region Save & Statement Selection - - private async void SavePlan_Click(object? sender, RoutedEventArgs e) - { - if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel == null) return; - - var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Save Plan", - DefaultExtension = "sqlplan", - SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan", - FileTypeChoices = new[] - { - new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } }, - new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } }, - new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } } - } - }); - - if (file != null) - { - try - { - await using var stream = await file.OpenWriteAsync(); - await using var writer = new StreamWriter(stream); - await writer.WriteAsync(_currentPlan.RawXml); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}"); - CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; - } - } - } - - #endregion - - #region Statements Panel - - private void PopulateStatementsGrid(List statements) - { - StatementsHeader.Text = $"Statements ({statements.Count})"; - - var hasActualTimes = statements.Any(s => s.QueryTimeStats != null && - (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0)); - var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0); - - // Build columns - StatementsGrid.Columns.Clear(); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "#", - Binding = new Avalonia.Data.Binding("Index"), - Width = new DataGridLength(40), - IsReadOnly = true - }); - - var queryTemplate = new FuncDataTemplate((row, _) => - { - if (row == null) return new TextBlock(); - var tb = new TextBlock - { - Text = row.QueryText, - TextWrapping = TextWrapping.Wrap, - MaxHeight = 80, - FontSize = 11, - Margin = new Thickness(4, 2) - }; - ToolTip.SetTip(tb, new TextBlock - { - Text = row.FullQueryText, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 600, - FontFamily = new FontFamily("Consolas"), - FontSize = 11 - }); - return tb; - }, supportsRecycling: false); - - StatementsGrid.Columns.Add(new DataGridTemplateColumn - { - Header = "Query", - CellTemplate = queryTemplate, - Width = new DataGridLength(250), - IsReadOnly = true - }); - - if (hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "CPU", - Binding = new Avalonia.Data.Binding("CpuDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.CpuMs) - }); - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Elapsed", - Binding = new Avalonia.Data.Binding("ElapsedDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.ElapsedMs) - }); - } - - if (hasUdf) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "UDF", - Binding = new Avalonia.Data.Binding("UdfDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - CustomSortComparer = new LongComparer(r => r.UdfMs) - }); - } - - if (!hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Est. Cost", - Binding = new Avalonia.Data.Binding("CostDisplay"), - Width = new DataGridLength(80), - IsReadOnly = true, - CustomSortComparer = new DoubleComparer(r => r.EstCost) - }); - } - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Critical", - Binding = new Avalonia.Data.Binding("Critical"), - Width = new DataGridLength(60), - IsReadOnly = true - }); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Warnings", - Binding = new Avalonia.Data.Binding("Warnings"), - Width = new DataGridLength(70), - IsReadOnly = true - }); - - // Build rows - var rows = new List(); - for (int i = 0; i < statements.Count; i++) - { - var stmt = statements[i]; - var allWarnings = stmt.PlanWarnings.ToList(); - if (stmt.RootNode != null) - CollectNodeWarnings(stmt.RootNode, allWarnings); - - var fullText = stmt.StatementText; - if (string.IsNullOrWhiteSpace(fullText)) - fullText = $"Statement {i + 1}"; - var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText; - - rows.Add(new StatementRow - { - Index = i + 1, - QueryText = displayText, - FullQueryText = fullText, - CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0, - ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0, - UdfMs = stmt.QueryUdfElapsedTimeMs, - EstCost = stmt.StatementSubTreeCost, - Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), - Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning), - Statement = stmt - }); - } - - StatementsGrid.ItemsSource = rows; - } - - private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (StatementsGrid.SelectedItem is StatementRow row) - RenderStatement(row.Statement); - } - - private async void CopyStatementText_Click(object? sender, RoutedEventArgs e) - { - if (StatementsGrid.SelectedItem is not StatementRow row) return; - var text = row.Statement.StatementText; - if (string.IsNullOrEmpty(text)) return; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard != null) - await topLevel.Clipboard.SetTextAsync(text); - } - - private void OpenInEditor_Click(object? sender, RoutedEventArgs e) - { - if (StatementsGrid.SelectedItem is not StatementRow row) return; - var text = row.Statement.StatementText; - if (string.IsNullOrEmpty(text)) return; - - OpenInEditorRequested?.Invoke(this, text); - } - - private static void CollectNodeWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectNodeWarnings(child, warnings); - } - - private void ToggleStatements_Click(object? sender, RoutedEventArgs e) - { - if (StatementsPanel.IsVisible) - CloseStatementsPanel(); - else - ShowStatementsPanel(); - } - - private void CloseStatements_Click(object? sender, RoutedEventArgs e) - { - CloseStatementsPanel(); - } - - private void ShowStatementsPanel() - { - _statementsColumn.Width = new GridLength(450); - _statementsSplitterColumn.Width = new GridLength(5); - StatementsSplitter.IsVisible = true; - StatementsPanel.IsVisible = true; - StatementsButton.IsVisible = true; - StatementsButtonSeparator.IsVisible = true; - } - - private void CloseStatementsPanel() - { - StatementsPanel.IsVisible = false; - StatementsSplitter.IsVisible = false; - _statementsColumn.Width = new GridLength(0); - _statementsSplitterColumn.Width = new GridLength(0); - } - - #endregion - - #region Minimap - - private void MinimapToggle_Click(object? sender, RoutedEventArgs e) - { - if (MinimapPanel.IsVisible) - CloseMinimapPanel(); - else - OpenMinimapPanel(); - } - - private void MinimapClose_Click(object? sender, RoutedEventArgs e) - { - CloseMinimapPanel(); - } - - private void OpenMinimapPanel() - { - MinimapPanel.Width = _minimapWidth; - MinimapPanel.Height = _minimapHeight; - MinimapPanel.IsVisible = true; - RenderMinimap(); - } - - private void CloseMinimapPanel() - { - MinimapPanel.IsVisible = false; - _minimapDragging = false; - _minimapResizing = false; - } - - private void RenderMinimap() - { - MinimapCanvas.Children.Clear(); - _minimapNodeMap.Clear(); - _minimapViewportBox = null; - _minimapSelectedNode = null; - - // Guard: don't render if the panel was closed between a deferred post and execution - if (!MinimapPanel.IsVisible) return; - - if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) - return; - - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) - { - // Defer until layout is ready - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded); - return; - } - - var scaleX = canvasW / PlanCanvas.Width; - var scaleY = canvasH / PlanCanvas.Height; - var scale = Math.Min(scaleX, scaleY); - - // Cache the non-expensive node border brush for this render cycle - _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg - ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B)) - : FindBrushResource("BorderBrush"); - - // Render branch areas with transparent colored backgrounds - RenderMinimapBranches(_currentStatement.RootNode, scale); - // Render edges - var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit); - RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit); - - // Render nodes - RenderMinimapNodes(_currentStatement.RootNode, scale); - - // Render viewport indicator - RenderMinimapViewportBox(scale); + #region Minimap - // Re-apply selection highlight if a node is selected - if (_selectedNode != null) - UpdateMinimapSelection(_selectedNode); - } private static readonly Color[] MinimapBranchColors = { @@ -3639,436 +394,13 @@ private void RenderMinimap() Color.FromArgb(0x30, 0xFF, 0x7B, 0xA5), // pink }; - private void RenderMinimapBranches(PlanNode root, double scale) - { - - for (int i = 0; i < root.Children.Count; i++) - { - var child = root.Children[i]; - var color = MinimapBranchColors[i % MinimapBranchColors.Length]; - - // Collect bounds of all nodes in this subtree - double minX = double.MaxValue, minY = double.MaxValue; - double maxX = double.MinValue, maxY = double.MinValue; - CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); - - var rect = new Avalonia.Controls.Shapes.Rectangle - { - Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4, - Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4, - Fill = new SolidColorBrush(color), - RadiusX = 2, - RadiusY = 2 - }; - Canvas.SetLeft(rect, minX * scale - 2); - Canvas.SetTop(rect, minY * scale - 2); - MinimapCanvas.Children.Add(rect); - } - } - - private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY) - { - if (node.X < minX) minX = node.X; - if (node.Y < minY) minY = node.Y; - if (node.X > maxX) maxX = node.X; - var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node); - if (bottom > maxY) maxY = bottom; - - foreach (var child in node.Children) - CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY); - } - - private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit) - { - foreach (var child in node.Children) - { - var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale; - var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale; - var childLeft = child.X * scale; - var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale; - var midX = (parentRight + childLeft) / 2; - - // Proportional thickness matching the plan viewer (logarithmic, scaled down) - var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows; - var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12)); - var thickness = Math.Max(0.5, fullThickness * scale); - - var geometry = new PathGeometry(); - var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false }; - figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) }); - figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); - geometry.Figures!.Add(figure); - - var linkBrush = GetLinkColorBrush(child, divergenceLimit); - - var path = new AvaloniaPath - { - Data = geometry, - Stroke = linkBrush, - StrokeThickness = thickness, - StrokeJoin = PenLineJoin.Round - }; - MinimapCanvas.Children.Add(path); - - RenderMinimapEdges(child, scale, divergenceLimit); - } - } // Cached per render cycle in RenderMinimap() to avoid per-node brush creation private IBrush _minimapNodeBorderBrushCache = Brushes.Gray; - private void RenderMinimapNodes(PlanNode node, double scale) - { - var w = PlanLayoutEngine.NodeWidth * scale; - var h = PlanLayoutEngine.GetNodeHeight(node) * scale; - // Use theme background colors with transparency - var bgBrush = node.IsExpensive - ? MinimapExpensiveNodeBgBrush - : FindBrushResource("BackgroundLightBrush"); - var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache; - - var border = new Border - { - Width = Math.Max(4, w), - Height = Math.Max(4, h), - Background = bgBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(0.5), - CornerRadius = new CornerRadius(1) - }; - - // Show a small icon inside the node if space allows - var iconBitmap = IconHelper.LoadIcon(node.IconName); - if (iconBitmap != null) - { - var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16); - if (iconSize >= 6) - { - border.Child = new Image - { - Source = iconBitmap, - Width = iconSize, - Height = iconSize, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; - } - } - - Canvas.SetLeft(border, node.X * scale); - Canvas.SetTop(border, node.Y * scale); - MinimapCanvas.Children.Add(border); - - _minimapNodeMap[border] = node; - - foreach (var child in node.Children) - RenderMinimapNodes(child, scale); - } - - private void RenderMinimapViewportBox(double scale) - { - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var contentW = PlanCanvas.Width * _zoomLevel; - var contentH = PlanCanvas.Height * _zoomLevel; - - var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale; - var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale; - var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale; - var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale; - - var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab - ? ab.Color - : Color.FromRgb(0x2E, 0xAE, 0xF1); - var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)); - var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B)); - - _minimapViewportBox = new Border - { - Width = Math.Max(4, boxW), - Height = Math.Max(4, boxH), - Background = themeBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1.5), - CornerRadius = new CornerRadius(1), - Cursor = new Cursor(StandardCursorType.SizeAll) - }; - Canvas.SetLeft(_minimapViewportBox, boxX); - Canvas.SetTop(_minimapViewportBox, boxY); - MinimapCanvas.Children.Add(_minimapViewportBox); - } - - private void UpdateMinimapViewportBox() - { - if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null) - return; - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return; - - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) return; - - var scaleX = canvasW / PlanCanvas.Width; - var scaleY = canvasH / PlanCanvas.Height; - var scale = Math.Min(scaleX, scaleY); - - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var contentW = PlanCanvas.Width * _zoomLevel; - var contentH = PlanCanvas.Height * _zoomLevel; - - _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale); - _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale); - Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale); - Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale); - } - - private double GetMinimapScale() - { - if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1; - var canvasW = MinimapCanvas.Bounds.Width; - var canvasH = MinimapCanvas.Bounds.Height; - if (canvasW <= 0 || canvasH <= 0) return 1; - return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height); - } - - private void UpdateMinimapSelection(PlanNode node) - { - if (!MinimapPanel.IsVisible) return; - - // Reset previous selection highlight - if (_minimapSelectedNode != null) - { - var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode); - _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true } - ? OrangeRedBrush - : _minimapNodeBorderBrushCache; - _minimapSelectedNode.BorderThickness = new Thickness(0.5); - _minimapSelectedNode = null; - } - - // Find and highlight the new node - foreach (var (border, n) in _minimapNodeMap) - { - if (n == node) - { - border.BorderBrush = SelectionBrush; - border.BorderThickness = new Thickness(2); - _minimapSelectedNode = border; - break; - } - } - } - - private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e) - { - var point = e.GetCurrentPoint(MinimapCanvas); - if (!point.Properties.IsLeftButtonPressed) return; - - var pos = point.Position; - var scale = GetMinimapScale(); - - // Check if clicking on a node (single click = center, double click = zoom) - if (e.ClickCount == 2) - { - // Double click: find node under pointer and zoom to it - var node = FindMinimapNodeAt(pos); - if (node != null) - { - ZoomToNode(node); - e.Handled = true; - return; - } - } - - if (e.ClickCount == 1) - { - // Check if over a minimap node for single-click centering - var node = FindMinimapNodeAt(pos); - if (node != null) - { - CenterOnNode(node); - e.Handled = true; - return; - } - } - - // Start viewport box drag - _minimapDragging = true; - - // Move viewport center to click position - ScrollPlanViewerToMinimapPoint(pos, scale); - - e.Pointer.Capture(MinimapCanvas); - e.Handled = true; - } - - private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_minimapDragging) return; - - var pos = e.GetPosition(MinimapCanvas); - var scale = GetMinimapScale(); - ScrollPlanViewerToMinimapPoint(pos, scale); - e.Handled = true; - } - - private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_minimapDragging) return; - _minimapDragging = false; - e.Pointer.Capture(null); - e.Handled = true; - } - - private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale) - { - if (scale <= 0) return; - // Convert minimap coords to plan content coords - var contentX = minimapPoint.X / scale; - var contentY = minimapPoint.Y / scale; - - // Center the viewport on this content point - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2); - var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(offsetX, offsetY); - }); - } - - private PlanNode? FindMinimapNodeAt(Point pos) - { - foreach (var (border, node) in _minimapNodeMap) - { - var left = Canvas.GetLeft(border); - var top = Canvas.GetTop(border); - if (pos.X >= left && pos.X <= left + border.Width && - pos.Y >= top && pos.Y <= top + border.Height) - return node; - } - return null; - } - - private void CenterOnNode(PlanNode node) - { - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(node); - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; - var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; - centerX = Math.Max(0, centerX); - centerY = Math.Max(0, centerY); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(centerX, centerY); - }); - } - - private void ZoomToNode(PlanNode node) - { - var viewW = PlanScrollViewer.Bounds.Width; - var viewH = PlanScrollViewer.Bounds.Height; - if (viewW <= 0 || viewH <= 0) return; - - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(node); - - // Zoom so the node takes about 1/3 of the viewport - var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3)); - fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom)); - SetZoom(fitZoom); - - // Center on the node - var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2; - var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2; - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY)); - }); - - // Also select the node in the plan - foreach (var (border, n) in _nodeBorderMap) - { - if (n == node) - { - SelectNode(border, node); - break; - } - } - } - - private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e) - { - var point = e.GetCurrentPoint(MinimapPanel); - if (!point.Properties.IsLeftButtonPressed) return; - _minimapResizing = true; - _minimapResizeStart = point.Position; - _minimapResizeStartW = MinimapPanel.Width; - _minimapResizeStartH = MinimapPanel.Height; - e.Pointer.Capture((Control)sender!); - e.Handled = true; - } - - private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e) - { - if (!_minimapResizing) return; - var current = e.GetPosition(MinimapPanel); - var dx = current.X - _minimapResizeStart.X; - var dy = current.Y - _minimapResizeStart.Y; - var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx)); - var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy)); - MinimapPanel.Width = newW; - MinimapPanel.Height = newH; - _minimapWidth = newW; - _minimapHeight = newH; - e.Handled = true; - - // Re-render after resize - Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background); - } - - private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_minimapResizing) return; - _minimapResizing = false; - e.Pointer.Capture(null); - e.Handled = true; - RenderMinimap(); - } #endregion - #region Helpers - - private IBrush FindBrushResource(string key) - { - if (this.TryFindResource(key, out var resource) && resource is IBrush brush) - return brush; - - // Fallback brushes in case resources are not found - return key switch - { - "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)), - "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)), - "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - _ => Brushes.White - }; - } - - #endregion #region Plan Viewer Connection @@ -4138,334 +470,9 @@ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEvent #region Schema Lookup - private static bool IsTempObject(string objectName) - { - // #temp tables, ##global temp, @table variables, internal worktables - return objectName.Contains('#') || objectName.Contains('@') - || objectName.Contains("worktable", StringComparison.OrdinalIgnoreCase) - || objectName.Contains("worksort", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsDataAccessOperator(PlanNode node) - { - var op = node.PhysicalOp; - if (string.IsNullOrEmpty(op)) return false; - - // Modification operators and data access operators reference objects - return op.Contains("Scan", StringComparison.OrdinalIgnoreCase) - || op.Contains("Seek", StringComparison.OrdinalIgnoreCase) - || op.Contains("Lookup", StringComparison.OrdinalIgnoreCase) - || op.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || op.Contains("Update", StringComparison.OrdinalIgnoreCase) - || op.Contains("Delete", StringComparison.OrdinalIgnoreCase) - || op.Contains("Spool", StringComparison.OrdinalIgnoreCase); - } - - private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) - { - if (string.IsNullOrEmpty(node.ObjectName) || IsTempObject(node.ObjectName)) - return; - if (!IsDataAccessOperator(node)) - return; - - var objectName = node.ObjectName; - - menu.Items.Add(new Separator()); - - var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" }; - showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName, - async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); - menu.Items.Add(showIndexes); - - var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" }; - showTableDef.Click += async (_, _) => await FetchAndShowSchemaAsync("Table", objectName, - async cs => - { - var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName); - var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName); - return FormatColumns(objectName, columns, indexes); - }); - menu.Items.Add(showTableDef); - - // Disable schema items when no connection - menu.Opening += (_, _) => - { - var enabled = ConnectionString != null; - showIndexes.IsEnabled = enabled; - showTableDef.IsEnabled = enabled; - }; - } - - private async System.Threading.Tasks.Task FetchAndShowSchemaAsync( - string kind, string objectName, Func> fetch) - { - if (ConnectionString == null) return; - - try - { - var content = await fetch(ConnectionString); - ShowSchemaResult($"{kind} — {objectName}", content); - } - catch (Exception ex) - { - ShowSchemaResult($"Error — {objectName}", $"-- Error: {ex.Message}"); - } - } - - private void ShowSchemaResult(string title, string content) - { - var editor = new AvaloniaEdit.TextEditor - { - Text = content, - IsReadOnly = true, - FontFamily = new FontFamily("Consolas, Menlo, monospace"), - FontSize = 13, - ShowLineNumbers = true, - Background = FindBrushResource("BackgroundBrush"), - Foreground = FindBrushResource("ForegroundBrush"), - HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(4) - }; - - // SQL syntax highlighting - var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); - var tm = editor.InstallTextMate(registryOptions); - tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); - - // Context menu - var copyItem = new MenuItem { Header = "Copy" }; - copyItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - var sel = editor.TextArea.Selection; - if (!sel.IsEmpty) - await clipboard.SetTextAsync(sel.GetText()); - }; - var copyAllItem = new MenuItem { Header = "Copy All" }; - copyAllItem.Click += async (_, _) => - { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; - if (clipboard == null) return; - await clipboard.SetTextAsync(editor.Text); - }; - var selectAllItem = new MenuItem { Header = "Select All" }; - selectAllItem.Click += (_, _) => editor.SelectAll(); - editor.TextArea.ContextMenu = new ContextMenu - { - Items = { copyItem, copyAllItem, new Separator(), selectAllItem } - }; - - // Show in a popup window - var window = new Window - { - Title = $"Performance Studio — {title}", - Width = 700, - Height = 500, - MinWidth = 400, - MinHeight = 200, - Background = FindBrushResource("BackgroundBrush"), - Foreground = FindBrushResource("ForegroundBrush"), - Content = editor - }; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is Window parentWindow) - { - window.Icon = parentWindow.Icon; - window.Show(parentWindow); - } - else - { - window.Show(); - } - } // --- Formatters (same logic as QuerySessionControl) --- - private static string FormatIndexes(string objectName, IReadOnlyList indexes) - { - if (indexes.Count == 0) - return $"-- No indexes found on {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"-- Indexes on {objectName}"); - sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows"); - sb.AppendLine(); - - foreach (var ix in indexes) - { - if (ix.IsDisabled) - sb.AppendLine("-- ** DISABLED **"); - - sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}"); - - var withOptions = BuildWithOptions(ix); - var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null - ? $"ON [{ix.PartitionScheme}]([{ix.PartitionColumn}])" - : null; - - if (ix.IsPrimaryKey) - { - var clustered = IsClusteredType(ix) ? "CLUSTERED" : "NONCLUSTERED"; - sb.AppendLine($"ALTER TABLE {objectName}"); - sb.AppendLine($"ADD CONSTRAINT [{ix.IndexName}]"); - sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})"); - if (withOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", withOptions)})"); - } - if (onPartition != null) - { - sb.AppendLine(); - sb.Append($" {onPartition}"); - } - sb.AppendLine(";"); - } - else if (IsColumnstore(ix)) - { - var clustered = ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) - ? "NONCLUSTERED " : "CLUSTERED "; - sb.Append($"CREATE {clustered}COLUMNSTORE INDEX [{ix.IndexName}]"); - sb.AppendLine($" ON {objectName}"); - if (ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrEmpty(ix.KeyColumns)) - sb.AppendLine($"({ix.KeyColumns})"); - var csOptions = BuildColumnstoreWithOptions(ix); - if (csOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", csOptions)})"); - if (onPartition != null) - sb.AppendLine(onPartition); - TrimTrailingNewline(sb); - sb.AppendLine(";"); - } - else - { - var unique = ix.IsUnique ? "UNIQUE " : ""; - var clustered = IsClusteredType(ix) ? "CLUSTERED " : "NONCLUSTERED "; - sb.Append($"CREATE {unique}{clustered}INDEX [{ix.IndexName}]"); - sb.AppendLine($" ON {objectName}"); - sb.AppendLine($"({ix.KeyColumns})"); - if (!string.IsNullOrEmpty(ix.IncludeColumns)) - sb.AppendLine($"INCLUDE ({ix.IncludeColumns})"); - if (!string.IsNullOrEmpty(ix.FilterDefinition)) - sb.AppendLine($"WHERE {ix.FilterDefinition}"); - if (withOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", withOptions)})"); - if (onPartition != null) - sb.AppendLine(onPartition); - TrimTrailingNewline(sb); - sb.AppendLine(";"); - } - - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes) - { - if (columns.Count == 0) - return $"-- No columns found for {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"CREATE TABLE {objectName}"); - sb.AppendLine("("); - - var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); - - for (int i = 0; i < columns.Count; i++) - { - var col = columns[i]; - var isLast = i == columns.Count - 1; - - sb.Append($" [{col.ColumnName}] "); - - if (col.IsComputed && col.ComputedDefinition != null) - { - sb.Append($"AS {col.ComputedDefinition}"); - } - else - { - sb.Append(col.DataType); - if (col.IsIdentity) - sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})"); - sb.Append(col.IsNullable ? " NULL" : " NOT NULL"); - if (col.DefaultValue != null) - sb.Append($" DEFAULT {col.DefaultValue}"); - } - - sb.AppendLine(!isLast || pkIndex != null ? "," : ""); - } - - if (pkIndex != null) - { - var clustered = IsClusteredType(pkIndex) ? "CLUSTERED " : "NONCLUSTERED "; - sb.AppendLine($" CONSTRAINT [{pkIndex.IndexName}]"); - sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})"); - var pkOptions = BuildWithOptions(pkIndex); - if (pkOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", pkOptions)})"); - } - sb.AppendLine(); - } - - sb.Append(")"); - - var clusteredIx = indexes.FirstOrDefault(ix => IsClusteredType(ix) && !IsColumnstore(ix)); - if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null) - { - sb.AppendLine(); - sb.Append($"ON [{clusteredIx.PartitionScheme}]([{clusteredIx.PartitionColumn}])"); - } - - sb.AppendLine(";"); - return sb.ToString(); - } - - private static bool IsClusteredType(IndexInfo ix) => - ix.IndexType.Contains("CLUSTERED", StringComparison.OrdinalIgnoreCase) - && !ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase); - - private static bool IsColumnstore(IndexInfo ix) => - ix.IndexType.Contains("COLUMNSTORE", StringComparison.OrdinalIgnoreCase); - - private static List BuildWithOptions(IndexInfo ix) - { - var options = new List(); - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - if (!ix.AllowRowLocks) - options.Add("ALLOW_ROW_LOCKS = OFF"); - if (!ix.AllowPageLocks) - options.Add("ALLOW_PAGE_LOCKS = OFF"); - if (!string.Equals(ix.DataCompression, "NONE", StringComparison.OrdinalIgnoreCase)) - options.Add($"DATA_COMPRESSION = {ix.DataCompression}"); - return options; - } - - private static List BuildColumnstoreWithOptions(IndexInfo ix) - { - var options = new List(); - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - return options; - } - - private static void TrimTrailingNewline(System.Text.StringBuilder sb) - { - if (sb.Length > 0 && sb[sb.Length - 1] == '\n') sb.Length--; - if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--; - } #endregion }