From ab65576ae3b5509c9ea8bd5a0874396d0b010f70 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:49:08 -0400 Subject: [PATCH 1/3] Strip AI-drafted wait-stat descriptions + legacy marker + remove Rule 21 (#215 D3, framework marker, Joe's categorization refinements) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3: WaitStatsKnowledge Description / HowToFix text was AI-drafted without expert review. Joe flagged the SOS_SCHEDULER_YIELD fix text as misleading and asked that creative copy come from SQL Server experts, not AI. Stripped all Description and HowToFix content; kept the file structure and the ShowEffectiveLatency flag (structural, not creative). Entries are still emitted but the warning message now shows only the wait name, observed ms and wait count, and — for PAGEIOLATCH_* — effective latency. Erik / Joe fill in content over time; the pipeline is ready to render it when they do. BenefitScorer.EmitWaitStatWarnings now omits the description separator and sets ActionableFix to null when the entry has no HowToFix, so the renderers don't print empty "Fix:" prefixes. Framework marker (option a, flipped): PlanWarning + WarningResult grow an IsLegacy bool. Joe's preference: tag old rules so new ones don't need re-tagging when the rest get migrated. A LegacyWarningTypes set in PlanAnalyzer lists rules that haven't been folded into the benefit-scoring framework yet; MarkLegacyWarnings post-pass sets IsLegacy = true on matches. Renderers (web strip, HTML export, Avalonia plan + node warnings, TextFormatter statement + grouped operator paths) all show a "legacy" badge or " [legacy]" tag when set. Rule 21 (CTE referenced multiple times) removed per Joe's feedback — actual plans already show where time goes; statement-text pattern matching on CTE references is guessing. Removed the rule, its regex, the rule map entry, and DetectMultiReferenceCte helper. TextFormatter operator-warning benefit % now also uses the N1-except-100 formatter so grouped operator output is consistent with the rest of the UI. Version bump 1.7.6 -> 1.7.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.axaml.cs | 7754 +++++++++-------- src/PlanViewer.App/PlanViewer.App.csproj | 2 +- src/PlanViewer.Core/Models/PlanModels.cs | 8 + src/PlanViewer.Core/Output/AnalysisResult.cs | 7 + src/PlanViewer.Core/Output/HtmlExporter.cs | 3 + src/PlanViewer.Core/Output/ResultMapper.cs | 6 +- src/PlanViewer.Core/Output/TextFormatter.cs | 21 +- src/PlanViewer.Core/Services/BenefitScorer.cs | 6 +- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 105 +- .../Services/WaitStatsKnowledge.cs | 282 +- src/PlanViewer.Web/Pages/Index.razor | 4 + src/PlanViewer.Web/wwwroot/css/app.css | 12 + 12 files changed, 4014 insertions(+), 4196 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 65b5b87..6eb93df 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -1,3876 +1,3878 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Shapes; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; -using Avalonia.Platform.Storage; -using AvaloniaEdit.TextMate; -using Microsoft.Data.SqlClient; -using PlanViewer.App.Dialogs; -using PlanViewer.Core.Interfaces; -using PlanViewer.App.Helpers; -using PlanViewer.App.Services; -using PlanViewer.App.Mcp; -using PlanViewer.Core.Models; -using PlanViewer.Core.Output; -using PlanViewer.Core.Services; - -using AvaloniaPath = Avalonia.Controls.Shapes.Path; - -namespace PlanViewer.App.Controls; - -public class StatementRow -{ - public int Index { get; set; } - public string QueryText { get; set; } = ""; - public string FullQueryText { get; set; } = ""; - public long CpuMs { get; set; } - public long ElapsedMs { get; set; } - public long UdfMs { get; set; } - public double EstCost { get; set; } - public int Critical { get; set; } - public int Warnings { get; set; } - public PlanStatement Statement { get; set; } = null!; - - // Display helpers - public string CpuDisplay => FormatDuration(CpuMs); - public string ElapsedDisplay => FormatDuration(ElapsedMs); - public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : ""; - public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : ""; - - private static string FormatDuration(long ms) - { - if (ms < 1000) return $"{ms}ms"; - if (ms < 60_000) return $"{ms / 1000.0:F1}s"; - return $"{ms / 60_000}m {(ms % 60_000) / 1000}s"; - } -} - -public partial class PlanViewerControl : UserControl -{ - private readonly string _mcpSessionId = Guid.NewGuid().ToString(); - private ParsedPlan? _currentPlan; - private PlanStatement? _currentStatement; - private string? _queryText; - private ServerMetadata? _serverMetadata; - private double _zoomLevel = 1.0; - private const double ZoomStep = 0.15; - private const double MinZoom = 0.1; - private const double MaxZoom = 3.0; - private string _label = ""; - - /// - /// Full path on disk when the plan was loaded from a file. - /// - public string? SourceFilePath { get; set; } - - // Node selection - private Border? _selectedNodeBorder; - private IBrush? _selectedNodeOriginalBorder; - private Thickness _selectedNodeOriginalThickness; - - // Border -> PlanNode mapping (replaces WPF Tag pattern) - private readonly Dictionary _nodeBorderMap = new(); - - // Brushes - private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23)); - private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45)); - private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); - private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35)); - private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed); - private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange); - - - // Track all property section grids for synchronized column resize - private readonly List _sectionLabelColumns = new(); - private double _propertyLabelWidth = 140; - private bool _isSyncingColumnWidth; - private Grid? _currentSectionGrid; - private int _currentSectionRowIndex; - - // Non-control named elements that Avalonia codegen doesn't auto-generate fields for - private readonly ColumnDefinition _statementsColumn; - private readonly ColumnDefinition _statementsSplitterColumn; - private readonly ColumnDefinition _splitterColumn; - private readonly ColumnDefinition _propertiesColumn; - private readonly ScaleTransform _zoomTransform; - - // Statement grid data - private List? _allStatements; - - // Pan state - private bool _isPanning; - private Point _panStart; - private double _panStartOffsetX; - private double _panStartOffsetY; - - public PlanViewerControl() - { - InitializeComponent(); - // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event - PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel); - // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events - PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); - PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel); - PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel); - - // Resolve non-control elements by traversal (Avalonia doesn't support x:Name on these types) - // The Grid in Row 4 has 5 ColumnDefinitions: - // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0) - var planGrid = (Grid)PlanScrollViewer.Parent!; - _statementsColumn = planGrid.ColumnDefinitions[0]; - _statementsSplitterColumn = planGrid.ColumnDefinitions[1]; - _splitterColumn = planGrid.ColumnDefinitions[3]; - _propertiesColumn = planGrid.ColumnDefinitions[4]; - - // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas - var layoutTransform = this.FindControl("PlanLayoutTransform")!; - _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; - - Helpers.DataGridBehaviors.Attach(StatementsGrid); - } - - /// - /// Exposes the raw XML so MainWindow can implement Save functionality. - /// - public string? RawXml => _currentPlan?.RawXml; - - /// - /// Exposes the parsed and analyzed plan for advice generation. - /// - public ParsedPlan? CurrentPlan => _currentPlan; - - /// - /// Exposes the query text associated with this plan (if any). - /// - public string? QueryText => _queryText; - - /// - /// Server metadata for advice generation and Plan Insights display. - /// - public ServerMetadata? Metadata - { - get => _serverMetadata; - set - { - _serverMetadata = value; - if (_currentStatement != null) - ShowServerContext(); - } - } - - /// - /// Connection string for schema lookups. Set when the plan was loaded from a connected session. - /// - public string? ConnectionString { get; set; } - - // Connection state for plans that connect via the toolbar - private ServerConnection? _planConnection; - private ICredentialService? _planCredentialService; - private ConnectionStore? _planConnectionStore; - private string? _planSelectedDatabase; - - /// - /// Provide credential service and connection store so the plan viewer can show a connection dialog. - /// - public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore) - { - _planCredentialService = credentialService; - _planConnectionStore = connectionStore; - } - - /// - /// Update the connection UI to reflect an active connection (used when connection is inherited). - /// - public void SetConnectionStatus(string serverName, string? database) - { - PlanServerLabel.Text = serverName; - PlanServerLabel.Foreground = Brushes.LimeGreen; - PlanConnectButton.Content = "Reconnect"; - if (database != null) - _planSelectedDatabase = database; - } - - // Events for MainWindow to wire up advice/repro actions - public event EventHandler? HumanAdviceRequested; - public event EventHandler? RobotAdviceRequested; - public event EventHandler? CopyReproRequested; - public event EventHandler? OpenInEditorRequested; - - /// - /// Navigates to a specific plan node by ID: selects it, zooms to show it, - /// and scrolls to center it in the viewport. - /// - public void NavigateToNode(int nodeId) - { - // Find the Border for this node - Border? targetBorder = null; - PlanNode? targetNode = null; - foreach (var (border, node) in _nodeBorderMap) - { - if (node.NodeId == nodeId) - { - targetBorder = border; - targetNode = node; - break; - } - } - - if (targetBorder == null || targetNode == null) - return; - - // Activate the parent window so the plan viewer becomes visible - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is Window parentWindow) - parentWindow.Activate(); - - // Select the node (highlights it and shows properties) - SelectNode(targetBorder, targetNode); - - // Ensure zoom level makes the node comfortably visible - var viewWidth = PlanScrollViewer.Bounds.Width; - var viewHeight = PlanScrollViewer.Bounds.Height; - if (viewWidth <= 0 || viewHeight <= 0) - return; - - // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode); - var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4)); - if (_zoomLevel < minVisibleZoom) - SetZoom(Math.Min(minVisibleZoom, 1.0)); - - // Scroll to center the node in the viewport - var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2; - var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2; - centerX = Math.Max(0, centerX); - centerY = Math.Max(0, centerY); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(centerX, centerY); - }); - } - - public void LoadPlan(string planXml, string label, string? queryText = null) - { - _label = label; - _queryText = queryText; - - // Query text stored for copy/repro but no longer shown in a - // separate expander — it's already visible in the Statements grid. - - _currentPlan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load()); - BenefitScorer.Score(_currentPlan); - - var allStatements = _currentPlan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .ToList(); - - if (allStatements.Count == 0) - { - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; - return; - } - - EmptyState.IsVisible = false; - PlanScrollViewer.IsVisible = true; - - // Always show statement grid — useful summary even for single-statement plans - _allStatements = allStatements; - PopulateStatementsGrid(allStatements); - ShowStatementsPanel(); - StatementsGrid.SelectedIndex = 0; - - // Register with MCP session manager for AI tool access - // Count warnings from both statement-level PlanWarnings and all node Warnings - int warningCount = 0, criticalCount = 0; - foreach (var s in allStatements) - { - warningCount += s.PlanWarnings.Count; - criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical); - if (s.RootNode != null) - CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); - } - - PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession - { - SessionId = _mcpSessionId, - Label = label, - Source = "file", - Plan = _currentPlan, - QueryText = queryText, - StatementCount = allStatements.Count, - HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), - WarningCount = warningCount, - CriticalWarningCount = criticalCount, - MissingIndexCount = _currentPlan.AllMissingIndexes.Count - }); - } - - public void Clear() - { - PlanSessionManager.Instance.Unregister(_mcpSessionId); - PlanCanvas.Children.Clear(); - _nodeBorderMap.Clear(); - _currentPlan = null; - _currentStatement = null; - _queryText = null; - _selectedNodeBorder = null; - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; - InsightsPanel.IsVisible = false; - CostText.Text = ""; - CloseStatementsPanel(); - StatementsButton.IsVisible = false; - StatementsButtonSeparator.IsVisible = false; - ClosePropertiesPanel(); - } - - 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; - - 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) - RenderEdges(statement.RootNode); - - // Render nodes — pass total warning count to root node for badge - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, 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 = ""; - } - - #region Node Rendering - - private void RenderNodes(PlanNode node, int totalWarningCount = -1) - { - var visual = CreateNodeVisual(node, totalWarningCount); - Canvas.SetLeft(visual, node.X); - Canvas.SetTop(visual, node.Y); - PlanCanvas.Children.Add(visual); - - foreach (var child in node.Children) - RenderNodes(child); - } - - private Border CreateNodeVisual(PlanNode node, 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 10x+ - var estRows = node.EstimateRows; - var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); - IBrush rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? 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) - { - foreach (var child in node.Children) - { - var path = CreateElbowConnector(node, child); - PlanCanvas.Children.Add(path); - - RenderEdges(child); - } - } - - private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) - { - 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 path = new AvaloniaPath - { - Data = geometry, - Stroke = EdgeBrush, - 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}"; - - #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); - - ShowPropertiesPanel(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 planWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}"; - 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 nodeWarnHeader = w.MaxBenefitPercent.HasValue - ? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit" - : $"\u26A0 {w.WarningType}"; - 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"; - - // Runtime stats (actual plans) - if (statement.QueryTimeStats != null) - { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - 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 time — plan-level property (category B). Show regardless of - // threshold so it's always visible, not just when Rule 19 fires. - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); - - // Memory grant — color by utilization percentage - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = EfficiencyColor(grantPct); - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); - } - - // DOP + parallelism efficiency — color by 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) - { - // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. - // Subtract external/preemptive wait time from CPU — those waits are CPU-busy - // in kernel and inflate the ratio without representing real query work. - 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); - - // Thread stats — color by utilization - 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"); - } - } - - // CE model - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - // Compile stats (always available) - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Optimization level - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - - 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)}%"; - } - - /// - /// 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); - }); - } - - 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); - }); - - 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 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 - - private async void PlanConnect_Click(object? sender, RoutedEventArgs e) - { - if (_planCredentialService == null || _planConnectionStore == null) return; - - var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore); - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is not Window parentWindow) return; - - var result = await dialog.ShowDialog(parentWindow); - if (result != true || dialog.ResultConnection == null) return; - - _planConnection = dialog.ResultConnection; - _planSelectedDatabase = dialog.ResultDatabase; - ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); - - PlanServerLabel.Text = _planConnection.ServerName; - PlanServerLabel.Foreground = Brushes.LimeGreen; - PlanConnectButton.Content = "Reconnect"; - - // Populate database dropdown - try - { - var connStr = _planConnection.GetConnectionString(_planCredentialService, "master"); - await using var conn = new SqlConnection(connStr); - await conn.OpenAsync(); - - var databases = new List(); - using var cmd = new SqlCommand( - "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - databases.Add(reader.GetString(0)); - - PlanDatabaseBox.ItemsSource = databases; - PlanDatabaseBox.IsEnabled = true; - - if (_planSelectedDatabase != null) - { - for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) - { - if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) - { - PlanDatabaseBox.SelectedIndex = i; - break; - } - } - } - } - catch - { - PlanDatabaseBox.IsEnabled = false; - } - } - - private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return; - - _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString(); - ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); - } - - #endregion - - #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 -} - -/// Sort DataGrid column by a long property on StatementRow. -public class LongComparer : System.Collections.IComparer -{ - private readonly Func _selector; - public LongComparer(Func selector) => _selector = selector; - public int Compare(object? x, object? y) - { - if (x is StatementRow a && y is StatementRow b) - return _selector(a).CompareTo(_selector(b)); - return 0; - } -} - -/// Sort DataGrid column by a double property on StatementRow. -public class DoubleComparer : System.Collections.IComparer -{ - private readonly Func _selector; - public DoubleComparer(Func selector) => _selector = selector; - public int Compare(object? x, object? y) - { - if (x is StatementRow a && y is StatementRow b) - return _selector(a).CompareTo(_selector(b)); - return 0; - } -} +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Platform.Storage; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.Core.Interfaces; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.App.Mcp; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public class StatementRow +{ + public int Index { get; set; } + public string QueryText { get; set; } = ""; + public string FullQueryText { get; set; } = ""; + public long CpuMs { get; set; } + public long ElapsedMs { get; set; } + public long UdfMs { get; set; } + public double EstCost { get; set; } + public int Critical { get; set; } + public int Warnings { get; set; } + public PlanStatement Statement { get; set; } = null!; + + // Display helpers + public string CpuDisplay => FormatDuration(CpuMs); + public string ElapsedDisplay => FormatDuration(ElapsedMs); + public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : ""; + public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : ""; + + private static string FormatDuration(long ms) + { + if (ms < 1000) return $"{ms}ms"; + if (ms < 60_000) return $"{ms / 1000.0:F1}s"; + return $"{ms / 60_000}m {(ms % 60_000) / 1000}s"; + } +} + +public partial class PlanViewerControl : UserControl +{ + private readonly string _mcpSessionId = Guid.NewGuid().ToString(); + private ParsedPlan? _currentPlan; + private PlanStatement? _currentStatement; + private string? _queryText; + private ServerMetadata? _serverMetadata; + private double _zoomLevel = 1.0; + private const double ZoomStep = 0.15; + private const double MinZoom = 0.1; + private const double MaxZoom = 3.0; + private string _label = ""; + + /// + /// Full path on disk when the plan was loaded from a file. + /// + public string? SourceFilePath { get; set; } + + // Node selection + private Border? _selectedNodeBorder; + private IBrush? _selectedNodeOriginalBorder; + private Thickness _selectedNodeOriginalThickness; + + // Border -> PlanNode mapping (replaces WPF Tag pattern) + private readonly Dictionary _nodeBorderMap = new(); + + // Brushes + private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23)); + private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45)); + private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); + private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35)); + private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed); + private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange); + + + // Track all property section grids for synchronized column resize + private readonly List _sectionLabelColumns = new(); + private double _propertyLabelWidth = 140; + private bool _isSyncingColumnWidth; + private Grid? _currentSectionGrid; + private int _currentSectionRowIndex; + + // Non-control named elements that Avalonia codegen doesn't auto-generate fields for + private readonly ColumnDefinition _statementsColumn; + private readonly ColumnDefinition _statementsSplitterColumn; + private readonly ColumnDefinition _splitterColumn; + private readonly ColumnDefinition _propertiesColumn; + private readonly ScaleTransform _zoomTransform; + + // Statement grid data + private List? _allStatements; + + // Pan state + private bool _isPanning; + private Point _panStart; + private double _panStartOffsetX; + private double _panStartOffsetY; + + public PlanViewerControl() + { + InitializeComponent(); + // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event + PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel); + // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events + PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); + PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel); + PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel); + + // Resolve non-control elements by traversal (Avalonia doesn't support x:Name on these types) + // The Grid in Row 4 has 5 ColumnDefinitions: + // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0) + var planGrid = (Grid)PlanScrollViewer.Parent!; + _statementsColumn = planGrid.ColumnDefinitions[0]; + _statementsSplitterColumn = planGrid.ColumnDefinitions[1]; + _splitterColumn = planGrid.ColumnDefinitions[3]; + _propertiesColumn = planGrid.ColumnDefinitions[4]; + + // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas + var layoutTransform = this.FindControl("PlanLayoutTransform")!; + _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; + + Helpers.DataGridBehaviors.Attach(StatementsGrid); + } + + /// + /// Exposes the raw XML so MainWindow can implement Save functionality. + /// + public string? RawXml => _currentPlan?.RawXml; + + /// + /// Exposes the parsed and analyzed plan for advice generation. + /// + public ParsedPlan? CurrentPlan => _currentPlan; + + /// + /// Exposes the query text associated with this plan (if any). + /// + public string? QueryText => _queryText; + + /// + /// Server metadata for advice generation and Plan Insights display. + /// + public ServerMetadata? Metadata + { + get => _serverMetadata; + set + { + _serverMetadata = value; + if (_currentStatement != null) + ShowServerContext(); + } + } + + /// + /// Connection string for schema lookups. Set when the plan was loaded from a connected session. + /// + public string? ConnectionString { get; set; } + + // Connection state for plans that connect via the toolbar + private ServerConnection? _planConnection; + private ICredentialService? _planCredentialService; + private ConnectionStore? _planConnectionStore; + private string? _planSelectedDatabase; + + /// + /// Provide credential service and connection store so the plan viewer can show a connection dialog. + /// + public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore) + { + _planCredentialService = credentialService; + _planConnectionStore = connectionStore; + } + + /// + /// Update the connection UI to reflect an active connection (used when connection is inherited). + /// + public void SetConnectionStatus(string serverName, string? database) + { + PlanServerLabel.Text = serverName; + PlanServerLabel.Foreground = Brushes.LimeGreen; + PlanConnectButton.Content = "Reconnect"; + if (database != null) + _planSelectedDatabase = database; + } + + // Events for MainWindow to wire up advice/repro actions + public event EventHandler? HumanAdviceRequested; + public event EventHandler? RobotAdviceRequested; + public event EventHandler? CopyReproRequested; + public event EventHandler? OpenInEditorRequested; + + /// + /// Navigates to a specific plan node by ID: selects it, zooms to show it, + /// and scrolls to center it in the viewport. + /// + public void NavigateToNode(int nodeId) + { + // Find the Border for this node + Border? targetBorder = null; + PlanNode? targetNode = null; + foreach (var (border, node) in _nodeBorderMap) + { + if (node.NodeId == nodeId) + { + targetBorder = border; + targetNode = node; + break; + } + } + + if (targetBorder == null || targetNode == null) + return; + + // Activate the parent window so the plan viewer becomes visible + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is Window parentWindow) + parentWindow.Activate(); + + // Select the node (highlights it and shows properties) + SelectNode(targetBorder, targetNode); + + // Ensure zoom level makes the node comfortably visible + var viewWidth = PlanScrollViewer.Bounds.Width; + var viewHeight = PlanScrollViewer.Bounds.Height; + if (viewWidth <= 0 || viewHeight <= 0) + return; + + // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode); + var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4)); + if (_zoomLevel < minVisibleZoom) + SetZoom(Math.Min(minVisibleZoom, 1.0)); + + // Scroll to center the node in the viewport + var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2; + var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2; + centerX = Math.Max(0, centerX); + centerY = Math.Max(0, centerY); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(centerX, centerY); + }); + } + + public void LoadPlan(string planXml, string label, string? queryText = null) + { + _label = label; + _queryText = queryText; + + // Query text stored for copy/repro but no longer shown in a + // separate expander — it's already visible in the Statements grid. + + _currentPlan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load()); + BenefitScorer.Score(_currentPlan); + + var allStatements = _currentPlan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .ToList(); + + if (allStatements.Count == 0) + { + EmptyState.IsVisible = true; + PlanScrollViewer.IsVisible = false; + return; + } + + EmptyState.IsVisible = false; + PlanScrollViewer.IsVisible = true; + + // Always show statement grid — useful summary even for single-statement plans + _allStatements = allStatements; + PopulateStatementsGrid(allStatements); + ShowStatementsPanel(); + StatementsGrid.SelectedIndex = 0; + + // Register with MCP session manager for AI tool access + // Count warnings from both statement-level PlanWarnings and all node Warnings + int warningCount = 0, criticalCount = 0; + foreach (var s in allStatements) + { + warningCount += s.PlanWarnings.Count; + criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical); + if (s.RootNode != null) + CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); + } + + PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession + { + SessionId = _mcpSessionId, + Label = label, + Source = "file", + Plan = _currentPlan, + QueryText = queryText, + StatementCount = allStatements.Count, + HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), + WarningCount = warningCount, + CriticalWarningCount = criticalCount, + MissingIndexCount = _currentPlan.AllMissingIndexes.Count + }); + } + + public void Clear() + { + PlanSessionManager.Instance.Unregister(_mcpSessionId); + PlanCanvas.Children.Clear(); + _nodeBorderMap.Clear(); + _currentPlan = null; + _currentStatement = null; + _queryText = null; + _selectedNodeBorder = null; + EmptyState.IsVisible = true; + PlanScrollViewer.IsVisible = false; + InsightsPanel.IsVisible = false; + CostText.Text = ""; + CloseStatementsPanel(); + StatementsButton.IsVisible = false; + StatementsButtonSeparator.IsVisible = false; + ClosePropertiesPanel(); + } + + 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; + + 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) + RenderEdges(statement.RootNode); + + // Render nodes — pass total warning count to root node for badge + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, 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 = ""; + } + + #region Node Rendering + + private void RenderNodes(PlanNode node, int totalWarningCount = -1) + { + var visual = CreateNodeVisual(node, totalWarningCount); + Canvas.SetLeft(visual, node.X); + Canvas.SetTop(visual, node.Y); + PlanCanvas.Children.Add(visual); + + foreach (var child in node.Children) + RenderNodes(child); + } + + private Border CreateNodeVisual(PlanNode node, 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 10x+ + var estRows = node.EstimateRows; + var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + IBrush rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? 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) + { + foreach (var child in node.Children) + { + var path = CreateElbowConnector(node, child); + PlanCanvas.Children.Add(path); + + RenderEdges(child); + } + } + + private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) + { + 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 path = new AvaloniaPath + { + Data = geometry, + Stroke = EdgeBrush, + 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}"; + + #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); + + ShowPropertiesPanel(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"; + + // Runtime stats (actual plans) + if (statement.QueryTimeStats != null) + { + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + 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 time — plan-level property (category B). Show regardless of + // threshold so it's always visible, not just when Rule 19 fires. + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + + // Memory grant — color by utilization percentage + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = EfficiencyColor(grantPct); + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // DOP + parallelism efficiency — color by 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) + { + // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. + // Subtract external/preemptive wait time from CPU — those waits are CPU-busy + // in kernel and inflate the ratio without representing real query work. + 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); + + // Thread stats — color by utilization + 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"); + } + } + + // CE model + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + // Compile stats (always available) + if (statement.CompileTimeMs > 0) + AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Optimization level + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + + 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)}%"; + } + + /// + /// 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); + }); + } + + 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); + }); + + 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 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 + + private async void PlanConnect_Click(object? sender, RoutedEventArgs e) + { + if (_planCredentialService == null || _planConnectionStore == null) return; + + var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore); + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is not Window parentWindow) return; + + var result = await dialog.ShowDialog(parentWindow); + if (result != true || dialog.ResultConnection == null) return; + + _planConnection = dialog.ResultConnection; + _planSelectedDatabase = dialog.ResultDatabase; + ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); + + PlanServerLabel.Text = _planConnection.ServerName; + PlanServerLabel.Foreground = Brushes.LimeGreen; + PlanConnectButton.Content = "Reconnect"; + + // Populate database dropdown + try + { + var connStr = _planConnection.GetConnectionString(_planCredentialService, "master"); + await using var conn = new SqlConnection(connStr); + await conn.OpenAsync(); + + var databases = new List(); + using var cmd = new SqlCommand( + "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + databases.Add(reader.GetString(0)); + + PlanDatabaseBox.ItemsSource = databases; + PlanDatabaseBox.IsEnabled = true; + + if (_planSelectedDatabase != null) + { + for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) + { + if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) + { + PlanDatabaseBox.SelectedIndex = i; + break; + } + } + } + } + catch + { + PlanDatabaseBox.IsEnabled = false; + } + } + + private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return; + + _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString(); + ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); + } + + #endregion + + #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 +} + +/// Sort DataGrid column by a long property on StatementRow. +public class LongComparer : System.Collections.IComparer +{ + private readonly Func _selector; + public LongComparer(Func selector) => _selector = selector; + public int Compare(object? x, object? y) + { + if (x is StatementRow a && y is StatementRow b) + return _selector(a).CompareTo(_selector(b)); + return 0; + } +} + +/// Sort DataGrid column by a double property on StatementRow. +public class DoubleComparer : System.Collections.IComparer +{ + private readonly Func _selector; + public DoubleComparer(Func selector) => _selector = selector; + public int Compare(object? x, object? y) + { + if (x is StatementRow a && y is StatementRow b) + return _selector(a).CompareTo(_selector(b)); + return 0; + } +} diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index cacf6da..6aa36a1 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -6,7 +6,7 @@ app.manifest EDD.ico true - 1.7.6 + 1.7.7 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index c839e35..7663b2e 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -385,6 +385,14 @@ public class PlanWarning /// Short actionable fix suggestion (e.g., "Add INCLUDE (columns) to index"). /// public string? ActionableFix { get; set; } + + /// + /// True for rules that pre-date the benefit-scoring framework (#215) and haven't + /// been folded into A/B/C/D categorization yet. Joe wanted these visibly marked so + /// reviewers know which items to hold to a higher bar vs which are known-legacy. + /// Renderers show a "legacy" badge when true. + /// + public bool IsLegacy { get; set; } } public enum PlanWarningSeverity { Info, Warning, Critical } diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index 20f83d7..c9c1a26 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -226,6 +226,13 @@ public class WarningResult [JsonPropertyName("actionable_fix")] public string? ActionableFix { get; set; } + + /// + /// True for rules predating the benefit-scoring framework. Renderers show a + /// "legacy" badge to distinguish from new-framework warnings. + /// + [JsonPropertyName("is_legacy")] + public bool IsLegacy { get; set; } } public class MissingIndexResult diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index b38f131..73e5950 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -187,6 +187,7 @@ .card h3 { .warn-type { font-size: 0.75rem; font-weight: 600; } .warn-benefit { font-size: 0.7rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.04); } .warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; } +.warn-legacy { font-size: 0.65rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.08); text-transform: uppercase; letter-spacing: 0.05em; } .warn-fix { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; flex-basis: 100%; border-left: 2px solid var(--border); padding-left: 0.5rem; margin-top: 0.15rem; } /* Query text */ @@ -460,6 +461,8 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt) if (w.Operator != null) sb.AppendLine($"{Encode(w.Operator)}"); sb.AppendLine($"{Encode(w.Type)}"); + if (w.IsLegacy) + sb.AppendLine("legacy"); if (w.MaxBenefitPercent.HasValue) sb.AppendLine($"up to {(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))}% benefit"); sb.AppendLine($"{Encode(w.Message)}"); diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index 35b0fca..f73dd06 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -180,7 +180,8 @@ private static StatementResult MapStatement(PlanStatement stmt) Severity = w.Severity.ToString(), Message = w.Message, MaxBenefitPercent = w.MaxBenefitPercent, - ActionableFix = w.ActionableFix + ActionableFix = w.ActionableFix, + IsLegacy = w.IsLegacy }); } @@ -283,7 +284,8 @@ private static OperatorResult MapNode(PlanNode node) Operator = FormatOperatorLabel(node), NodeId = node.NodeId, MaxBenefitPercent = w.MaxBenefitPercent, - ActionableFix = w.ActionableFix + ActionableFix = w.ActionableFix, + IsLegacy = w.IsLegacy }); } diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index 954af41..bb31488 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -171,7 +171,8 @@ public static void WriteText(AnalysisResult result, TextWriter writer) var benefitTag = w.MaxBenefitPercent.HasValue ? $" (up to {(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))}% benefit)" : ""; - writer.WriteLine($" [{w.Severity}] {w.Type}{benefitTag}: {EscapeNewlines(w.Message)}"); + var legacyTag = w.IsLegacy ? " [legacy]" : ""; + writer.WriteLine($" [{w.Severity}] {w.Type}{legacyTag}{benefitTag}: {EscapeNewlines(w.Message)}"); if (!string.IsNullOrEmpty(w.ActionableFix)) writer.WriteLine($" Fix: {EscapeNewlines(w.ActionableFix)}"); } @@ -298,7 +299,7 @@ private static void WriteGroupedOperatorWarnings(List warnings, T // Split each message into "data | explanation" at the last sentence boundary // that starts with "The " (the harm assessment). Group by shared explanation. - var entries = new List<(string Severity, string Operator, string Data, string? Explanation, double? Benefit)>(); + var entries = new List<(string Severity, string Operator, string Data, string? Explanation, double? Benefit, bool IsLegacy)>(); foreach (var w in sorted) { var msg = w.Message; @@ -317,7 +318,7 @@ private static void WriteGroupedOperatorWarnings(List warnings, T data = msg; } - entries.Add((w.Severity, w.Operator ?? "?", data, explanation, w.MaxBenefitPercent)); + entries.Add((w.Severity, w.Operator ?? "?", data, explanation, w.MaxBenefitPercent, w.IsLegacy)); } // Group entries that share the same severity, type, and explanation @@ -334,8 +335,11 @@ private static void WriteGroupedOperatorWarnings(List warnings, T // Multiple operators with the same explanation — list compactly foreach (var item in items) { - var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : ""; - writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(item.Data)}"); + var legacyTag = item.IsLegacy ? " [legacy]" : ""; + var benefitTag = item.Benefit.HasValue + ? $" (up to {(item.Benefit.Value >= 100 ? item.Benefit.Value.ToString("N0") : item.Benefit.Value.ToString("N1"))}% benefit)" + : ""; + writer.WriteLine($" [{item.Severity}] {item.Operator}{legacyTag}{benefitTag}: {EscapeNewlines(item.Data)}"); } writer.WriteLine($" -> {group.Key.Item2}"); } @@ -345,8 +349,11 @@ private static void WriteGroupedOperatorWarnings(List warnings, T foreach (var item in items) { var full = item.Explanation != null ? $"{item.Data}. {item.Explanation}" : item.Data; - var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : ""; - writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(full)}"); + var legacyTag = item.IsLegacy ? " [legacy]" : ""; + var benefitTag = item.Benefit.HasValue + ? $" (up to {(item.Benefit.Value >= 100 ? item.Benefit.Value.ToString("N0") : item.Benefit.Value.ToString("N1"))}% benefit)" + : ""; + writer.WriteLine($" [{item.Severity}] {item.Operator}{legacyTag}{benefitTag}: {EscapeNewlines(full)}"); } } } diff --git a/src/PlanViewer.Core/Services/BenefitScorer.cs b/src/PlanViewer.Core/Services/BenefitScorer.cs index c6cc842..f2c96a3 100644 --- a/src/PlanViewer.Core/Services/BenefitScorer.cs +++ b/src/PlanViewer.Core/Services/BenefitScorer.cs @@ -65,7 +65,9 @@ private static void EmitWaitStatWarnings(PlanStatement stmt) double? benefitPct = benefitByType.TryGetValue(wait.WaitType, out var b) ? b : null; var msg = new System.Text.StringBuilder(); - msg.Append(wait.WaitType).Append(": ").Append(entry.Description); + msg.Append(wait.WaitType); + if (!string.IsNullOrEmpty(entry.Description)) + msg.Append(": ").Append(entry.Description); msg.Append(" Observed ").Append(wait.WaitTimeMs.ToString("N0")).Append(" ms"); if (wait.WaitCount > 0) msg.Append(" across ").Append(wait.WaitCount.ToString("N0")).Append(" wait").Append(wait.WaitCount == 1 ? "" : "s"); @@ -92,7 +94,7 @@ private static void EmitWaitStatWarnings(PlanStatement stmt) Message = msg.ToString(), Severity = severity, MaxBenefitPercent = benefitPct, - ActionableFix = entry.HowToFix + ActionableFix = string.IsNullOrEmpty(entry.HowToFix) ? null : entry.HowToFix }); } } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 653b224..dd5739f 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -24,11 +24,6 @@ public static class PlanAnalyzer @"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = new( - @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null) { var cfg = config ?? AnalyzerConfig.Default; @@ -40,6 +35,8 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null) if (stmt.RootNode != null) AnalyzeNodeTree(stmt.RootNode, stmt, cfg); + + MarkLegacyWarnings(stmt); } } @@ -48,6 +45,59 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null) ApplySeverityOverrides(plan, cfg); } + /// + /// Rule types that predate the benefit-scoring framework (#215) and haven't + /// been folded into A/B/C/D categorization yet. Tagged so reviewers can hold + /// new-framework items to a higher bar vs known-legacy items that will be + /// reworked later. Remove entries from this set as rules migrate. + /// + private static readonly HashSet LegacyWarningTypes = new(StringComparer.OrdinalIgnoreCase) + { + "Excessive Memory Grant", + "Large Memory Grant", + "Compile Memory Exceeded", + "Local Variables", + "Optimize For Unknown", + "Low Impact Index", + "Wide Index Suggestion", + "Duplicate Index Suggestions", + "Table Variable", + "Scalar UDF", + "Parallel Skew", + "Estimated Plan CE Guess", + "Data Type Mismatch", + "Lazy Spool Ineffective", + "Join OR Clause", + "Many-to-Many Merge Join", + "Table-Valued Function", + "Top Above Scan", + "Row Goal", + "NOT IN with Nullable Column", + "Implicit Conversion", + }; + + private static void MarkLegacyWarnings(PlanStatement stmt) + { + foreach (var w in stmt.PlanWarnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + if (stmt.RootNode != null) + MarkLegacyWarningsOnTree(stmt.RootNode); + } + + private static void MarkLegacyWarningsOnTree(PlanNode node) + { + foreach (var w in node.Warnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + foreach (var child in node.Children) + MarkLegacyWarningsOnTree(child); + } + // Rule number → WarningType mapping for severity overrides private static readonly Dictionary RuleWarningTypes = new() { @@ -58,7 +108,7 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null) [13] = "Data Type Mismatch", [14] = "Lazy Spool Ineffective", [15] = "Join OR Clause", [16] = "Nested Loops High Executions", [17] = "Many-to-Many Merge Join", [18] = "Compile Memory Exceeded", [19] = "High Compile CPU", [20] = "Local Variables", - [21] = "CTE Multiple References", [22] = "Table Variable", [23] = "Table-Valued Function", + [22] = "Table Variable", [23] = "Table-Valued Function", [24] = "Top Above Scan", [25] = "Ineffective Parallelism", [26] = "Row Goal", [27] = "Optimize For Unknown", [28] = "NOT IN with Nullable Column", [29] = "Implicit Conversion", [30] = "Wide Index Suggestion", @@ -367,11 +417,9 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) } } - // Rule 21: CTE referenced multiple times - if (!cfg.IsRuleDisabled(21) && !string.IsNullOrEmpty(stmt.StatementText)) - { - DetectMultiReferenceCte(stmt); - } + // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback: + // for actual plans, SQL Server runtime stats show exactly where time was + // spent, so a statement-text-pattern warning about CTE reuse is guessing. // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) && @@ -1445,41 +1493,6 @@ private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); } - /// - /// Detects CTEs that are referenced more than once in the statement text. - /// Each reference re-executes the CTE since SQL Server does not materialize them. - /// - private static void DetectMultiReferenceCte(PlanStatement stmt) - { - var text = stmt.StatementText; - var cteMatches = CteDefinitionRegex.Matches(text); - if (cteMatches.Count == 0) - return; - - foreach (Match match in cteMatches) - { - var cteName = match.Groups[1].Value; - if (string.IsNullOrEmpty(cteName)) - continue; - - // Count references as FROM/JOIN targets after the CTE definition - var refPattern = new Regex( - $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", - RegexOptions.IgnoreCase); - var refCount = refPattern.Matches(text).Count; - - if (refCount > 1) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "CTE Multiple References", - Message = $"CTE \"{cteName}\" is referenced {refCount} times. SQL Server re-executes the entire CTE each time — it does not materialize the results. Materialize into a #temp table instead.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - /// /// Verifies the OR expansion chain walking up from a Concatenation node: /// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation diff --git a/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs b/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs index 1b964c3..eb6343d 100644 --- a/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs +++ b/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs @@ -4,10 +4,16 @@ namespace PlanViewer.Core.Services; /// -/// Per-wait-type knowledge used when surfacing wait stats as warnings: -/// what the wait means, how to address it, and any per-wait display hints. -/// Entries are looked up by exact wait type first, then by prefix/family fallback, -/// and finally a generic default. +/// Per-wait-type knowledge used when surfacing wait stats as warnings. +/// +/// CONTENT STATUS: descriptions and fix text are intentionally empty. The prior +/// copy was AI-drafted without expert review and Joe Obbish flagged some of it +/// as misleading (#215 D3). Entries are kept so the rendering pipeline keeps +/// emitting warnings with names, benefit %, and effective latency, but without +/// speculative advice until Erik / Joe fill in content. +/// +/// ShowEffectiveLatency flags stay because they're structural (emit a +/// wait_ms / wait_count statistic), not creative advice. /// public static class WaitStatsKnowledge { @@ -27,220 +33,22 @@ public sealed class Entry public bool ShowEffectiveLatency { get; init; } } - private static readonly Entry Default = new() - { - Description = "Query time was spent waiting on this resource.", - HowToFix = "Investigate why this wait is elevated for this query — the wait type name is the best starting point." - }; + private static readonly Entry Default = new(); - // Exact-match lookup. Prefix fallbacks handled in Lookup(). + // Structural flags only (effective-latency display). Description/HowToFix pending + // expert-written content — see file-level comment. private static readonly Dictionary Exact = new(StringComparer.OrdinalIgnoreCase) { - // ---- I/O ---- - ["PAGEIOLATCH_SH"] = new() - { - Description = "Waiting to read a data page from disk into the buffer pool (shared latch for a reader).", - HowToFix = "Reduce physical reads: add or redesign indexes so fewer pages are touched, fix cardinality estimates that cause over-scanning, or move the workload to faster storage. Check whether the buffer pool is under memory pressure and evicting pages that should stay hot.", - ShowEffectiveLatency = true - }, - ["PAGEIOLATCH_EX"] = new() - { - Description = "Waiting to read a data page from disk into the buffer pool for modification (exclusive latch for a writer).", - HowToFix = "Same levers as PAGEIOLATCH_SH — reduce the number of pages the write path touches, fix bad estimates that fan writes out more than they should, and check for buffer pool pressure.", - ShowEffectiveLatency = true - }, - ["PAGEIOLATCH_UP"] = new() - { - Description = "Waiting to read a data page from disk into the buffer pool for an update latch (typically an index maintenance/update path).", - HowToFix = "Same levers as PAGEIOLATCH_SH/EX. A consistently elevated _UP latch often points at update-in-place paths on heavily fragmented tables or ascending-key hot spots.", - ShowEffectiveLatency = true - }, - ["PAGEIOLATCH_DT"] = new() - { - Description = "Waiting to read a data page from disk with a destroy latch — nearly always a tempdb allocation or deallocation path.", - HowToFix = "Look at tempdb activity in this plan: spills, spools, large sorts, and hash operations. Reducing the size/count of tempdb trips (fix estimates, tighter memory grants, smaller intermediate result sets) is usually more effective than tempdb file tuning for query-level waits.", - ShowEffectiveLatency = true - }, - ["WRITELOG"] = new() - { - Description = "Waiting for the log writer to harden transaction log records to disk.", - HowToFix = "Faster log storage (lower write latency) is the direct lever. Also look at batching small transactions, avoiding row-at-a-time modifications, and checking whether the log file is growing during the query (preallocate it)." - }, - ["IO_COMPLETION"] = new() - { - Description = "Waiting for non-data-page I/O (log reads, backup I/O, tempdb sort spills, merge join spools, etc.) to complete.", - HowToFix = "Identify what's doing the non-data I/O — for queries, spills and sort/merge workfiles are the usual culprits. Fix cardinality estimates that cause undersized memory grants and avoid sorts you don't need." - }, - ["ASYNC_IO_COMPLETION"] = new() - { - Description = "Waiting for asynchronous I/O to complete (backup, file growth, read-ahead).", - HowToFix = "If this shows up on a query plan it usually indicates a read-ahead starvation pattern or file growth happening mid-query. Pre-grow files, and verify storage is keeping up with read-ahead prefetching." - }, - ["LOGBUFFER"] = new() - { - Description = "Waiting for space in the in-memory log buffer before log records can be written.", - HowToFix = "The log writer can't harden records fast enough to free buffer space. Faster log storage is the primary fix. Also reduce log volume generated by this query (fewer rows modified per statement, no unnecessary triggers)." - }, - - // ---- Memory ---- - ["MEMORY_ALLOCATION_EXT"] = new() - { - Description = "Waiting on the memory allocator while granting or extending a memory grant — generally a sign of memory pressure inside the server.", - HowToFix = "Right-size memory grants so queries don't over-request. Look for cardinality misestimates that cause inflated grants. At the server level: check for too-generous Resource Governor settings, competing workloads soaking up the buffer pool, and whether max server memory is set sensibly." - }, - ["RESOURCE_SEMAPHORE"] = new() - { - Description = "Waiting for a memory grant before execution can begin — the memory grant queue is backed up.", - HowToFix = "Shrink the grant this query asks for (fix the estimates that inflate it) and/or reduce concurrent queries contending for memory. A plan that wants a multi-gigabyte grant for a small result set is almost always fixable by improving cardinality estimates." - }, - ["RESOURCE_SEMAPHORE_QUERY_COMPILE"] = new() - { - Description = "Waiting for memory to compile the query plan — compile memory is a separately throttled pool.", - HowToFix = "Large, auto-generated, or deeply nested queries compile-memory starve. Parameterize and cache plans where possible, split monster queries, and avoid compile-on-every-execution patterns (OPTION RECOMPILE on hot paths, non-parameterized ad hoc SQL)." - }, - ["SOS_PHYS_PAGE_CACHE"] = new() - { - Description = "Waiting on SQL Server's internal physical page cache (large-page allocator on Linux/containerized hosts).", - HowToFix = "This is usually symptomatic of broader memory pressure on the host rather than a query-level issue. Check host memory configuration and whether large pages are enabled correctly." - }, - - // ---- CPU / scheduler ---- - ["SOS_SCHEDULER_YIELD"] = new() - { - Description = "Yielded CPU voluntarily after running for its scheduler quantum — the query was CPU-bound and took turns with other runnable tasks.", - HowToFix = "This means the query is CPU-heavy and had runnable peers, not that anything is blocking. Reduce CPU work per row (remove scalar UDFs, simplify expressions, fix plans doing excessive row-by-row work) or give the query more concurrency headroom (higher DOP if it benefits, lower concurrency from competing queries)." - }, - ["THREADPOOL"] = new() - { - Description = "Waiting for a worker thread — the server's worker pool is exhausted.", - HowToFix = "Almost never a single-query problem: something else on the server is eating workers (blocked sessions, too-many-parallel-queries, runaway parallelism). Check the overall workload. If this wait appears for a query, it's a symptom of the environment, not the plan." - }, - ["DISPATCHER_QUEUE_SEMAPHORE"] = new() - { - Description = "Waiting for a dispatcher thread (background task scheduling).", - HowToFix = "Typically benign/background and not a query tuning signal. Investigate only if it dominates elapsed time, which usually points at broader server stress." - }, - - // ---- Parallelism ---- - ["CXPACKET"] = new() - { - Description = "Waiting inside a parallel exchange — producer threads waiting for consumers or vice-versa.", - HowToFix = "CXPACKET alone isn't a problem; the question is why threads are blocked. Look at what other waits are present on the same operators (I/O, lock, latch) — those are the real cause. Parallel skew from bad cardinality estimates or uneven data distribution also shows up here." - }, - ["CXCONSUMER"] = new() - { - Description = "A consumer thread in a parallel exchange is waiting for a producer to deliver rows — generally benign on its own.", - HowToFix = "CXCONSUMER is usually a mirror of other waits happening upstream of the exchange. Focus on the producing side of the plan (scans, joins feeding the exchange) rather than CXCONSUMER itself." - }, - ["CXSYNC_PORT"] = new() - { - Description = "Waiting on a parallel exchange port synchronization — threads coordinating at an exchange operator.", - HowToFix = "Another form of parallel coordination. Like CXPACKET, this is usually caused by the underlying work, not parallelism itself. Fix parallel skew, spills, or I/O at the operators below the exchange." - }, - ["CXSYNC_CONSUMER"] = new() - { - Description = "Consumer-side synchronization wait inside a parallel exchange.", - HowToFix = "Same story as CXSYNC_PORT — investigate the producing operators for the real source of delay." - }, - ["HTBUILD"] = new() - { - Description = "Waiting for a batch-mode hash table to finish building before probing can start.", - HowToFix = "Build-side row count drives this wait. Reduce the build-side input (better filter pushdown, fewer rows feeding the hash), or confirm the join order puts the smaller side on the build. Make sure statistics are accurate so the optimizer actually knows which side is smaller." - }, - ["HTREPARTITION"] = new() - { - Description = "Waiting for a batch-mode hash table to repartition (re-hash onto more threads).", - HowToFix = "Repartitioning happens when initial hash distribution is uneven. Check for parallel skew from data distribution (skewed join keys, few distinct values on a hash key). Sometimes forcing MAXDOP down reduces repartition overhead more than it costs in parallelism." - }, - ["HTDELETE"] = new() - { - Description = "Waiting for batch-mode hash table cleanup at operator shutdown.", - HowToFix = "Usually shows up alongside HTBUILD/HTREPARTITION as part of the batch-mode hash lifecycle. The fix is upstream — reduce hash input size and skew." - }, - ["HTMEMO"] = new() - { - Description = "Waiting on a batch-mode memoization hash (aggregate/distinct memoization).", - HowToFix = "Similar to HTBUILD — reduce the volume of rows feeding the aggregate, or confirm batch-mode aggregation is actually the right choice for this shape of work." - }, - ["HTREINIT"] = new() - { - Description = "Waiting for a batch-mode hash table to be reinitialized for the next execution.", - HowToFix = "Shows up when a batch-mode hash runs many times (inner side of a nested loop, apply, etc.). Usually a sign the plan shape is wrong — batch mode is best amortized over large row counts per execution." - }, - ["BPSORT"] = new() - { - Description = "Waiting inside a batch-mode sort operator.", - HowToFix = "Batch-mode sorts that wait are usually memory- or concurrency-limited. Check for a too-small memory grant (spilled sort) or excessive concurrency crowding the sort." - }, - ["BMPBUILD"] = new() - { - Description = "Waiting for a bitmap filter to build (bitmap-assisted parallel hash/merge).", - HowToFix = "Bitmap builds depend on the smaller input finishing. Reduce the build-side input where possible; bad estimates that make the optimizer pick a bitmap-assisted plan incorrectly will also show here." - }, - - // ---- Latch ---- - ["PAGELATCH_SH"] = new() - { - Description = "Waiting for a shared latch on an in-memory page (not an I/O wait — the page is already in the buffer pool).", - HowToFix = "Classic tempdb allocation contention (PFS/GAM/SGAM) or last-page insert contention (ascending hot index key). For tempdb: more equally sized tempdb files. For hot pages: use an OPTIMIZE_FOR_SEQUENTIAL_KEY index option, change the clustering key, or hash-partition inserts." - }, - ["PAGELATCH_EX"] = new() - { - Description = "Waiting for an exclusive latch on an in-memory page — a writer contending with other writers on the same page.", - HowToFix = "Same as PAGELATCH_SH: tempdb allocation contention or last-page insert hot spots. OPTIMIZE_FOR_SEQUENTIAL_KEY on ascending-key indexes is the cheapest first-step fix for last-page contention." - }, - ["PAGELATCH_UP"] = new() - { - Description = "Waiting for an update latch on an in-memory page.", - HowToFix = "Usually shows up with PAGELATCH_EX on the same hot pages. The same fixes apply: reduce contention on the specific page type (PFS/GAM/SGAM/last-page)." - }, - ["LATCH_EX"] = new() - { - Description = "Waiting for an exclusive non-buffer latch (internal SQL Server structures — plan cache, partition maps, etc.).", - HowToFix = "Not usually a query-tuning wait; it's a sign of concurrency pressure on shared internal structures. Investigate what other concurrent work is happening. For specific LATCH_EX subtypes check sys.dm_os_latch_stats." - }, - ["LATCH_SH"] = new() - { - Description = "Waiting for a shared non-buffer latch (internal SQL Server structures).", - HowToFix = "Same story as LATCH_EX — a concurrency signal about a shared internal structure, not typically a single-query tuning problem." - }, - - // ---- Locking ---- - ["LCK_M_S"] = new() { Description = "Waiting for a shared (read) lock.", HowToFix = "Another session holds an incompatible lock. Reduce the duration and granularity of modifications in the blocker, or adopt row versioning (RCSI/SI) so readers don't take S locks in the first place." }, - ["LCK_M_X"] = new() { Description = "Waiting for an exclusive (write) lock.", HowToFix = "A writer is blocked by another session's lock. Shorten transaction duration, update fewer rows per statement, and check for lock escalation turning row locks into table locks." }, - ["LCK_M_U"] = new() { Description = "Waiting for an update lock (about to modify).",HowToFix = "Another session holds an incompatible lock. Shorten transactions on the blocker, narrow the rows touched, and verify appropriate indexes so updates don't scan." }, - ["LCK_M_IS"] = new() { Description = "Waiting for an intent-shared lock on a higher-level object.", HowToFix = "Reader is blocked by a session escalating or already holding a table-level lock. Look at what caused lock escalation on the blocker." }, - ["LCK_M_IX"] = new() { Description = "Waiting for an intent-exclusive lock on a higher-level object.", HowToFix = "Writer intent is blocked by a session holding a higher-level lock. Reduce escalation triggers (batch sizes, statement scope) on the blocker." }, - ["LCK_M_SCH_S"] = new() { Description = "Waiting for a schema stability lock (DDL compatibility).", HowToFix = "Blocked by DDL on the same object. Time DDL operations for quiet periods; verify online index operations when possible." }, - ["LCK_M_SCH_M"] = new() { Description = "Waiting for a schema modification lock (DDL blocks everything).", HowToFix = "Your DDL is waiting on active readers/writers to complete. Online index operations where available; otherwise time the change for quiet windows." }, - ["LCK_M_RS_S"] = new() { Description = "Waiting for a key-range shared lock (SERIALIZABLE reader).", HowToFix = "SERIALIZABLE isolation is holding key-range locks. Drop to READ COMMITTED + RCSI unless SERIALIZABLE is specifically required." }, - ["LCK_M_RS_U"] = new() { Description = "Waiting for a key-range update lock (SERIALIZABLE path).", HowToFix = "Same as LCK_M_RS_S — SERIALIZABLE is the cost center here." }, - ["LCK_M_RX_X"] = new() { Description = "Waiting for a key-range exclusive lock.", HowToFix = "SERIALIZABLE writer path. Drop isolation if possible, shorten transactions, and narrow the update predicate." }, - - // ---- Network / client ---- - ["ASYNC_NETWORK_IO"] = new() - { - Description = "Waiting for the client application to fetch result rows — SQL Server has produced rows faster than the client is reading them.", - HowToFix = "This is a client-side problem, not a query-tuning one. The client is either slow to consume rows (processing row-by-row, cross-network, or paused) or it's asking for far more data than it actually uses. Return fewer columns/rows, stream result processing, and move the client closer to the server network-wise." - }, - - // ---- Misc ---- - ["EXECSYNC"] = new() - { - Description = "Waiting on internal parallel execution synchronization (e.g. building a spool that a nested loop on the outer side is reading).", - HowToFix = "Often shows up around eager spools in parallel plans. Fixing the underlying reason for the spool (cardinality estimates, Halloween protection concerns, missing indexes) is the lever." - }, - ["PREEMPTIVE_OS_WRITEFILEGATHER"] = new() - { - Description = "Waiting while SQL Server asks the OS to zero out file space (data or log file growth).", - HowToFix = "Pre-size data files to avoid mid-query growth, and enable Instant File Initialization to skip zeroing for data files. (Log files must still be zeroed.)" - } + ["PAGEIOLATCH_SH"] = new() { ShowEffectiveLatency = true }, + ["PAGEIOLATCH_EX"] = new() { ShowEffectiveLatency = true }, + ["PAGEIOLATCH_UP"] = new() { ShowEffectiveLatency = true }, + ["PAGEIOLATCH_DT"] = new() { ShowEffectiveLatency = true }, }; /// /// Look up the knowledge entry for a wait type. Falls back through family prefixes - /// (LCK_, HT, CX, PAGEIOLATCH_, PAGELATCH_, LATCH_, PREEMPTIVE_) before returning - /// a generic default. Never returns null. + /// for structural flags (effective-latency display) before returning a default. + /// Never returns null. /// public static Entry Lookup(string waitType) { @@ -249,58 +57,8 @@ public static Entry Lookup(string waitType) var wt = waitType.ToUpperInvariant(); - if (wt.StartsWith("LCK_M_")) - return new Entry - { - Description = "Waiting for a lock held by another session.", - HowToFix = "Identify the blocker and reduce its transaction duration, granularity, or isolation level. Consider row versioning (RCSI/SI) for read-heavy workloads." - }; - if (wt.StartsWith("PAGEIOLATCH_")) - return new Entry - { - Description = "Waiting to read a data page from disk into the buffer pool.", - HowToFix = "Reduce physical reads (better indexes, fewer pages touched, fix cardinality estimates) or move to faster storage.", - ShowEffectiveLatency = true - }; - - if (wt.StartsWith("PAGELATCH_")) - return new Entry - { - Description = "Waiting for a latch on an in-memory page — usually tempdb allocation or last-page contention.", - HowToFix = "For tempdb: more equally-sized tempdb files. For last-page inserts: OPTIMIZE_FOR_SEQUENTIAL_KEY or change the clustering key." - }; - - if (wt.StartsWith("LATCH_")) - return new Entry - { - Description = "Waiting for a non-buffer latch on an internal SQL Server structure.", - HowToFix = "Check sys.dm_os_latch_stats for the specific latch class — these are usually concurrency signals rather than query-tuning problems." - }; - - if (wt.StartsWith("HT") && wt.Length > 2) - return new Entry - { - Description = "Batch-mode hash operation synchronization wait.", - HowToFix = "Reduce input rows and skew for the batch-mode hash. Confirm the smaller side is on the build, and check that batch mode is the right choice for this plan shape." - }; - - if (wt.StartsWith("CX")) - return new Entry - { - Description = "Parallel exchange coordination wait — threads waiting for each other inside a parallel plan.", - HowToFix = "CX* waits are usually symptoms of other waits upstream (I/O, lock, latch) or parallel skew. Look at what's happening on the operators feeding the exchange, not the exchange itself." - }; - - if (wt.StartsWith("PREEMPTIVE_")) - return new Entry - { - Description = "SQL Server is waiting on an external OS or system call.", - HowToFix = "The specific preemptive wait suffix indicates the source (file growth, DNS, CLR, etc.). Address whichever external dependency is slow." - }; - - if (wt.Contains("MEMORY_ALLOCATION")) - return Exact["MEMORY_ALLOCATION_EXT"]; + return new Entry { ShowEffectiveLatency = true }; return Default; } diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 5b5b243..d31b574 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -353,6 +353,10 @@ else @w.Operator } @w.Type + @if (w.IsLegacy) + { + legacy + } @if (w.MaxBenefitPercent.HasValue) { up to @(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))% benefit diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index bad44a6..7ab9dfd 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -834,6 +834,18 @@ textarea::placeholder { margin-right: 0.4rem; } +.warn-legacy { + font-size: 0.65rem; + font-weight: 600; + color: var(--text-muted); + padding: 0.05rem 0.3rem; + border-radius: 3px; + background: rgba(0, 0, 0, 0.08); + margin-right: 0.4rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + .warning-fix { color: var(--text-secondary); display: block; From 6567eddbc4b089be8cda2f67a21db698a9c5078a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:09:52 -0400 Subject: [PATCH 2/3] Restore CRLF line endings in PlanViewerControl.axaml.cs Prior commit's Python patch script wrote LF line endings. This file is checked in with CRLF; restoring. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.axaml.cs | 7756 ++++++++--------- 1 file changed, 3878 insertions(+), 3878 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 6eb93df..ef0306f 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -1,3878 +1,3878 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Shapes; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; -using Avalonia.Platform.Storage; -using AvaloniaEdit.TextMate; -using Microsoft.Data.SqlClient; -using PlanViewer.App.Dialogs; -using PlanViewer.Core.Interfaces; -using PlanViewer.App.Helpers; -using PlanViewer.App.Services; -using PlanViewer.App.Mcp; -using PlanViewer.Core.Models; -using PlanViewer.Core.Output; -using PlanViewer.Core.Services; - -using AvaloniaPath = Avalonia.Controls.Shapes.Path; - -namespace PlanViewer.App.Controls; - -public class StatementRow -{ - public int Index { get; set; } - public string QueryText { get; set; } = ""; - public string FullQueryText { get; set; } = ""; - public long CpuMs { get; set; } - public long ElapsedMs { get; set; } - public long UdfMs { get; set; } - public double EstCost { get; set; } - public int Critical { get; set; } - public int Warnings { get; set; } - public PlanStatement Statement { get; set; } = null!; - - // Display helpers - public string CpuDisplay => FormatDuration(CpuMs); - public string ElapsedDisplay => FormatDuration(ElapsedMs); - public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : ""; - public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : ""; - - private static string FormatDuration(long ms) - { - if (ms < 1000) return $"{ms}ms"; - if (ms < 60_000) return $"{ms / 1000.0:F1}s"; - return $"{ms / 60_000}m {(ms % 60_000) / 1000}s"; - } -} - -public partial class PlanViewerControl : UserControl -{ - private readonly string _mcpSessionId = Guid.NewGuid().ToString(); - private ParsedPlan? _currentPlan; - private PlanStatement? _currentStatement; - private string? _queryText; - private ServerMetadata? _serverMetadata; - private double _zoomLevel = 1.0; - private const double ZoomStep = 0.15; - private const double MinZoom = 0.1; - private const double MaxZoom = 3.0; - private string _label = ""; - - /// - /// Full path on disk when the plan was loaded from a file. - /// - public string? SourceFilePath { get; set; } - - // Node selection - private Border? _selectedNodeBorder; - private IBrush? _selectedNodeOriginalBorder; - private Thickness _selectedNodeOriginalThickness; - - // Border -> PlanNode mapping (replaces WPF Tag pattern) - private readonly Dictionary _nodeBorderMap = new(); - - // Brushes - private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23)); - private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45)); - private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); - private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35)); - private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed); - private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange); - - - // Track all property section grids for synchronized column resize - private readonly List _sectionLabelColumns = new(); - private double _propertyLabelWidth = 140; - private bool _isSyncingColumnWidth; - private Grid? _currentSectionGrid; - private int _currentSectionRowIndex; - - // Non-control named elements that Avalonia codegen doesn't auto-generate fields for - private readonly ColumnDefinition _statementsColumn; - private readonly ColumnDefinition _statementsSplitterColumn; - private readonly ColumnDefinition _splitterColumn; - private readonly ColumnDefinition _propertiesColumn; - private readonly ScaleTransform _zoomTransform; - - // Statement grid data - private List? _allStatements; - - // Pan state - private bool _isPanning; - private Point _panStart; - private double _panStartOffsetX; - private double _panStartOffsetY; - - public PlanViewerControl() - { - InitializeComponent(); - // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event - PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel); - // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events - PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); - PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel); - PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel); - - // Resolve non-control elements by traversal (Avalonia doesn't support x:Name on these types) - // The Grid in Row 4 has 5 ColumnDefinitions: - // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0) - var planGrid = (Grid)PlanScrollViewer.Parent!; - _statementsColumn = planGrid.ColumnDefinitions[0]; - _statementsSplitterColumn = planGrid.ColumnDefinitions[1]; - _splitterColumn = planGrid.ColumnDefinitions[3]; - _propertiesColumn = planGrid.ColumnDefinitions[4]; - - // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas - var layoutTransform = this.FindControl("PlanLayoutTransform")!; - _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; - - Helpers.DataGridBehaviors.Attach(StatementsGrid); - } - - /// - /// Exposes the raw XML so MainWindow can implement Save functionality. - /// - public string? RawXml => _currentPlan?.RawXml; - - /// - /// Exposes the parsed and analyzed plan for advice generation. - /// - public ParsedPlan? CurrentPlan => _currentPlan; - - /// - /// Exposes the query text associated with this plan (if any). - /// - public string? QueryText => _queryText; - - /// - /// Server metadata for advice generation and Plan Insights display. - /// - public ServerMetadata? Metadata - { - get => _serverMetadata; - set - { - _serverMetadata = value; - if (_currentStatement != null) - ShowServerContext(); - } - } - - /// - /// Connection string for schema lookups. Set when the plan was loaded from a connected session. - /// - public string? ConnectionString { get; set; } - - // Connection state for plans that connect via the toolbar - private ServerConnection? _planConnection; - private ICredentialService? _planCredentialService; - private ConnectionStore? _planConnectionStore; - private string? _planSelectedDatabase; - - /// - /// Provide credential service and connection store so the plan viewer can show a connection dialog. - /// - public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore) - { - _planCredentialService = credentialService; - _planConnectionStore = connectionStore; - } - - /// - /// Update the connection UI to reflect an active connection (used when connection is inherited). - /// - public void SetConnectionStatus(string serverName, string? database) - { - PlanServerLabel.Text = serverName; - PlanServerLabel.Foreground = Brushes.LimeGreen; - PlanConnectButton.Content = "Reconnect"; - if (database != null) - _planSelectedDatabase = database; - } - - // Events for MainWindow to wire up advice/repro actions - public event EventHandler? HumanAdviceRequested; - public event EventHandler? RobotAdviceRequested; - public event EventHandler? CopyReproRequested; - public event EventHandler? OpenInEditorRequested; - - /// - /// Navigates to a specific plan node by ID: selects it, zooms to show it, - /// and scrolls to center it in the viewport. - /// - public void NavigateToNode(int nodeId) - { - // Find the Border for this node - Border? targetBorder = null; - PlanNode? targetNode = null; - foreach (var (border, node) in _nodeBorderMap) - { - if (node.NodeId == nodeId) - { - targetBorder = border; - targetNode = node; - break; - } - } - - if (targetBorder == null || targetNode == null) - return; - - // Activate the parent window so the plan viewer becomes visible - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is Window parentWindow) - parentWindow.Activate(); - - // Select the node (highlights it and shows properties) - SelectNode(targetBorder, targetNode); - - // Ensure zoom level makes the node comfortably visible - var viewWidth = PlanScrollViewer.Bounds.Width; - var viewHeight = PlanScrollViewer.Bounds.Height; - if (viewWidth <= 0 || viewHeight <= 0) - return; - - // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport - var nodeW = PlanLayoutEngine.NodeWidth; - var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode); - var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4)); - if (_zoomLevel < minVisibleZoom) - SetZoom(Math.Min(minVisibleZoom, 1.0)); - - // Scroll to center the node in the viewport - var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2; - var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2; - centerX = Math.Max(0, centerX); - centerY = Math.Max(0, centerY); - - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(centerX, centerY); - }); - } - - public void LoadPlan(string planXml, string label, string? queryText = null) - { - _label = label; - _queryText = queryText; - - // Query text stored for copy/repro but no longer shown in a - // separate expander — it's already visible in the Statements grid. - - _currentPlan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load()); - BenefitScorer.Score(_currentPlan); - - var allStatements = _currentPlan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .ToList(); - - if (allStatements.Count == 0) - { - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; - return; - } - - EmptyState.IsVisible = false; - PlanScrollViewer.IsVisible = true; - - // Always show statement grid — useful summary even for single-statement plans - _allStatements = allStatements; - PopulateStatementsGrid(allStatements); - ShowStatementsPanel(); - StatementsGrid.SelectedIndex = 0; - - // Register with MCP session manager for AI tool access - // Count warnings from both statement-level PlanWarnings and all node Warnings - int warningCount = 0, criticalCount = 0; - foreach (var s in allStatements) - { - warningCount += s.PlanWarnings.Count; - criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical); - if (s.RootNode != null) - CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); - } - - PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession - { - SessionId = _mcpSessionId, - Label = label, - Source = "file", - Plan = _currentPlan, - QueryText = queryText, - StatementCount = allStatements.Count, - HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), - WarningCount = warningCount, - CriticalWarningCount = criticalCount, - MissingIndexCount = _currentPlan.AllMissingIndexes.Count - }); - } - - public void Clear() - { - PlanSessionManager.Instance.Unregister(_mcpSessionId); - PlanCanvas.Children.Clear(); - _nodeBorderMap.Clear(); - _currentPlan = null; - _currentStatement = null; - _queryText = null; - _selectedNodeBorder = null; - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; - InsightsPanel.IsVisible = false; - CostText.Text = ""; - CloseStatementsPanel(); - StatementsButton.IsVisible = false; - StatementsButtonSeparator.IsVisible = false; - ClosePropertiesPanel(); - } - - 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; - - 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) - RenderEdges(statement.RootNode); - - // Render nodes — pass total warning count to root node for badge - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, 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 = ""; - } - - #region Node Rendering - - private void RenderNodes(PlanNode node, int totalWarningCount = -1) - { - var visual = CreateNodeVisual(node, totalWarningCount); - Canvas.SetLeft(visual, node.X); - Canvas.SetTop(visual, node.Y); - PlanCanvas.Children.Add(visual); - - foreach (var child in node.Children) - RenderNodes(child); - } - - private Border CreateNodeVisual(PlanNode node, 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 10x+ - var estRows = node.EstimateRows; - var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); - IBrush rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? 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) - { - foreach (var child in node.Children) - { - var path = CreateElbowConnector(node, child); - PlanCanvas.Children.Add(path); - - RenderEdges(child); - } - } - - private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) - { - 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 path = new AvaloniaPath - { - Data = geometry, - Stroke = EdgeBrush, - 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}"; - - #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); - - ShowPropertiesPanel(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"; - - // Runtime stats (actual plans) - if (statement.QueryTimeStats != null) - { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - 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 time — plan-level property (category B). Show regardless of - // threshold so it's always visible, not just when Rule 19 fires. - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); - - // Memory grant — color by utilization percentage - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = EfficiencyColor(grantPct); - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); - } - - // DOP + parallelism efficiency — color by 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) - { - // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. - // Subtract external/preemptive wait time from CPU — those waits are CPU-busy - // in kernel and inflate the ratio without representing real query work. - 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); - - // Thread stats — color by utilization - 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"); - } - } - - // CE model - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - // Compile stats (always available) - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Optimization level - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - - 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)}%"; - } - - /// - /// 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); - }); - } - - 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); - }); - - 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 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 - - private async void PlanConnect_Click(object? sender, RoutedEventArgs e) - { - if (_planCredentialService == null || _planConnectionStore == null) return; - - var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore); - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel is not Window parentWindow) return; - - var result = await dialog.ShowDialog(parentWindow); - if (result != true || dialog.ResultConnection == null) return; - - _planConnection = dialog.ResultConnection; - _planSelectedDatabase = dialog.ResultDatabase; - ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); - - PlanServerLabel.Text = _planConnection.ServerName; - PlanServerLabel.Foreground = Brushes.LimeGreen; - PlanConnectButton.Content = "Reconnect"; - - // Populate database dropdown - try - { - var connStr = _planConnection.GetConnectionString(_planCredentialService, "master"); - await using var conn = new SqlConnection(connStr); - await conn.OpenAsync(); - - var databases = new List(); - using var cmd = new SqlCommand( - "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - databases.Add(reader.GetString(0)); - - PlanDatabaseBox.ItemsSource = databases; - PlanDatabaseBox.IsEnabled = true; - - if (_planSelectedDatabase != null) - { - for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) - { - if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) - { - PlanDatabaseBox.SelectedIndex = i; - break; - } - } - } - } - catch - { - PlanDatabaseBox.IsEnabled = false; - } - } - - private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return; - - _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString(); - ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); - } - - #endregion - - #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 -} - -/// Sort DataGrid column by a long property on StatementRow. -public class LongComparer : System.Collections.IComparer -{ - private readonly Func _selector; - public LongComparer(Func selector) => _selector = selector; - public int Compare(object? x, object? y) - { - if (x is StatementRow a && y is StatementRow b) - return _selector(a).CompareTo(_selector(b)); - return 0; - } -} - -/// Sort DataGrid column by a double property on StatementRow. -public class DoubleComparer : System.Collections.IComparer -{ - private readonly Func _selector; - public DoubleComparer(Func selector) => _selector = selector; - public int Compare(object? x, object? y) - { - if (x is StatementRow a && y is StatementRow b) - return _selector(a).CompareTo(_selector(b)); - return 0; - } -} +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Platform.Storage; +using AvaloniaEdit.TextMate; +using Microsoft.Data.SqlClient; +using PlanViewer.App.Dialogs; +using PlanViewer.Core.Interfaces; +using PlanViewer.App.Helpers; +using PlanViewer.App.Services; +using PlanViewer.App.Mcp; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +using AvaloniaPath = Avalonia.Controls.Shapes.Path; + +namespace PlanViewer.App.Controls; + +public class StatementRow +{ + public int Index { get; set; } + public string QueryText { get; set; } = ""; + public string FullQueryText { get; set; } = ""; + public long CpuMs { get; set; } + public long ElapsedMs { get; set; } + public long UdfMs { get; set; } + public double EstCost { get; set; } + public int Critical { get; set; } + public int Warnings { get; set; } + public PlanStatement Statement { get; set; } = null!; + + // Display helpers + public string CpuDisplay => FormatDuration(CpuMs); + public string ElapsedDisplay => FormatDuration(ElapsedMs); + public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : ""; + public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : ""; + + private static string FormatDuration(long ms) + { + if (ms < 1000) return $"{ms}ms"; + if (ms < 60_000) return $"{ms / 1000.0:F1}s"; + return $"{ms / 60_000}m {(ms % 60_000) / 1000}s"; + } +} + +public partial class PlanViewerControl : UserControl +{ + private readonly string _mcpSessionId = Guid.NewGuid().ToString(); + private ParsedPlan? _currentPlan; + private PlanStatement? _currentStatement; + private string? _queryText; + private ServerMetadata? _serverMetadata; + private double _zoomLevel = 1.0; + private const double ZoomStep = 0.15; + private const double MinZoom = 0.1; + private const double MaxZoom = 3.0; + private string _label = ""; + + /// + /// Full path on disk when the plan was loaded from a file. + /// + public string? SourceFilePath { get; set; } + + // Node selection + private Border? _selectedNodeBorder; + private IBrush? _selectedNodeOriginalBorder; + private Thickness _selectedNodeOriginalThickness; + + // Border -> PlanNode mapping (replaces WPF Tag pattern) + private readonly Dictionary _nodeBorderMap = new(); + + // Brushes + private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23)); + private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45)); + private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); + private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35)); + private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed); + private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange); + + + // Track all property section grids for synchronized column resize + private readonly List _sectionLabelColumns = new(); + private double _propertyLabelWidth = 140; + private bool _isSyncingColumnWidth; + private Grid? _currentSectionGrid; + private int _currentSectionRowIndex; + + // Non-control named elements that Avalonia codegen doesn't auto-generate fields for + private readonly ColumnDefinition _statementsColumn; + private readonly ColumnDefinition _statementsSplitterColumn; + private readonly ColumnDefinition _splitterColumn; + private readonly ColumnDefinition _propertiesColumn; + private readonly ScaleTransform _zoomTransform; + + // Statement grid data + private List? _allStatements; + + // Pan state + private bool _isPanning; + private Point _panStart; + private double _panStartOffsetX; + private double _panStartOffsetY; + + public PlanViewerControl() + { + InitializeComponent(); + // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event + PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel); + // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events + PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); + PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel); + PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel); + + // Resolve non-control elements by traversal (Avalonia doesn't support x:Name on these types) + // The Grid in Row 4 has 5 ColumnDefinitions: + // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0) + var planGrid = (Grid)PlanScrollViewer.Parent!; + _statementsColumn = planGrid.ColumnDefinitions[0]; + _statementsSplitterColumn = planGrid.ColumnDefinitions[1]; + _splitterColumn = planGrid.ColumnDefinitions[3]; + _propertiesColumn = planGrid.ColumnDefinitions[4]; + + // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas + var layoutTransform = this.FindControl("PlanLayoutTransform")!; + _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; + + Helpers.DataGridBehaviors.Attach(StatementsGrid); + } + + /// + /// Exposes the raw XML so MainWindow can implement Save functionality. + /// + public string? RawXml => _currentPlan?.RawXml; + + /// + /// Exposes the parsed and analyzed plan for advice generation. + /// + public ParsedPlan? CurrentPlan => _currentPlan; + + /// + /// Exposes the query text associated with this plan (if any). + /// + public string? QueryText => _queryText; + + /// + /// Server metadata for advice generation and Plan Insights display. + /// + public ServerMetadata? Metadata + { + get => _serverMetadata; + set + { + _serverMetadata = value; + if (_currentStatement != null) + ShowServerContext(); + } + } + + /// + /// Connection string for schema lookups. Set when the plan was loaded from a connected session. + /// + public string? ConnectionString { get; set; } + + // Connection state for plans that connect via the toolbar + private ServerConnection? _planConnection; + private ICredentialService? _planCredentialService; + private ConnectionStore? _planConnectionStore; + private string? _planSelectedDatabase; + + /// + /// Provide credential service and connection store so the plan viewer can show a connection dialog. + /// + public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore) + { + _planCredentialService = credentialService; + _planConnectionStore = connectionStore; + } + + /// + /// Update the connection UI to reflect an active connection (used when connection is inherited). + /// + public void SetConnectionStatus(string serverName, string? database) + { + PlanServerLabel.Text = serverName; + PlanServerLabel.Foreground = Brushes.LimeGreen; + PlanConnectButton.Content = "Reconnect"; + if (database != null) + _planSelectedDatabase = database; + } + + // Events for MainWindow to wire up advice/repro actions + public event EventHandler? HumanAdviceRequested; + public event EventHandler? RobotAdviceRequested; + public event EventHandler? CopyReproRequested; + public event EventHandler? OpenInEditorRequested; + + /// + /// Navigates to a specific plan node by ID: selects it, zooms to show it, + /// and scrolls to center it in the viewport. + /// + public void NavigateToNode(int nodeId) + { + // Find the Border for this node + Border? targetBorder = null; + PlanNode? targetNode = null; + foreach (var (border, node) in _nodeBorderMap) + { + if (node.NodeId == nodeId) + { + targetBorder = border; + targetNode = node; + break; + } + } + + if (targetBorder == null || targetNode == null) + return; + + // Activate the parent window so the plan viewer becomes visible + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is Window parentWindow) + parentWindow.Activate(); + + // Select the node (highlights it and shows properties) + SelectNode(targetBorder, targetNode); + + // Ensure zoom level makes the node comfortably visible + var viewWidth = PlanScrollViewer.Bounds.Width; + var viewHeight = PlanScrollViewer.Bounds.Height; + if (viewWidth <= 0 || viewHeight <= 0) + return; + + // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode); + var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4)); + if (_zoomLevel < minVisibleZoom) + SetZoom(Math.Min(minVisibleZoom, 1.0)); + + // Scroll to center the node in the viewport + var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2; + var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2; + centerX = Math.Max(0, centerX); + centerY = Math.Max(0, centerY); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(centerX, centerY); + }); + } + + public void LoadPlan(string planXml, string label, string? queryText = null) + { + _label = label; + _queryText = queryText; + + // Query text stored for copy/repro but no longer shown in a + // separate expander — it's already visible in the Statements grid. + + _currentPlan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load()); + BenefitScorer.Score(_currentPlan); + + var allStatements = _currentPlan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .ToList(); + + if (allStatements.Count == 0) + { + EmptyState.IsVisible = true; + PlanScrollViewer.IsVisible = false; + return; + } + + EmptyState.IsVisible = false; + PlanScrollViewer.IsVisible = true; + + // Always show statement grid — useful summary even for single-statement plans + _allStatements = allStatements; + PopulateStatementsGrid(allStatements); + ShowStatementsPanel(); + StatementsGrid.SelectedIndex = 0; + + // Register with MCP session manager for AI tool access + // Count warnings from both statement-level PlanWarnings and all node Warnings + int warningCount = 0, criticalCount = 0; + foreach (var s in allStatements) + { + warningCount += s.PlanWarnings.Count; + criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical); + if (s.RootNode != null) + CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); + } + + PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession + { + SessionId = _mcpSessionId, + Label = label, + Source = "file", + Plan = _currentPlan, + QueryText = queryText, + StatementCount = allStatements.Count, + HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), + WarningCount = warningCount, + CriticalWarningCount = criticalCount, + MissingIndexCount = _currentPlan.AllMissingIndexes.Count + }); + } + + public void Clear() + { + PlanSessionManager.Instance.Unregister(_mcpSessionId); + PlanCanvas.Children.Clear(); + _nodeBorderMap.Clear(); + _currentPlan = null; + _currentStatement = null; + _queryText = null; + _selectedNodeBorder = null; + EmptyState.IsVisible = true; + PlanScrollViewer.IsVisible = false; + InsightsPanel.IsVisible = false; + CostText.Text = ""; + CloseStatementsPanel(); + StatementsButton.IsVisible = false; + StatementsButtonSeparator.IsVisible = false; + ClosePropertiesPanel(); + } + + 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; + + 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) + RenderEdges(statement.RootNode); + + // Render nodes — pass total warning count to root node for badge + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, 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 = ""; + } + + #region Node Rendering + + private void RenderNodes(PlanNode node, int totalWarningCount = -1) + { + var visual = CreateNodeVisual(node, totalWarningCount); + Canvas.SetLeft(visual, node.X); + Canvas.SetTop(visual, node.Y); + PlanCanvas.Children.Add(visual); + + foreach (var child in node.Children) + RenderNodes(child); + } + + private Border CreateNodeVisual(PlanNode node, 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 10x+ + var estRows = node.EstimateRows; + var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + IBrush rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? 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) + { + foreach (var child in node.Children) + { + var path = CreateElbowConnector(node, child); + PlanCanvas.Children.Add(path); + + RenderEdges(child); + } + } + + private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) + { + 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 path = new AvaloniaPath + { + Data = geometry, + Stroke = EdgeBrush, + 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}"; + + #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); + + ShowPropertiesPanel(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"; + + // Runtime stats (actual plans) + if (statement.QueryTimeStats != null) + { + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + 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 time — plan-level property (category B). Show regardless of + // threshold so it's always visible, not just when Rule 19 fires. + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + + // Memory grant — color by utilization percentage + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = EfficiencyColor(grantPct); + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // DOP + parallelism efficiency — color by 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) + { + // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. + // Subtract external/preemptive wait time from CPU — those waits are CPU-busy + // in kernel and inflate the ratio without representing real query work. + 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); + + // Thread stats — color by utilization + 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"); + } + } + + // CE model + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + // Compile stats (always available) + if (statement.CompileTimeMs > 0) + AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Optimization level + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + + 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)}%"; + } + + /// + /// 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); + }); + } + + 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); + }); + + 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 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 + + private async void PlanConnect_Click(object? sender, RoutedEventArgs e) + { + if (_planCredentialService == null || _planConnectionStore == null) return; + + var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore); + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is not Window parentWindow) return; + + var result = await dialog.ShowDialog(parentWindow); + if (result != true || dialog.ResultConnection == null) return; + + _planConnection = dialog.ResultConnection; + _planSelectedDatabase = dialog.ResultDatabase; + ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); + + PlanServerLabel.Text = _planConnection.ServerName; + PlanServerLabel.Foreground = Brushes.LimeGreen; + PlanConnectButton.Content = "Reconnect"; + + // Populate database dropdown + try + { + var connStr = _planConnection.GetConnectionString(_planCredentialService, "master"); + await using var conn = new SqlConnection(connStr); + await conn.OpenAsync(); + + var databases = new List(); + using var cmd = new SqlCommand( + "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + databases.Add(reader.GetString(0)); + + PlanDatabaseBox.ItemsSource = databases; + PlanDatabaseBox.IsEnabled = true; + + if (_planSelectedDatabase != null) + { + for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) + { + if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) + { + PlanDatabaseBox.SelectedIndex = i; + break; + } + } + } + } + catch + { + PlanDatabaseBox.IsEnabled = false; + } + } + + private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return; + + _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString(); + ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase); + } + + #endregion + + #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 +} + +/// Sort DataGrid column by a long property on StatementRow. +public class LongComparer : System.Collections.IComparer +{ + private readonly Func _selector; + public LongComparer(Func selector) => _selector = selector; + public int Compare(object? x, object? y) + { + if (x is StatementRow a && y is StatementRow b) + return _selector(a).CompareTo(_selector(b)); + return 0; + } +} + +/// Sort DataGrid column by a double property on StatementRow. +public class DoubleComparer : System.Collections.IComparer +{ + private readonly Func _selector; + public DoubleComparer(Func selector) => _selector = selector; + public int Compare(object? x, object? y) + { + if (x is StatementRow a && y is StatementRow b) + return _selector(a).CompareTo(_selector(b)); + return 0; + } +} From d4fe4ba17bea6a596a2366d5a69d2174a1752fe6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:17:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?Drop=20Rule=2021=20test=20=E2=80=94=20rule?= =?UTF-8?q?=20was=20removed=20in=20prior=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 8944a39..fb067d7 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -436,20 +436,8 @@ public void Rule20_LocalVariables_DetectsUnsnifffedParameters() Assert.Contains("density estimates", warnings[0].Message); } - // --------------------------------------------------------------- - // Rule 21: CTE Multiple References - // --------------------------------------------------------------- - - [Fact] - public void Rule21_CteMultipleReferences_DetectsDoubleReference() - { - var plan = PlanTestHelper.LoadAndAnalyze("cte_multi_ref_plan.sqlplan"); - var warnings = PlanTestHelper.WarningsOfType(plan, "CTE Multiple References"); - - Assert.Single(warnings); - Assert.Contains("TopUsers", warnings[0].Message); - Assert.Contains("2 times", warnings[0].Message); - } + // Rule 21 (CTE Multiple References) removed per Joe's #215 feedback — actual + // plans show time directly, no need to guess from statement-text patterns. // --------------------------------------------------------------- // Rule 22: Table Variable