From 1ab826d77fff3ac1ad072c52b755ba47d73c65d5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:28:16 -0400 Subject: [PATCH 01/19] Split Lite god-class files into partial classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. Three files split: - Controls/ServerTab.xaml.cs (5824 → 1571) into 8 partials: Refresh, Charts, Pickers, Plans, DrillDown, Comparison, CopyExport, Filters - MainWindow.xaml.cs (2352 → 1967): plan viewer extracted to MainWindow.PlanViewer.cs - Controls/PlanViewerControl.xaml.cs (2376 → 209) into 4 partials: Rendering, Properties, Tooltips, Interaction Build clean: 0 errors, 186 warnings (all pre-existing CA1873). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.Interaction.cs | 249 + Lite/Controls/PlanViewerControl.Properties.cs | 1263 +++++ Lite/Controls/PlanViewerControl.Rendering.cs | 448 ++ Lite/Controls/PlanViewerControl.Tooltips.cs | 261 + Lite/Controls/PlanViewerControl.xaml.cs | 2585 +-------- Lite/Controls/ServerTab.Charts.cs | 1479 ++++++ Lite/Controls/ServerTab.Comparison.cs | 230 + Lite/Controls/ServerTab.CopyExport.cs | 268 + Lite/Controls/ServerTab.DrillDown.cs | 225 + Lite/Controls/ServerTab.Filters.cs | 125 + Lite/Controls/ServerTab.Pickers.cs | 569 ++ Lite/Controls/ServerTab.Plans.cs | 718 +++ Lite/Controls/ServerTab.Refresh.cs | 815 +++ Lite/Controls/ServerTab.xaml.cs | 4673 +---------------- Lite/MainWindow.PlanViewer.cs | 405 ++ Lite/MainWindow.xaml.cs | 385 -- 16 files changed, 7474 insertions(+), 7224 deletions(-) create mode 100644 Lite/Controls/PlanViewerControl.Interaction.cs create mode 100644 Lite/Controls/PlanViewerControl.Properties.cs create mode 100644 Lite/Controls/PlanViewerControl.Rendering.cs create mode 100644 Lite/Controls/PlanViewerControl.Tooltips.cs create mode 100644 Lite/Controls/ServerTab.Charts.cs create mode 100644 Lite/Controls/ServerTab.Comparison.cs create mode 100644 Lite/Controls/ServerTab.CopyExport.cs create mode 100644 Lite/Controls/ServerTab.DrillDown.cs create mode 100644 Lite/Controls/ServerTab.Filters.cs create mode 100644 Lite/Controls/ServerTab.Pickers.cs create mode 100644 Lite/Controls/ServerTab.Plans.cs create mode 100644 Lite/Controls/ServerTab.Refresh.cs create mode 100644 Lite/MainWindow.PlanViewer.cs diff --git a/Lite/Controls/PlanViewerControl.Interaction.cs b/Lite/Controls/PlanViewerControl.Interaction.cs new file mode 100644 index 0000000..d66a8db --- /dev/null +++ b/Lite/Controls/PlanViewerControl.Interaction.cs @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void Node_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border border && border.Tag is PlanNode 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; + _selectedNode = node; + 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 += (_, _) => + { + // Find the border for this node by checking Tags + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && b.Tag == node) + { + SelectNode(b, node); + break; + } + } + }; + menu.Items.Add(propsItem); + + menu.Items.Add(new Separator()); + + var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; + copyOpItem.Click += (_, _) => Clipboard.SetDataObject(node.PhysicalOp, false); + menu.Items.Add(copyOpItem); + + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + var copyObjItem = new MenuItem { Header = "Copy Object Name" }; + copyObjItem.Click += (_, _) => Clipboard.SetDataObject(node.FullObjectName, false); + menu.Items.Add(copyObjItem); + } + + if (!string.IsNullOrEmpty(node.Predicate)) + { + var copyPredItem = new MenuItem { Header = "Copy Predicate" }; + copyPredItem.Click += (_, _) => Clipboard.SetDataObject(node.Predicate, false); + menu.Items.Add(copyPredItem); + } + + if (!string.IsNullOrEmpty(node.SeekPredicates)) + { + var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; + copySeekItem.Click += (_, _) => Clipboard.SetDataObject(node.SeekPredicates, false); + menu.Items.Add(copySeekItem); + } + + return menu; + } + + 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.ActualWidth; + var viewHeight = PlanScrollViewer.ActualHeight; + if (viewWidth <= 0 || viewHeight <= 0) return; + + var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); + SetZoom(Math.Min(fitZoom, 1.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)}%"; + } + + private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + SetZoom(_zoomLevel + (e.Delta > 0 ? ZoomStep : -ZoomStep)); + } + } + + private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + // Don't steal focus from interactive controls (ComboBox, TextBox, Button, etc.) + // ComboBox dropdown items live in a separate visual tree (Popup), so also check + // for ComboBoxItem to avoid stealing focus when selecting dropdown items. + if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase + || e.OriginalSource is ComboBox + || e.OriginalSource is ComboBoxItem + || FindVisualParent(e.OriginalSource as DependencyObject) != null + || FindVisualParent(e.OriginalSource as DependencyObject) != null + || FindVisualParent(e.OriginalSource as DependencyObject) != null) + return; + + Focus(); + } + + private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject + { + while (child != null) + { + if (child is T parent) return parent; + child = VisualTreeHelper.GetParent(child); + } + return null; + } + + private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control + && e.OriginalSource is not TextBox) + { + var text = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(text)) + { + e.Handled = true; + try + { + System.Xml.Linq.XDocument.Parse(text); + } + catch (System.Xml.XmlException ex) + { + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + LoadPlan(text, "Pasted Plan"); + } + } + } + + private void PlanScrollViewer_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // Don't intercept scrollbar interactions + if (IsScrollBarAtPoint(e)) + return; + + // Don't pan if clicking on a node + if (IsNodeAtPoint(e)) + return; + + _isPanning = true; + _panStart = e.GetPosition(PlanScrollViewer); + _panStartOffsetX = PlanScrollViewer.HorizontalOffset; + _panStartOffsetY = PlanScrollViewer.VerticalOffset; + PlanScrollViewer.Cursor = Cursors.SizeAll; + PlanScrollViewer.CaptureMouse(); + e.Handled = true; + } + + private void PlanScrollViewer_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (!_isPanning) return; + + var current = e.GetPosition(PlanScrollViewer); + var dx = current.X - _panStart.X; + var dy = current.Y - _panStart.Y; + + PlanScrollViewer.ScrollToHorizontalOffset(Math.Max(0, _panStartOffsetX - dx)); + PlanScrollViewer.ScrollToVerticalOffset(Math.Max(0, _panStartOffsetY - dy)); + e.Handled = true; + } + + private void PlanScrollViewer_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (!_isPanning) return; + _isPanning = false; + PlanScrollViewer.Cursor = Cursors.Arrow; + PlanScrollViewer.ReleaseMouseCapture(); + e.Handled = true; + } + + /// Check if the mouse event originated from a ScrollBar. + private static bool IsScrollBarAtPoint(MouseButtonEventArgs e) + { + var source = e.OriginalSource as DependencyObject; + while (source != null) + { + if (source is System.Windows.Controls.Primitives.ScrollBar) + return true; + source = VisualTreeHelper.GetParent(source); + } + return false; + } + + /// Check if the mouse event originated from a node Border (has PlanNode in Tag). + private static bool IsNodeAtPoint(MouseButtonEventArgs e) + { + var source = e.OriginalSource as DependencyObject; + while (source != null) + { + if (source is Border b && b.Tag is PlanNode) + return true; + source = VisualTreeHelper.GetParent(source); + } + return false; + } +} diff --git a/Lite/Controls/PlanViewerControl.Properties.cs b/Lite/Controls/PlanViewerControl.Properties.cs new file mode 100644 index 0000000..9271c24 --- /dev/null +++ b/Lite/Controls/PlanViewerControl.Properties.cs @@ -0,0 +1,1263 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowPropertiesPanel(PlanNode node) + { + PropertiesContent.Children.Clear(); + _currentPropertySection = null; + + // 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 — always show for operators that have object references + 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 Matching 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) + { + AddPropertySection("Plan Warnings"); + foreach (var w in s.PlanWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + warnPanel.Children.Add(new TextBlock + { + Text = $"⚠ {w.WarningType}", + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel); + } + } + + // === 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) + { + AddPropertySection("Warnings"); + foreach (var w in node.Warnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + warnPanel.Children.Add(new TextBlock + { + Text = $"⚠ {w.WarningType}", + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + PropertiesContent.Children.Add(warnPanel); + } + } + + // Show the panel + PropertiesColumn.Width = new GridLength(320); + PropertiesSplitter.Visibility = Visibility.Visible; + PropertiesPanel.Visibility = Visibility.Visible; + } + + private void AddPropertySection(string title) + { + var contentPanel = new StackPanel(); + var expander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = title, + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = contentPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1) + }; + PropertiesContent.Children.Add(expander); + _currentPropertySection = contentPanel; + } + + private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) + { + var grid = new Grid { Margin = new Thickness(10, 3, 10, 3) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var labelBlock = new TextBlock + { + Text = label, + FontSize = indent ? 10 : 11, + Foreground = MutedBrush, + VerticalAlignment = VerticalAlignment.Top, + TextWrapping = TextWrapping.Wrap, + Margin = indent ? new Thickness(16, 0, 0, 0) : new Thickness(0) + }; + Grid.SetColumn(labelBlock, 0); + grid.Children.Add(labelBlock); + + var valueBlock = new TextBlock + { + Text = value, + FontSize = indent ? 10 : 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap + }; + if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBlock, 1); + grid.Children.Add(valueBlock); + + var target = _currentPropertySection ?? PropertiesContent; + target.Children.Add(grid); + } + + private void CloseProperties_Click(object sender, RoutedEventArgs e) + { + ClosePropertiesPanel(); + } + + private void ClosePropertiesPanel() + { + PropertiesPanel.Visibility = Visibility.Collapsed; + PropertiesSplitter.Visibility = Visibility.Collapsed; + PropertiesColumn.Width = new GridLength(0); + + // Deselect node + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + _selectedNodeBorder = null; + } + _selectedNode = null; + } + + private void ShowMissingIndexes(System.Collections.Generic.List indexes) + { + MissingIndexContent.Children.Clear(); + + if (indexes.Count > 0) + { + MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; + + 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 = FontWeights.SemiBold, + Foreground = TooltipFgBrush, + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $" — Impact: ", + Foreground = MutedBrush, + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $"{mi.Impact:F1}%", + Foreground = OrangeBrush, + FontSize = 12 + }); + itemPanel.Children.Add(headerRow); + + if (!string.IsNullOrEmpty(mi.CreateStatement)) + { + itemPanel.Children.Add(new TextBox + { + Text = mi.CreateStatement, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + Foreground = TooltipFgBrush, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(12, 2, 0, 0) + }); + } + + MissingIndexContent.Children.Add(itemPanel); + } + + MissingIndexEmpty.Visibility = Visibility.Collapsed; + } + else + { + MissingIndexHeader.Text = "Missing Index Suggestions"; + MissingIndexEmpty.Visibility = Visibility.Visible; + } + } + + private void ShowWaitStats(System.Collections.Generic.List waits, 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.Visibility = Visibility.Visible; + return; + } + + WaitStatsEmpty.Visibility = Visibility.Collapsed; + + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); + var maxWait = sorted[0].WaitTimeMs; + var totalWait = sorted.Sum(w => w.WaitTimeMs); + + WaitStatsHeader.Text = $" Wait Stats — {totalWait:N0}ms total"; + + var longestName = sorted.Max(w => w.WaitType.Length); + var nameColWidth = longestName * 6.5 + 10; + + var maxBarWidth = 300; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(nameColWidth) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(maxBarWidth + 16) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + for (int i = 0; i < sorted.Count; i++) + grid.RowDefinitions.Add(new RowDefinition { Height = 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)); + + var nameText = new TextBlock + { + Text = w.WaitType, + FontSize = 12, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 10, 2) + }; + Grid.SetRow(nameText, i); + Grid.SetColumn(nameText, 0); + grid.Children.Add(nameText); + + var colorBar = new Border + { + Width = Math.Max(4, barFraction * maxBarWidth), + Height = 14, + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)), + 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); + + var durationText = new TextBlock + { + Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", + FontSize = 12, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(durationText, i); + Grid.SetColumn(durationText, 2); + grid.Children.Add(durationText); + } + + WaitStatsContent.Children.Add(grid); + } + + private static string GetWaitCategory(string waitType) + { + if (waitType.StartsWith("SOS_SCHEDULER_YIELD", StringComparison.Ordinal) || + waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || + waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) || + waitType.StartsWith("CXSYNC_PORT", StringComparison.Ordinal) || + waitType.StartsWith("CXSYNC_CONSUMER", StringComparison.Ordinal)) + return "CPU"; + + if (waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || + waitType.StartsWith("WRITELOG", StringComparison.Ordinal) || + waitType.StartsWith("IO_COMPLETION", StringComparison.Ordinal) || + waitType.StartsWith("ASYNC_IO_COMPLETION", StringComparison.Ordinal)) + return "I/O"; + + if (waitType.StartsWith("LCK_M_", StringComparison.Ordinal)) + 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" + }; + } + + private void ShowRuntimeSummary(PlanStatement statement) + { + RuntimeSummaryContent.Children.Clear(); + + var labelBrush = MutedBrush; + var valueBrush = TooltipFgBrush; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + int rowIndex = 0; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var labelText = new TextBlock + { + Text = label, + FontSize = 11, + Foreground = labelBrush, + 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 = valueBrush, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(valueText, rowIndex); + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + + rowIndex++; + } + + 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"); + } + + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used"); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms"); + } + + if (statement.DegreeOfParallelism > 0) + AddRow("DOP", statement.DegreeOfParallelism.ToString()); + else if (statement.NonParallelPlanReason != null) + AddRow("Serial", statement.NonParallelPlanReason); + + 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 threadText = ts.UsedThreads == totalReserved + ? $"{ts.UsedThreads} used ({totalReserved} reserved)" + : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; + AddRow("Threads", threadText); + } + else + { + AddRow("Threads", $"{ts.UsedThreads} used"); + } + } + + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + if (statement.CompileTimeMs > 0) + AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + + RuntimeSummaryContent.Children.Add(grid); + } + + /// + /// Formats a memory value given in KB to a human-readable string. + /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals). + /// + private static string FormatMemoryGrantKB(long kb) + { + if (kb < 1024) + return $"{kb:N0} KB"; + if (kb < 1024 * 1024) + return $"{kb / 1024.0:N1} MB"; + return $"{kb / (1024.0 * 1024.0):N2} GB"; + } + + private void UpdateInsightsHeader() + { + InsightsPanel.Visibility = Visibility.Visible; + InsightsHeader.Text = " Plan Insights"; + } +} diff --git a/Lite/Controls/PlanViewerControl.Rendering.cs b/Lite/Controls/PlanViewerControl.Rendering.cs new file mode 100644 index 0000000..d35231c --- /dev/null +++ b/Lite/Controls/PlanViewerControl.Rendering.cs @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +using WpfPath = System.Windows.Shapes.Path; + +namespace PerformanceMonitorLite.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void RenderStatement(PlanStatement statement) + { + _currentStatement = statement; + PlanCanvas.Children.Clear(); + _selectedNodeBorder = null; + PlanScrollViewer.ScrollToHome(); + + 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 + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, allWarnings.Count); + + // Update insights panel + ShowMissingIndexes(statement.MissingIndexes); + ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); + ShowRuntimeSummary(statement); + UpdateInsightsHeader(); + + // Update cost text + CostText.Text = $"Statement Cost: {statement.StatementSubTreeCost:F4}"; + } + + 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 border = new Border + { + Width = PlanLayoutEngine.NodeWidth, + MinHeight = PlanLayoutEngine.NodeHeightMin, + Background = isExpensive + ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) + : (Brush)FindResource("BackgroundLightBrush"), + BorderBrush = isExpensive + ? Brushes.OrangeRed + : (Brush)FindResource("BorderBrush"), + BorderThickness = new Thickness(isExpensive ? 2 : 1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + SnapsToDevicePixels = true, + Tag = node + }; + + // Tooltip — root node includes statement-level PlanWarnings + if (totalWarningCount > 0 && _currentStatement != null) + { + var allWarnings = new List(); + allWarnings.AddRange(_currentStatement.PlanWarnings); + CollectWarnings(node, allWarnings); + border.ToolTip = BuildNodeTooltip(node, allWarnings); + } + else + { + border.ToolTip = BuildNodeTooltip(node); + } + + // Click to select + show properties + border.MouseLeftButtonUp += 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 icon = PlanIconMapper.GetIcon(node.IconName); + if (icon != null) + { + iconRow.Children.Add(new Image + { + Source = icon, + 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 Polygon + { + Points = new PointCollection + { + new Point(10, 0), new Point(20, 18), new Point(0, 18) + }, + Fill = Brushes.Orange + }); + warnBadge.Children.Add(new TextBlock + { + Text = "!", + FontSize = 12, + FontWeight = FontWeights.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 = "⇆", + FontSize = 12, + FontWeight = FontWeights.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, 4, 1), + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"+{node.NonClusteredIndexCount} NC", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + } + }; + iconRow.Children.Add(ncBadge); + } + + stack.Children.Add(iconRow); + + // Operator name — use full name, let TextTrimming handle overflow + stack.Children.Add(new TextBlock + { + Text = node.PhysicalOp, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("ForegroundBrush"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Cost percentage + var costColor = node.CostPercent >= 50 ? Brushes.OrangeRed + : node.CostPercent >= 25 ? Brushes.Orange + : (Brush)FindResource("ForegroundBrush"); + + 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) + { + var fgBrush = (Brush)FindResource("ForegroundBrush"); + + // Elapsed time — red if >= 1 second + var elapsedSec = node.ActualElapsedMs / 1000.0; + var elapsedBrush = elapsedSec >= 1.0 ? Brushes.OrangeRed : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"{elapsedSec:F3}s", + FontSize = 10, + Foreground = elapsedBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // CPU time — red if >= 1 second + var cpuSec = node.ActualCPUMs / 1000.0; + var cpuBrush = cpuSec >= 1.0 ? Brushes.OrangeRed : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"CPU: {cpuSec:F3}s", + FontSize = 9, + Foreground = cpuBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Actual rows per execution vs Estimated rows (accuracy %) — red if off by 10x+ + var estRows = node.EstimateRows; + var actualRowsPerExec = node.ActualExecutions > 0 ? node.ActualRows / (double)node.ActualExecutions : node.ActualRows; + var accuracyRatio = estRows > 0 ? actualRowsPerExec / estRows : (actualRowsPerExec > 0 ? double.MaxValue : 1.0); + var rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? Brushes.OrangeRed : fgBrush; + var accuracy = estRows > 0 + ? $" ({accuracyRatio * 100:F0}%)" + : ""; + stack.Children.Add(new TextBlock + { + Text = $"{actualRowsPerExec:N0} of {estRows:N0}{accuracy}", + FontSize = 9, + Foreground = rowBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16 + }); + } + + // Object name — show full object name, use ellipsis for overflow + if (!string.IsNullOrEmpty(node.ObjectName)) + { + stack.Children.Add(new TextBlock + { + Text = node.FullObjectName ?? node.ObjectName, + FontSize = 9, + Foreground = (Brush)FindResource("ForegroundBrush"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center, + ToolTip = node.FullObjectName ?? node.ObjectName + }); + } + + // 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 = "⚠", + 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 = FontWeights.SemiBold, + Foreground = OrangeBrush, + VerticalAlignment = VerticalAlignment.Center + }); + stack.Children.Add(badgeRow); + } + + border.Child = stack; + return border; + } + + private void RenderEdges(PlanNode node) + { + foreach (var child in node.Children) + { + var path = CreateElbowConnector(node, child); + PlanCanvas.Children.Add(path); + + RenderEdges(child); + } + } + + private WpfPath 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(new Point(midX, parentCenterY), true)); + figure.Segments.Add(new LineSegment(new Point(midX, childCenterY), true)); + figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true)); + geometry.Figures.Add(figure); + + return new WpfPath + { + Data = geometry, + Stroke = EdgeBrush, + StrokeThickness = thickness, + StrokeLineJoin = PenLineJoin.Round, + ToolTip = BuildEdgeTooltipContent(child), + SnapsToDevicePixels = true + }; + } + + private Border BuildEdgeTooltipContent(PlanNode child) + { + var grid = new Grid { MinWidth = 240 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + int row = 0; + var bgBrush = TooltipBgBrush; + var borderBrush = TooltipBorderBrush; + var mutedBrush = MutedBrush; + var fgBrush = TooltipFgBrush; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var lbl = new TextBlock + { + Text = label, + Foreground = mutedBrush, + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = fgBrush, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(lbl, row); + Grid.SetColumn(lbl, 0); + Grid.SetRow(val, row); + Grid.SetColumn(val, 1); + grid.Children.Add(lbl); + grid.Children.Add(val); + 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 = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6, 10, 6), + CornerRadius = new CornerRadius(4), + Child = grid + }; + } + + 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"; + } +} diff --git a/Lite/Controls/PlanViewerControl.Tooltips.cs b/Lite/Controls/PlanViewerControl.Tooltips.cs new file mode 100644 index 0000000..cbb1e27 --- /dev/null +++ b/Lite/Controls/PlanViewerControl.Tooltips.cs @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class PlanViewerControl : UserControl +{ + private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null) + { + var tip = new ToolTip + { + Background = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, + Foreground = TooltipFgBrush, + 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 = FontWeights.Bold, + FontSize = 13, + 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}"); + } + + // 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 — show full qualified name + 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 (includes statement-level) 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 + var distinct = warnings + .GroupBy(w => w.WarningType) + .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count())) + .OrderByDescending(g => g.MaxSeverity) + .ThenBy(g => g.Type); + + foreach (var (type, severity, count) in distinct) + { + var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" + : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var label = count > 1 ? $"⚠ {type} ({count})" : $"⚠ {type}"; + stack.Children.Add(new TextBlock + { + Text = label, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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 = $"⚠ {w.WarningType}: {w.Message}", + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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 = FontStyles.Italic, + Foreground = MutedBrush, + Margin = new Thickness(0, 8, 0, 0) + }); + + tip.Content = stack; + return tip; + } + + private void AddTooltipSection(StackPanel parent, string title) + { + parent.Children.Add(new TextBlock + { + Text = title, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = SectionHeaderBrush, + Margin = new Thickness(0, 6, 0, 2) + }); + } + + private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) + { + var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; + row.Children.Add(new TextBlock + { + Text = $"{label}: ", + Foreground = MutedBrush, + FontSize = 11, + MinWidth = 120 + }); + var valueBlock = new TextBlock + { + Text = value, + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 350 + }; + if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); + row.Children.Add(valueBlock); + parent.Children.Add(row); + } +} diff --git a/Lite/Controls/PlanViewerControl.xaml.cs b/Lite/Controls/PlanViewerControl.xaml.cs index 4af6ba7..1d24bcb 100644 --- a/Lite/Controls/PlanViewerControl.xaml.cs +++ b/Lite/Controls/PlanViewerControl.xaml.cs @@ -1,2376 +1,209 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Shapes; -using Microsoft.Win32; -using PerformanceMonitorLite.Models; -using PerformanceMonitorLite.Services; - -using WpfPath = System.Windows.Shapes.Path; - -namespace PerformanceMonitorLite.Controls; - -public partial class PlanViewerControl : UserControl -{ - private ParsedPlan? _currentPlan; - private PlanStatement? _currentStatement; - 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 = ""; - - // Node selection - private Border? _selectedNodeBorder; - private Brush? _selectedNodeOriginalBorder; - private Thickness _selectedNodeOriginalThickness; - private PlanNode? _selectedNode; - - // Brushes — accent/neutral tones that suit every theme - private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); - private static readonly SolidColorBrush OrangeBrush = new(Color.FromRgb(0xFF, 0xB3, 0x47)); - - // Theme-aware brushes resolved at call time from Application.Resources - private SolidColorBrush TooltipBgBrush => - (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23)); - private SolidColorBrush TooltipBorderBrush => - (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)); - private SolidColorBrush TooltipFgBrush => - (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private SolidColorBrush MutedBrush => - (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private SolidColorBrush SectionHeaderBrush => - (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private SolidColorBrush PropSeparatorBrush => - (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35)); - - // Current property section for collapsible groups - private StackPanel? _currentPropertySection; - - // Canvas panning - private bool _isPanning; - private Point _panStart; - private double _panStartOffsetX; - private double _panStartOffsetY; - - public PlanViewerControl() - { - InitializeComponent(); - Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - } - - private void OnThemeChanged(string _) - { - if (_currentStatement == null) return; - - var nodeToRestore = _selectedNode; - RenderStatement(_currentStatement); - - if (nodeToRestore == null) return; - - // Find the re-created border for the previously selected node and reopen properties - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && b.Tag == nodeToRestore) - { - SelectNode(b, nodeToRestore); - break; - } - } - } - - public void LoadPlan(string planXml, string label, string? queryText = null) - { - _label = label; - - if (!string.IsNullOrEmpty(queryText)) - { - QueryTextBox.Text = queryText; - QueryTextExpander.Visibility = Visibility.Visible; - } - else - { - QueryTextExpander.Visibility = Visibility.Collapsed; - } - _currentPlan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(_currentPlan); - - var allStatements = _currentPlan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .ToList(); - - if (allStatements.Count == 0) - { - EmptyState.Visibility = Visibility.Visible; - PlanScrollViewer.Visibility = Visibility.Collapsed; - return; - } - - EmptyState.Visibility = Visibility.Collapsed; - PlanScrollViewer.Visibility = Visibility.Visible; - - // Populate statement selector - if (allStatements.Count > 1) - { - StatementSelector.Items.Clear(); - for (int i = 0; i < allStatements.Count; i++) - { - var s = allStatements[i]; - var text = s.StatementText.Length > 80 - ? s.StatementText[..80] + "..." - : s.StatementText; - if (string.IsNullOrWhiteSpace(text)) - text = $"Statement {i + 1}"; - StatementSelector.Items.Add(new ComboBoxItem - { - Content = $"[{s.StatementSubTreeCost:F4}] {text}", - Tag = i - }); - } - StatementSelector.SelectedIndex = 0; - StatementLabel.Visibility = Visibility.Visible; - StatementSelector.Visibility = Visibility.Visible; - CostText.Visibility = Visibility.Visible; - } - else - { - StatementLabel.Visibility = Visibility.Collapsed; - StatementSelector.Visibility = Visibility.Collapsed; - CostText.Visibility = Visibility.Collapsed; - RenderStatement(allStatements[0]); - } - } - - public void Clear() - { - PlanCanvas.Children.Clear(); - _currentPlan = null; - _currentStatement = null; - _selectedNodeBorder = null; - EmptyState.Visibility = Visibility.Visible; - PlanScrollViewer.Visibility = Visibility.Collapsed; - InsightsPanel.Visibility = Visibility.Collapsed; - StatementLabel.Visibility = Visibility.Collapsed; - StatementSelector.Visibility = Visibility.Collapsed; - CostText.Text = ""; - CostText.Visibility = Visibility.Collapsed; - ClosePropertiesPanel(); - } - - private void RenderStatement(PlanStatement statement) - { - _currentStatement = statement; - PlanCanvas.Children.Clear(); - _selectedNodeBorder = null; - PlanScrollViewer.ScrollToHome(); - - 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 - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, allWarnings.Count); - - // Update insights panel - ShowMissingIndexes(statement.MissingIndexes); - ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); - ShowRuntimeSummary(statement); - UpdateInsightsHeader(); - - // Update cost text - CostText.Text = $"Statement Cost: {statement.StatementSubTreeCost:F4}"; - } - - #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 border = new Border - { - Width = PlanLayoutEngine.NodeWidth, - MinHeight = PlanLayoutEngine.NodeHeightMin, - Background = isExpensive - ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) - : (Brush)FindResource("BackgroundLightBrush"), - BorderBrush = isExpensive - ? Brushes.OrangeRed - : (Brush)FindResource("BorderBrush"), - BorderThickness = new Thickness(isExpensive ? 2 : 1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 4, 6, 4), - Cursor = Cursors.Hand, - SnapsToDevicePixels = true, - Tag = node - }; - - // Tooltip — root node includes statement-level PlanWarnings - if (totalWarningCount > 0 && _currentStatement != null) - { - var allWarnings = new List(); - allWarnings.AddRange(_currentStatement.PlanWarnings); - CollectWarnings(node, allWarnings); - border.ToolTip = BuildNodeTooltip(node, allWarnings); - } - else - { - border.ToolTip = BuildNodeTooltip(node); - } - - // Click to select + show properties - border.MouseLeftButtonUp += 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 icon = PlanIconMapper.GetIcon(node.IconName); - if (icon != null) - { - iconRow.Children.Add(new Image - { - Source = icon, - 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 Polygon - { - Points = new PointCollection - { - new Point(10, 0), new Point(20, 18), new Point(0, 18) - }, - Fill = Brushes.Orange - }); - warnBadge.Children.Add(new TextBlock - { - Text = "!", - FontSize = 12, - FontWeight = FontWeights.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 = FontWeights.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, 4, 1), - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = $"+{node.NonClusteredIndexCount} NC", - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.White - } - }; - iconRow.Children.Add(ncBadge); - } - - stack.Children.Add(iconRow); - - // Operator name — use full name, let TextTrimming handle overflow - stack.Children.Add(new TextBlock - { - Text = node.PhysicalOp, - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = (Brush)FindResource("ForegroundBrush"), - TextAlignment = TextAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Cost percentage - var costColor = node.CostPercent >= 50 ? Brushes.OrangeRed - : node.CostPercent >= 25 ? Brushes.Orange - : (Brush)FindResource("ForegroundBrush"); - - 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) - { - var fgBrush = (Brush)FindResource("ForegroundBrush"); - - // Elapsed time — red if >= 1 second - var elapsedSec = node.ActualElapsedMs / 1000.0; - var elapsedBrush = elapsedSec >= 1.0 ? Brushes.OrangeRed : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"{elapsedSec:F3}s", - FontSize = 10, - Foreground = elapsedBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // CPU time — red if >= 1 second - var cpuSec = node.ActualCPUMs / 1000.0; - var cpuBrush = cpuSec >= 1.0 ? Brushes.OrangeRed : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"CPU: {cpuSec:F3}s", - FontSize = 9, - Foreground = cpuBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Actual rows per execution vs Estimated rows (accuracy %) — red if off by 10x+ - var estRows = node.EstimateRows; - var actualRowsPerExec = node.ActualExecutions > 0 ? node.ActualRows / (double)node.ActualExecutions : node.ActualRows; - var accuracyRatio = estRows > 0 ? actualRowsPerExec / estRows : (actualRowsPerExec > 0 ? double.MaxValue : 1.0); - var rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? Brushes.OrangeRed : fgBrush; - var accuracy = estRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; - stack.Children.Add(new TextBlock - { - Text = $"{actualRowsPerExec:N0} of {estRows:N0}{accuracy}", - FontSize = 9, - Foreground = rowBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16 - }); - } - - // Object name — show full object name, use ellipsis for overflow - if (!string.IsNullOrEmpty(node.ObjectName)) - { - stack.Children.Add(new TextBlock - { - Text = node.FullObjectName ?? node.ObjectName, - FontSize = 9, - Foreground = (Brush)FindResource("ForegroundBrush"), - TextAlignment = TextAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center, - ToolTip = node.FullObjectName ?? node.ObjectName - }); - } - - // 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 = FontWeights.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 WpfPath 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(new Point(midX, parentCenterY), true)); - figure.Segments.Add(new LineSegment(new Point(midX, childCenterY), true)); - figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true)); - geometry.Figures.Add(figure); - - return new WpfPath - { - Data = geometry, - Stroke = EdgeBrush, - StrokeThickness = thickness, - StrokeLineJoin = PenLineJoin.Round, - ToolTip = BuildEdgeTooltipContent(child), - SnapsToDevicePixels = true - }; - } - - private Border BuildEdgeTooltipContent(PlanNode child) - { - var grid = new Grid { MinWidth = 240 }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - int row = 0; - var bgBrush = TooltipBgBrush; - var borderBrush = TooltipBorderBrush; - var mutedBrush = MutedBrush; - var fgBrush = TooltipFgBrush; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - var lbl = new TextBlock - { - Text = label, - Foreground = mutedBrush, - FontSize = 12, - Margin = new Thickness(0, 1, 12, 1) - }; - var val = new TextBlock - { - Text = value, - Foreground = fgBrush, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(lbl, row); - Grid.SetColumn(lbl, 0); - Grid.SetRow(val, row); - Grid.SetColumn(val, 1); - grid.Children.Add(lbl); - grid.Children.Add(val); - 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 = bgBrush, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6, 10, 6), - CornerRadius = new CornerRadius(4), - Child = grid - }; - } - - 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"; - } - - #endregion - - #region Node Selection & Properties Panel - - private void Node_Click(object sender, MouseButtonEventArgs e) - { - if (sender is Border border && border.Tag is PlanNode 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; - _selectedNode = node; - 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 += (_, _) => - { - // Find the border for this node by checking Tags - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && b.Tag == node) - { - SelectNode(b, node); - break; - } - } - }; - menu.Items.Add(propsItem); - - menu.Items.Add(new Separator()); - - var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; - copyOpItem.Click += (_, _) => Clipboard.SetDataObject(node.PhysicalOp, false); - menu.Items.Add(copyOpItem); - - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - var copyObjItem = new MenuItem { Header = "Copy Object Name" }; - copyObjItem.Click += (_, _) => Clipboard.SetDataObject(node.FullObjectName, false); - menu.Items.Add(copyObjItem); - } - - if (!string.IsNullOrEmpty(node.Predicate)) - { - var copyPredItem = new MenuItem { Header = "Copy Predicate" }; - copyPredItem.Click += (_, _) => Clipboard.SetDataObject(node.Predicate, false); - menu.Items.Add(copyPredItem); - } - - if (!string.IsNullOrEmpty(node.SeekPredicates)) - { - var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; - copySeekItem.Click += (_, _) => Clipboard.SetDataObject(node.SeekPredicates, false); - menu.Items.Add(copySeekItem); - } - - return menu; - } - - private void ShowPropertiesPanel(PlanNode node) - { - PropertiesContent.Children.Clear(); - _currentPropertySection = null; - - // 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 — always show for operators that have object references - 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 Matching 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) - { - AddPropertySection("Plan Warnings"); - foreach (var w in s.PlanWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - warnPanel.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}", - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel); - } - } - - // === 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) - { - AddPropertySection("Warnings"); - foreach (var w in node.Warnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - warnPanel.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}", - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - PropertiesContent.Children.Add(warnPanel); - } - } - - // Show the panel - PropertiesColumn.Width = new GridLength(320); - PropertiesSplitter.Visibility = Visibility.Visible; - PropertiesPanel.Visibility = Visibility.Visible; - } - - private void AddPropertySection(string title) - { - var contentPanel = new StackPanel(); - var expander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = title, - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = contentPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1) - }; - PropertiesContent.Children.Add(expander); - _currentPropertySection = contentPanel; - } - - private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) - { - var grid = new Grid { Margin = new Thickness(10, 3, 10, 3) }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var labelBlock = new TextBlock - { - Text = label, - FontSize = indent ? 10 : 11, - Foreground = MutedBrush, - VerticalAlignment = VerticalAlignment.Top, - TextWrapping = TextWrapping.Wrap, - Margin = indent ? new Thickness(16, 0, 0, 0) : new Thickness(0) - }; - Grid.SetColumn(labelBlock, 0); - grid.Children.Add(labelBlock); - - var valueBlock = new TextBlock - { - Text = value, - FontSize = indent ? 10 : 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap - }; - if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBlock, 1); - grid.Children.Add(valueBlock); - - var target = _currentPropertySection ?? PropertiesContent; - target.Children.Add(grid); - } - - private void CloseProperties_Click(object sender, RoutedEventArgs e) - { - ClosePropertiesPanel(); - } - - private void ClosePropertiesPanel() - { - PropertiesPanel.Visibility = Visibility.Collapsed; - PropertiesSplitter.Visibility = Visibility.Collapsed; - PropertiesColumn.Width = new GridLength(0); - - // Deselect node - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - _selectedNodeBorder = null; - } - _selectedNode = null; - } - - #endregion - - #region Tooltips - - private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null) - { - var tip = new ToolTip - { - Background = TooltipBgBrush, - BorderBrush = TooltipBorderBrush, - Foreground = TooltipFgBrush, - 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 = FontWeights.Bold, - FontSize = 13, - 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}"); - } - - // 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 — show full qualified name - 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 (includes statement-level) 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 - var distinct = warnings - .GroupBy(w => w.WarningType) - .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count())) - .OrderByDescending(g => g.MaxSeverity) - .ThenBy(g => g.Type); - - foreach (var (type, severity, count) in distinct) - { - var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" - : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}"; - stack.Children.Add(new TextBlock - { - Text = label, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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)ColorConverter.ConvertFromString(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 = FontStyles.Italic, - Foreground = MutedBrush, - Margin = new Thickness(0, 8, 0, 0) - }); - - tip.Content = stack; - return tip; - } - - private void AddTooltipSection(StackPanel parent, string title) - { - parent.Children.Add(new TextBlock - { - Text = title, - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = SectionHeaderBrush, - Margin = new Thickness(0, 6, 0, 2) - }); - } - - private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) - { - var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; - row.Children.Add(new TextBlock - { - Text = $"{label}: ", - Foreground = MutedBrush, - FontSize = 11, - MinWidth = 120 - }); - var valueBlock = new TextBlock - { - Text = value, - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 350 - }; - if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); - row.Children.Add(valueBlock); - parent.Children.Add(row); - } - - #endregion - - #region Insights Panel - - private void ShowMissingIndexes(List indexes) - { - MissingIndexContent.Children.Clear(); - - if (indexes.Count > 0) - { - MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; - - 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 = FontWeights.SemiBold, - Foreground = TooltipFgBrush, - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $" \u2014 Impact: ", - Foreground = MutedBrush, - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $"{mi.Impact:F1}%", - Foreground = OrangeBrush, - FontSize = 12 - }); - itemPanel.Children.Add(headerRow); - - if (!string.IsNullOrEmpty(mi.CreateStatement)) - { - itemPanel.Children.Add(new TextBox - { - Text = mi.CreateStatement, - FontFamily = new FontFamily("Consolas"), - FontSize = 11, - Foreground = TooltipFgBrush, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - IsReadOnly = true, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(12, 2, 0, 0) - }); - } - - MissingIndexContent.Children.Add(itemPanel); - } - - MissingIndexEmpty.Visibility = Visibility.Collapsed; - } - else - { - MissingIndexHeader.Text = "Missing Index Suggestions"; - MissingIndexEmpty.Visibility = Visibility.Visible; - } - } - - private static void CollectWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectWarnings(child, warnings); - } - - private void ShowWaitStats(List waits, 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.Visibility = Visibility.Visible; - return; - } - - WaitStatsEmpty.Visibility = Visibility.Collapsed; - - var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); - var maxWait = sorted[0].WaitTimeMs; - var totalWait = sorted.Sum(w => w.WaitTimeMs); - - WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; - - var longestName = sorted.Max(w => w.WaitType.Length); - var nameColWidth = longestName * 6.5 + 10; - - var maxBarWidth = 300; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(nameColWidth) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(maxBarWidth + 16) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - for (int i = 0; i < sorted.Count; i++) - grid.RowDefinitions.Add(new RowDefinition { Height = 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)); - - var nameText = new TextBlock - { - Text = w.WaitType, - FontSize = 12, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 10, 2) - }; - Grid.SetRow(nameText, i); - Grid.SetColumn(nameText, 0); - grid.Children.Add(nameText); - - var colorBar = new Border - { - Width = Math.Max(4, barFraction * maxBarWidth), - Height = 14, - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)), - 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); - - var durationText = new TextBlock - { - Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", - FontSize = 12, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) - }; - Grid.SetRow(durationText, i); - Grid.SetColumn(durationText, 2); - grid.Children.Add(durationText); - } - - WaitStatsContent.Children.Add(grid); - } - - private static string GetWaitCategory(string waitType) - { - if (waitType.StartsWith("SOS_SCHEDULER_YIELD", StringComparison.Ordinal) || - waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || - waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) || - waitType.StartsWith("CXSYNC_PORT", StringComparison.Ordinal) || - waitType.StartsWith("CXSYNC_CONSUMER", StringComparison.Ordinal)) - return "CPU"; - - if (waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || - waitType.StartsWith("WRITELOG", StringComparison.Ordinal) || - waitType.StartsWith("IO_COMPLETION", StringComparison.Ordinal) || - waitType.StartsWith("ASYNC_IO_COMPLETION", StringComparison.Ordinal)) - return "I/O"; - - if (waitType.StartsWith("LCK_M_", StringComparison.Ordinal)) - 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" - }; - } - - private void ShowRuntimeSummary(PlanStatement statement) - { - RuntimeSummaryContent.Children.Clear(); - - var labelBrush = MutedBrush; - var valueBrush = TooltipFgBrush; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - int rowIndex = 0; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - var labelText = new TextBlock - { - Text = label, - FontSize = 11, - Foreground = labelBrush, - 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 = valueBrush, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(valueText, rowIndex); - Grid.SetColumn(valueText, 1); - grid.Children.Add(valueText); - - rowIndex++; - } - - 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"); - } - - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used"); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms"); - } - - if (statement.DegreeOfParallelism > 0) - AddRow("DOP", statement.DegreeOfParallelism.ToString()); - else if (statement.NonParallelPlanReason != null) - AddRow("Serial", statement.NonParallelPlanReason); - - 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 threadText = ts.UsedThreads == totalReserved - ? $"{ts.UsedThreads} used ({totalReserved} reserved)" - : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; - AddRow("Threads", threadText); - } - else - { - AddRow("Threads", $"{ts.UsedThreads} used"); - } - } - - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - - RuntimeSummaryContent.Children.Add(grid); - } - - /// - /// Formats a memory value given in KB to a human-readable string. - /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals). - /// - private static string FormatMemoryGrantKB(long kb) - { - if (kb < 1024) - return $"{kb:N0} KB"; - if (kb < 1024 * 1024) - return $"{kb / 1024.0:N1} MB"; - return $"{kb / (1024.0 * 1024.0):N2} GB"; - } - - private void UpdateInsightsHeader() - { - InsightsPanel.Visibility = Visibility.Visible; - InsightsHeader.Text = " Plan Insights"; - } - - #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.ActualWidth; - var viewHeight = PlanScrollViewer.ActualHeight; - if (viewWidth <= 0 || viewHeight <= 0) return; - - var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); - SetZoom(Math.Min(fitZoom, 1.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)}%"; - } - - private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) - { - if (Keyboard.Modifiers == ModifierKeys.Control) - { - e.Handled = true; - SetZoom(_zoomLevel + (e.Delta > 0 ? ZoomStep : -ZoomStep)); - } - } - - private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) - { - // Don't steal focus from interactive controls (ComboBox, TextBox, Button, etc.) - // ComboBox dropdown items live in a separate visual tree (Popup), so also check - // for ComboBoxItem to avoid stealing focus when selecting dropdown items. - if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase - || e.OriginalSource is ComboBox - || e.OriginalSource is ComboBoxItem - || FindVisualParent(e.OriginalSource as DependencyObject) != null - || FindVisualParent(e.OriginalSource as DependencyObject) != null - || FindVisualParent(e.OriginalSource as DependencyObject) != null) - return; - - Focus(); - } - - private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject - { - while (child != null) - { - if (child is T parent) return parent; - child = VisualTreeHelper.GetParent(child); - } - return null; - } - - private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control - && e.OriginalSource is not TextBox) - { - var text = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(text)) - { - e.Handled = true; - try - { - System.Xml.Linq.XDocument.Parse(text); - } - catch (System.Xml.XmlException ex) - { - MessageBox.Show( - $"The plan XML is not valid:\n\n{ex.Message}", - "Invalid Plan XML", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - LoadPlan(text, "Pasted Plan"); - } - } - } - - #endregion - - #region Canvas Panning - - private void PlanScrollViewer_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - // Don't intercept scrollbar interactions - if (IsScrollBarAtPoint(e)) - return; - - // Don't pan if clicking on a node - if (IsNodeAtPoint(e)) - return; - - _isPanning = true; - _panStart = e.GetPosition(PlanScrollViewer); - _panStartOffsetX = PlanScrollViewer.HorizontalOffset; - _panStartOffsetY = PlanScrollViewer.VerticalOffset; - PlanScrollViewer.Cursor = Cursors.SizeAll; - PlanScrollViewer.CaptureMouse(); - e.Handled = true; - } - - private void PlanScrollViewer_PreviewMouseMove(object sender, MouseEventArgs e) - { - if (!_isPanning) return; - - var current = e.GetPosition(PlanScrollViewer); - var dx = current.X - _panStart.X; - var dy = current.Y - _panStart.Y; - - PlanScrollViewer.ScrollToHorizontalOffset(Math.Max(0, _panStartOffsetX - dx)); - PlanScrollViewer.ScrollToVerticalOffset(Math.Max(0, _panStartOffsetY - dy)); - e.Handled = true; - } - - private void PlanScrollViewer_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - if (!_isPanning) return; - _isPanning = false; - PlanScrollViewer.Cursor = Cursors.Arrow; - PlanScrollViewer.ReleaseMouseCapture(); - e.Handled = true; - } - - /// Check if the mouse event originated from a ScrollBar. - private static bool IsScrollBarAtPoint(MouseButtonEventArgs e) - { - var source = e.OriginalSource as DependencyObject; - while (source != null) - { - if (source is System.Windows.Controls.Primitives.ScrollBar) - return true; - source = VisualTreeHelper.GetParent(source); - } - return false; - } - - /// Check if the mouse event originated from a node Border (has PlanNode in Tag). - private static bool IsNodeAtPoint(MouseButtonEventArgs e) - { - var source = e.OriginalSource as DependencyObject; - while (source != null) - { - if (source is Border b && b.Tag is PlanNode) - return true; - source = VisualTreeHelper.GetParent(source); - } - return false; - } - - #endregion - - #region Save & Statement Selection - - private void SavePlan_Click(object sender, RoutedEventArgs e) - { - if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", - DefaultExt = ".sqlplan", - FileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" - }; - - if (dialog.ShowDialog() == true) - { - File.WriteAllText(dialog.FileName, _currentPlan.RawXml); - } - } - - private void StatementSelector_Changed(object sender, SelectionChangedEventArgs e) - { - if (StatementSelector.SelectedItem is ComboBoxItem item && item.Tag is int index) - { - var allStatements = _currentPlan?.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .ToList(); - - if (allStatements != null && index >= 0 && index < allStatements.Count) - RenderStatement(allStatements[index]); - } - } - - #endregion -} +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class PlanViewerControl : UserControl +{ + private ParsedPlan? _currentPlan; + private PlanStatement? _currentStatement; + 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 = ""; + + // Node selection + private Border? _selectedNodeBorder; + private Brush? _selectedNodeOriginalBorder; + private Thickness _selectedNodeOriginalThickness; + private PlanNode? _selectedNode; + + // Brushes — accent/neutral tones that suit every theme + private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); + private static readonly SolidColorBrush OrangeBrush = new(Color.FromRgb(0xFF, 0xB3, 0x47)); + + // Theme-aware brushes resolved at call time from Application.Resources + private SolidColorBrush TooltipBgBrush => + (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23)); + private SolidColorBrush TooltipBorderBrush => + (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)); + private SolidColorBrush TooltipFgBrush => + (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush MutedBrush => + (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush SectionHeaderBrush => + (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private SolidColorBrush PropSeparatorBrush => + (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35)); + + // Current property section for collapsible groups + private StackPanel? _currentPropertySection; + + // Canvas panning + private bool _isPanning; + private Point _panStart; + private double _panStartOffsetX; + private double _panStartOffsetY; + + public PlanViewerControl() + { + InitializeComponent(); + Helpers.ThemeManager.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(string _) + { + if (_currentStatement == null) return; + + var nodeToRestore = _selectedNode; + RenderStatement(_currentStatement); + + if (nodeToRestore == null) return; + + // Find the re-created border for the previously selected node and reopen properties + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && b.Tag == nodeToRestore) + { + SelectNode(b, nodeToRestore); + break; + } + } + } + + public void LoadPlan(string planXml, string label, string? queryText = null) + { + _label = label; + + if (!string.IsNullOrEmpty(queryText)) + { + QueryTextBox.Text = queryText; + QueryTextExpander.Visibility = Visibility.Visible; + } + else + { + QueryTextExpander.Visibility = Visibility.Collapsed; + } + _currentPlan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(_currentPlan); + + var allStatements = _currentPlan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .ToList(); + + if (allStatements.Count == 0) + { + EmptyState.Visibility = Visibility.Visible; + PlanScrollViewer.Visibility = Visibility.Collapsed; + return; + } + + EmptyState.Visibility = Visibility.Collapsed; + PlanScrollViewer.Visibility = Visibility.Visible; + + // Populate statement selector + if (allStatements.Count > 1) + { + StatementSelector.Items.Clear(); + for (int i = 0; i < allStatements.Count; i++) + { + var s = allStatements[i]; + var text = s.StatementText.Length > 80 + ? s.StatementText[..80] + "..." + : s.StatementText; + if (string.IsNullOrWhiteSpace(text)) + text = $"Statement {i + 1}"; + StatementSelector.Items.Add(new ComboBoxItem + { + Content = $"[{s.StatementSubTreeCost:F4}] {text}", + Tag = i + }); + } + StatementSelector.SelectedIndex = 0; + StatementLabel.Visibility = Visibility.Visible; + StatementSelector.Visibility = Visibility.Visible; + CostText.Visibility = Visibility.Visible; + } + else + { + StatementLabel.Visibility = Visibility.Collapsed; + StatementSelector.Visibility = Visibility.Collapsed; + CostText.Visibility = Visibility.Collapsed; + RenderStatement(allStatements[0]); + } + } + + public void Clear() + { + PlanCanvas.Children.Clear(); + _currentPlan = null; + _currentStatement = null; + _selectedNodeBorder = null; + EmptyState.Visibility = Visibility.Visible; + PlanScrollViewer.Visibility = Visibility.Collapsed; + InsightsPanel.Visibility = Visibility.Collapsed; + StatementLabel.Visibility = Visibility.Collapsed; + StatementSelector.Visibility = Visibility.Collapsed; + CostText.Text = ""; + CostText.Visibility = Visibility.Collapsed; + ClosePropertiesPanel(); + } + + private static void CollectWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectWarnings(child, warnings); + } + + private void SavePlan_Click(object sender, RoutedEventArgs e) + { + if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; + + var dialog = new SaveFileDialog + { + Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() == true) + { + File.WriteAllText(dialog.FileName, _currentPlan.RawXml); + } + } + + private void StatementSelector_Changed(object sender, SelectionChangedEventArgs e) + { + if (StatementSelector.SelectedItem is ComboBoxItem item && item.Tag is int index) + { + var allStatements = _currentPlan?.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .ToList(); + + if (allStatements != null && index >= 0 && index < allStatements.Count) + RenderStatement(allStatements[index]); + } + } +} diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs new file mode 100644 index 0000000..d64d58c --- /dev/null +++ b/Lite/Controls/ServerTab.Charts.cs @@ -0,0 +1,1479 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using ScottPlot; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + private static readonly string[] SeriesColors = new[] + { + "#4FC3F7", "#E57373", "#81C784", "#FFD54F", "#BA68C8", + "#FFB74D", "#4DD0E1", "#F06292", "#AED581", "#7986CB", + "#FFF176", "#A1887F", "#FF7043", "#80DEEA", "#FFE082", + "#CE93D8", "#EF9A9A", "#C5E1A5", "#FFCC80", "#B0BEC5" + }; + + private void UpdateMemorySummary(MemoryStatsRow? stats) + { + if (stats == null) + { + PhysicalMemoryText.Text = "--"; + AvailablePhysicalMemoryText.Text = "--"; + TotalServerMemoryText.Text = "--"; + TargetServerMemoryText.Text = "--"; + BufferPoolText.Text = "--"; + PlanCacheText.Text = "--"; + TotalPageFileText.Text = "--"; + AvailablePageFileText.Text = "--"; + MemoryStateText.Text = "--"; + SqlMemoryModelText.Text = "--"; + return; + } + + PhysicalMemoryText.Text = FormatMb(stats.TotalPhysicalMemoryMb); + AvailablePhysicalMemoryText.Text = FormatMb(stats.AvailablePhysicalMemoryMb); + TotalServerMemoryText.Text = FormatMb(stats.TotalServerMemoryMb); + TargetServerMemoryText.Text = FormatMb(stats.TargetServerMemoryMb); + BufferPoolText.Text = FormatMb(stats.BufferPoolMb); + PlanCacheText.Text = FormatMb(stats.PlanCacheMb); + TotalPageFileText.Text = FormatMb(stats.TotalPageFileMb); + AvailablePageFileText.Text = FormatMb(stats.AvailablePageFileMb); + MemoryStateText.Text = stats.SystemMemoryState; + SqlMemoryModelText.Text = stats.SqlMemoryModel; + } + + private static string FormatMb(double mb) + { + return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB"; + } + + + private void UpdateCpuChart(List data) + { + ClearChart(CpuChart); + _cpuHover?.Clear(); + ApplyTheme(CpuChart); + + if (data.Count == 0) { CpuChart.Refresh(); return; } + + var times = data.Select(d => d.SampleTime.ToOADate()).ToArray(); + var sqlCpu = data.Select(d => (double)d.SqlServerCpu).ToArray(); + var otherCpu = data.Select(d => (double)d.OtherProcessCpu).ToArray(); + + var sqlPlot = CpuChart.Plot.Add.Scatter(times, sqlCpu); + sqlPlot.LegendText = "SQL Server"; + sqlPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); + _cpuHover?.Add(sqlPlot, "SQL Server"); + + var otherPlot = CpuChart.Plot.Add.Scatter(times, otherCpu); + otherPlot.LegendText = "Other"; + otherPlot.Color = ScottPlot.Color.FromHex("#E57373"); + _cpuHover?.Add(otherPlot, "Other"); + + CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(CpuChart); + CpuChart.Plot.YLabel("CPU %"); + CpuChart.Plot.Axes.SetLimitsY(0, 105); + + ShowChartLegend(CpuChart); + CpuChart.Refresh(); + } + + private void UpdateMemoryChart(List data, List grantData) + { + ClearChart(MemoryChart); + _memoryHover?.Clear(); + ApplyTheme(MemoryChart); + + if (data.Count == 0) { MemoryChart.Refresh(); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var totalMem = data.Select(d => d.TotalServerMemoryMb / 1024.0).ToArray(); + var targetMem = data.Select(d => d.TargetServerMemoryMb / 1024.0).ToArray(); + var bufferPool = data.Select(d => d.BufferPoolMb / 1024.0).ToArray(); + + var totalPlot = MemoryChart.Plot.Add.Scatter(times, totalMem); + totalPlot.LegendText = "Total Server Memory"; + totalPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); + _memoryHover?.Add(totalPlot, "Total Server Memory"); + + var targetPlot = MemoryChart.Plot.Add.Scatter(times, targetMem); + targetPlot.LegendText = "Target Memory"; + targetPlot.Color = ScottPlot.Colors.Gray; + targetPlot.LineStyle.Pattern = LinePattern.Dashed; + _memoryHover?.Add(targetPlot, "Target Memory"); + + var bpPlot = MemoryChart.Plot.Add.Scatter(times, bufferPool); + bpPlot.LegendText = "Buffer Pool"; + bpPlot.Color = ScottPlot.Color.FromHex("#81C784"); + _memoryHover?.Add(bpPlot, "Buffer Pool"); + + /* Memory grants trend line — show zero line when no grant data */ + double[] grantTimes, grantMb; + if (grantData.Count > 0) + { + grantTimes = grantData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + grantMb = grantData.Select(d => d.TotalGrantedMb / 1024.0).ToArray(); + } + else + { + grantTimes = new[] { times.First(), times.Last() }; + grantMb = new[] { 0.0, 0.0 }; + } + + var grantPlot = MemoryChart.Plot.Add.Scatter(grantTimes, grantMb); + grantPlot.LegendText = "Memory Grants"; + grantPlot.Color = ScottPlot.Color.FromHex("#FFB74D"); + _memoryHover?.Add(grantPlot, "Memory Grants"); + + MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(MemoryChart); + MemoryChart.Plot.YLabel("Memory (GB)"); + + var maxVal = totalMem.Max(); + SetChartYLimitsWithLegendPadding(MemoryChart, 0, maxVal); + + ShowChartLegend(MemoryChart); + MemoryChart.Refresh(); + } + + private void UpdateMemoryGrantCharts(List data) + { + ClearChart(MemoryGrantSizingChart); + ClearChart(MemoryGrantActivityChart); + _memoryGrantSizingHover?.Clear(); + _memoryGrantActivityHover?.Clear(); + ApplyTheme(MemoryGrantSizingChart); + ApplyTheme(MemoryGrantActivityChart); + + if (data.Count == 0) + { + MemoryGrantSizingChart.Refresh(); + MemoryGrantActivityChart.Refresh(); + return; + } + + var poolIds = data.Select(d => d.PoolId).Distinct().OrderBy(p => p).ToList(); + int colorIndex = 0; + + /* Chart 1: Memory Grant Sizing — Available, Granted, Used MB per pool */ + double sizingMax = 0; + var sizingMetrics = new (string Name, Func Selector)[] + { + ("Available MB", d => d.AvailableMemoryMb), + ("Granted MB", d => d.GrantedMemoryMb), + ("Used MB", d => d.UsedMemoryMb) + }; + + foreach (var poolId in poolIds) + { + var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + var times = poolData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + + foreach (var metric in sizingMetrics) + { + var values = poolData.Select(d => metric.Selector(d)).ToArray(); + var plot = MemoryGrantSizingChart.Plot.Add.Scatter(times, values); + var label = $"Pool {poolId}: {metric.Name}"; + plot.LegendText = label; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); + _memoryGrantSizingHover?.Add(plot, label); + if (values.Length > 0) sizingMax = Math.Max(sizingMax, values.Max()); + colorIndex++; + } + } + + MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(MemoryGrantSizingChart); + MemoryGrantSizingChart.Plot.YLabel("Memory (MB)"); + SetChartYLimitsWithLegendPadding(MemoryGrantSizingChart, 0, sizingMax > 0 ? sizingMax : 100); + ShowChartLegend(MemoryGrantSizingChart); + MemoryGrantSizingChart.Refresh(); + + /* Chart 2: Memory Grant Activity — Grantees, Waiters, Timeouts, Forced per pool */ + double activityMax = 0; + colorIndex = 0; + var activityMetrics = new (string Name, Func Selector)[] + { + ("Grantees", d => d.GranteeCount), + ("Waiters", d => d.WaiterCount), + ("Timeouts", d => d.TimeoutErrorCountDelta), + ("Forced Grants", d => d.ForcedGrantCountDelta) + }; + + foreach (var poolId in poolIds) + { + var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + var times = poolData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + + foreach (var metric in activityMetrics) + { + var values = poolData.Select(d => metric.Selector(d)).ToArray(); + var plot = MemoryGrantActivityChart.Plot.Add.Scatter(times, values); + var label = $"Pool {poolId}: {metric.Name}"; + plot.LegendText = label; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); + _memoryGrantActivityHover?.Add(plot, label); + if (values.Length > 0) activityMax = Math.Max(activityMax, values.Max()); + colorIndex++; + } + } + + MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(MemoryGrantActivityChart); + MemoryGrantActivityChart.Plot.YLabel("Count"); + SetChartYLimitsWithLegendPadding(MemoryGrantActivityChart, 0, activityMax > 0 ? activityMax : 10); + ShowChartLegend(MemoryGrantActivityChart); + MemoryGrantActivityChart.Refresh(); + } + + /// + /// Stacked bar chart of memory pressure events per hour, split by SQL Server (process) vs + /// Operating System (system) and stacked by severity (medium=indicator 2, severe=indicator >= 3). + /// + private void UpdateMemoryPressureEventsChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(MemoryPressureEventsChart); + _memoryPressureEventsHover?.Clear(); + ApplyTheme(MemoryPressureEventsChart); + + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + /* Only count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). */ + var pressureRows = data + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList(); + + bool hasData = false; + int maxBarCount = 0; + + if (pressureRows.Count > 0) + { + var grouped = pressureRows + .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) + .OrderBy(g => g.Key) + .ToList(); + + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 + var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 + var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 + var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 + + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = g.Key.AddMinutes(UtcOffsetMinutes).ToOADate(); + + if (sqlMedium > 0) + sqlMediumBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = 0, Value = sqlMedium, Size = barSize, FillColor = sqlMediumColor, LineWidth = 0 }); + if (sqlSevere > 0) + sqlSevereBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = sqlMedium, Value = sqlMedium + sqlSevere, Size = barSize, FillColor = sqlSevereColor, LineWidth = 0 }); + if (osMedium > 0) + osMediumBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = 0, Value = osMedium, Size = barSize, FillColor = osMediumColor, LineWidth = 0 }); + if (osSevere > 0) + osSevereBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = osMedium, Value = osMedium + osSevere, Size = barSize, FillColor = osSevereColor, LineWidth = 0 }); + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + if (sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 || osMediumBars.Count > 0 || osSevereBars.Count > 0) + { + hasData = true; + + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } + } + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryPressureEventsChart); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + SetChartYLimitsWithLegendPadding(MemoryPressureEventsChart, 0, Math.Max(maxBarCount, 5)); + + if (hasData) + { + ShowChartLegend(MemoryPressureEventsChart); + } + + MemoryPressureEventsChart.Refresh(); + } + + private void UpdateTempDbChart(List data) + { + ClearChart(TempDbChart); + _tempDbHover?.Clear(); + ApplyTheme(TempDbChart); + + if (data.Count == 0) { TempDbChart.Refresh(); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var userObj = data.Select(d => d.UserObjectReservedMb).ToArray(); + var internalObj = data.Select(d => d.InternalObjectReservedMb).ToArray(); + var versionStore = data.Select(d => d.VersionStoreReservedMb).ToArray(); + + var userPlot = TempDbChart.Plot.Add.Scatter(times, userObj); + userPlot.LegendText = "User Objects"; + userPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); + _tempDbHover?.Add(userPlot, "User Objects"); + + var internalPlot = TempDbChart.Plot.Add.Scatter(times, internalObj); + internalPlot.LegendText = "Internal Objects"; + internalPlot.Color = ScottPlot.Color.FromHex("#FFD54F"); + _tempDbHover?.Add(internalPlot, "Internal Objects"); + + var vsPlot = TempDbChart.Plot.Add.Scatter(times, versionStore); + vsPlot.LegendText = "Version Store"; + vsPlot.Color = ScottPlot.Color.FromHex("#81C784"); + _tempDbHover?.Add(vsPlot, "Version Store"); + + TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(TempDbChart); + TempDbChart.Plot.YLabel("MB"); + + var maxVal = new[] { userObj.Max(), internalObj.Max(), versionStore.Max() }.Max(); + SetChartYLimitsWithLegendPadding(TempDbChart, 0, maxVal); + + ShowChartLegend(TempDbChart); + TempDbChart.Refresh(); + } + + private void UpdateTempDbFileIoChart(List data) + { + ClearChart(TempDbFileIoChart); + _tempDbFileIoHover?.Clear(); + ApplyTheme(TempDbFileIoChart); + + if (data.Count == 0) { TempDbFileIoChart.Refresh(); return; } + + var files = data + .GroupBy(d => d.DatabaseName) + .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) + .Take(12) + .ToList(); + + double maxLatency = 0; + int colorIdx = 0; + + foreach (var fileGroup in files) + { + var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var latency = points.Select(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (latency.Length > 0) + { + var plot = TempDbFileIoChart.Plot.Add.Scatter(times, latency); + plot.LegendText = fileGroup.Key; + plot.Color = color; + _tempDbFileIoHover?.Add(plot, fileGroup.Key); + maxLatency = Math.Max(maxLatency, latency.Max()); + } + } + + TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(TempDbFileIoChart); + TempDbFileIoChart.Plot.YLabel("TempDB File I/O Latency (ms)"); + SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10); + ShowChartLegend(TempDbFileIoChart); + TempDbFileIoChart.Refresh(); + } + + private void UpdateFileIoCharts(List data) + { + ClearChart(FileIoReadChart); + ClearChart(FileIoWriteChart); + _fileIoReadHover?.Clear(); + _fileIoWriteHover?.Clear(); + ApplyTheme(FileIoReadChart); + ApplyTheme(FileIoWriteChart); + + if (data.Count == 0) { FileIoReadChart.Refresh(); FileIoWriteChart.Refresh(); return; } + + /* Group by file, limit to top 10 by total stall */ + var databases = data + .GroupBy(d => $"{d.DatabaseName}.{d.FileName}") + .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) + .Take(10) + .ToList(); + + double readMax = 0, writeMax = 0; + int colorIdx = 0; + + bool hasQueuedData = data.Any(d => d.AvgQueuedReadLatencyMs > 0 || d.AvgQueuedWriteLatencyMs > 0); + + foreach (var dbGroup in databases) + { + var points = dbGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var readLatency = points.Select(d => d.AvgReadLatencyMs).ToArray(); + var writeLatency = points.Select(d => d.AvgWriteLatencyMs).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (readLatency.Length > 0) + { + var readPlot = FileIoReadChart.Plot.Add.Scatter(times, readLatency); + readPlot.LegendText = dbGroup.Key; + readPlot.Color = color; + _fileIoReadHover?.Add(readPlot, dbGroup.Key); + readMax = Math.Max(readMax, readLatency.Max()); + } + + if (writeLatency.Length > 0) + { + var writePlot = FileIoWriteChart.Plot.Add.Scatter(times, writeLatency); + writePlot.LegendText = dbGroup.Key; + writePlot.Color = color; + _fileIoWriteHover?.Add(writePlot, dbGroup.Key); + writeMax = Math.Max(writeMax, writeLatency.Max()); + } + + /* Queued I/O overlay — dashed lines showing queue wait portion of latency */ + if (hasQueuedData) + { + var queuedReadLatency = points.Select(d => d.AvgQueuedReadLatencyMs).ToArray(); + var queuedWriteLatency = points.Select(d => d.AvgQueuedWriteLatencyMs).ToArray(); + + if (queuedReadLatency.Any(v => v > 0)) + { + var qReadPlot = FileIoReadChart.Plot.Add.Scatter(times, queuedReadLatency); + qReadPlot.LegendText = $"{dbGroup.Key} (queued)"; + qReadPlot.Color = color; + qReadPlot.LinePattern = ScottPlot.LinePattern.Dashed; + _fileIoReadHover?.Add(qReadPlot, $"{dbGroup.Key} (queued)"); + } + + if (queuedWriteLatency.Any(v => v > 0)) + { + var qWritePlot = FileIoWriteChart.Plot.Add.Scatter(times, queuedWriteLatency); + qWritePlot.LegendText = $"{dbGroup.Key} (queued)"; + qWritePlot.Color = color; + qWritePlot.LinePattern = ScottPlot.LinePattern.Dashed; + _fileIoWriteHover?.Add(qWritePlot, $"{dbGroup.Key} (queued)"); + } + } + } + + FileIoReadChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(FileIoReadChart); + FileIoReadChart.Plot.YLabel("Read Latency (ms)"); + SetChartYLimitsWithLegendPadding(FileIoReadChart, 0, readMax > 0 ? readMax : 10); + ShowChartLegend(FileIoReadChart); + FileIoReadChart.Refresh(); + + FileIoWriteChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(FileIoWriteChart); + FileIoWriteChart.Plot.YLabel("Write Latency (ms)"); + SetChartYLimitsWithLegendPadding(FileIoWriteChart, 0, writeMax > 0 ? writeMax : 10); + ShowChartLegend(FileIoWriteChart); + FileIoWriteChart.Refresh(); + } + + private void UpdateFileIoThroughputCharts(List data) + { + ClearChart(FileIoReadThroughputChart); + ClearChart(FileIoWriteThroughputChart); + _fileIoReadThroughputHover?.Clear(); + _fileIoWriteThroughputHover?.Clear(); + ApplyTheme(FileIoReadThroughputChart); + ApplyTheme(FileIoWriteThroughputChart); + + if (data.Count == 0) { FileIoReadThroughputChart.Refresh(); FileIoWriteThroughputChart.Refresh(); return; } + + /* Group by file label, limit to top 10 by total throughput */ + var files = data + .GroupBy(d => d.FileLabel) + .OrderByDescending(g => g.Sum(d => d.ReadMbPerSec + d.WriteMbPerSec)) + .Take(10) + .ToList(); + + double readMax = 0, writeMax = 0; + int colorIdx = 0; + + foreach (var fileGroup in files) + { + var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var readThroughput = points.Select(d => d.ReadMbPerSec).ToArray(); + var writeThroughput = points.Select(d => d.WriteMbPerSec).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (readThroughput.Length > 0) + { + var readPlot = FileIoReadThroughputChart.Plot.Add.Scatter(times, readThroughput); + readPlot.LegendText = fileGroup.Key; + readPlot.Color = color; + _fileIoReadThroughputHover?.Add(readPlot, fileGroup.Key); + readMax = Math.Max(readMax, readThroughput.Max()); + } + + if (writeThroughput.Length > 0) + { + var writePlot = FileIoWriteThroughputChart.Plot.Add.Scatter(times, writeThroughput); + writePlot.LegendText = fileGroup.Key; + writePlot.Color = color; + _fileIoWriteThroughputHover?.Add(writePlot, fileGroup.Key); + writeMax = Math.Max(writeMax, writeThroughput.Max()); + } + } + + FileIoReadThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(FileIoReadThroughputChart); + FileIoReadThroughputChart.Plot.YLabel("Read Throughput (MB/s)"); + SetChartYLimitsWithLegendPadding(FileIoReadThroughputChart, 0, readMax > 0 ? readMax : 1); + ShowChartLegend(FileIoReadThroughputChart); + FileIoReadThroughputChart.Refresh(); + + FileIoWriteThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(FileIoWriteThroughputChart); + FileIoWriteThroughputChart.Plot.YLabel("Write Throughput (MB/s)"); + SetChartYLimitsWithLegendPadding(FileIoWriteThroughputChart, 0, writeMax > 0 ? writeMax : 1); + ShowChartLegend(FileIoWriteThroughputChart); + FileIoWriteThroughputChart.Refresh(); + } + + /* ========== Blocking/Deadlock Trend Charts ========== */ + + private void UpdateLockWaitTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(LockWaitTrendChart); + ApplyTheme(LockWaitTrendChart); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _lockWaitTrendHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = LockWaitTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Lock Waits"; + zeroLine.Color = ScottPlot.Color.FromHex("#4FC3F7"); + zeroLine.MarkerSize = 0; + LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(LockWaitTrendChart); + LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, 1); + ShowChartLegend(LockWaitTrendChart); + LockWaitTrendChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.WaitType).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var times = group.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = group.Select(t => t.WaitTimeMsPerSecond).ToArray(); + + var plot = LockWaitTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _lockWaitTrendHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(LockWaitTrendChart); + LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(LockWaitTrendChart); + LockWaitTrendChart.Refresh(); + } + + private void UpdateBlockingTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(BlockingTrendChart); + ApplyTheme(BlockingTrendChart); + + /* Calculate X-axis range based on selected time window */ + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _blockingTrendHover?.Clear(); + if (data.Count == 0) + { + /* No blocking events — show a flat line at zero so the chart looks active */ + var zeroLine = BlockingTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocking Incidents"; + zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); + zeroLine.MarkerSize = 0; + BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTrendChart); + BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1); + ShowChartLegend(BlockingTrendChart); + BlockingTrendChart.Refresh(); + return; + } + + /* Build arrays with zero baseline between data points for spike effect */ + var expandedTimes = new List(); + var expandedCounts = new List(); + + /* Add zero at start */ + expandedTimes.Add(rangeStart.ToOADate()); + expandedCounts.Add(0); + + foreach (var point in data.OrderBy(d => d.Time)) + { + var time = point.Time.AddMinutes(UtcOffsetMinutes).ToOADate(); + /* Go to zero just before the spike */ + expandedTimes.Add(time - 0.0001); + expandedCounts.Add(0); + /* Spike up */ + expandedTimes.Add(time); + expandedCounts.Add(point.Count); + /* Back to zero just after */ + expandedTimes.Add(time + 0.0001); + expandedCounts.Add(0); + } + + /* Add zero at end */ + expandedTimes.Add(rangeEnd.ToOADate()); + expandedCounts.Add(0); + + var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); + plot.LegendText = "Blocking Incidents"; + plot.Color = ScottPlot.Color.FromHex("#E57373"); + plot.MarkerSize = 0; /* No markers, just lines */ + _blockingTrendHover?.Add(plot, "Blocking Incidents"); + + BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTrendChart); + BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, data.Max(d => d.Count)); + ShowChartLegend(BlockingTrendChart); + BlockingTrendChart.Refresh(); + } + + private void UpdateDeadlockTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(DeadlockTrendChart); + ApplyTheme(DeadlockTrendChart); + + /* Calculate X-axis range based on selected time window */ + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _deadlockTrendHover?.Clear(); + if (data.Count == 0) + { + /* No deadlocks — show a flat line at zero so the chart looks active */ + var zeroLine = DeadlockTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Deadlocks"; + zeroLine.Color = ScottPlot.Color.FromHex("#FFB74D"); + zeroLine.MarkerSize = 0; + DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTrendChart); + DeadlockTrendChart.Plot.YLabel("Deadlocks"); + SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, 1); + ShowChartLegend(DeadlockTrendChart); + DeadlockTrendChart.Refresh(); + return; + } + + /* Build arrays with zero baseline between data points for spike effect */ + var expandedTimes = new List(); + var expandedCounts = new List(); + + /* Add zero at start */ + expandedTimes.Add(rangeStart.ToOADate()); + expandedCounts.Add(0); + + foreach (var point in data.OrderBy(d => d.Time)) + { + var time = point.Time.AddMinutes(UtcOffsetMinutes).ToOADate(); + /* Go to zero just before the spike */ + expandedTimes.Add(time - 0.0001); + expandedCounts.Add(0); + /* Spike up */ + expandedTimes.Add(time); + expandedCounts.Add(point.Count); + /* Back to zero just after */ + expandedTimes.Add(time + 0.0001); + expandedCounts.Add(0); + } + + /* Add zero at end */ + expandedTimes.Add(rangeEnd.ToOADate()); + expandedCounts.Add(0); + + var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); + plot.LegendText = "Deadlocks"; + plot.Color = ScottPlot.Color.FromHex("#FFB74D"); + plot.MarkerSize = 0; /* No markers, just lines */ + _deadlockTrendHover?.Add(plot, "Deadlocks"); + + DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTrendChart); + DeadlockTrendChart.Plot.YLabel("Deadlocks"); + SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, data.Max(d => d.Count)); + ShowChartLegend(DeadlockTrendChart); + DeadlockTrendChart.Refresh(); + } + + /* ========== Current Waits Charts ========== */ + + private void UpdateCurrentWaitsDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(CurrentWaitsDurationChart); + ApplyTheme(CurrentWaitsDurationChart); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _currentWaitsDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Current Waits"; + zeroLine.Color = ScottPlot.Color.FromHex("#4FC3F7"); + zeroLine.MarkerSize = 0; + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.WaitType).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.TotalWaitMs).ToArray(); + + var plot = CurrentWaitsDurationChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.LineWidth = 2; + plot.MarkerSize = 5; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _currentWaitsDurationHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + } + + private void UpdateCurrentWaitsBlockedChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(CurrentWaitsBlockedChart); + ApplyTheme(CurrentWaitsBlockedChart); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _currentWaitsBlockedHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsBlockedChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocked Sessions"; + zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); + zeroLine.MarkerSize = 0; + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.DatabaseName).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.BlockedCount).ToArray(); + + var plot = CurrentWaitsBlockedChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.LineWidth = 2; + plot.MarkerSize = 5; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _currentWaitsBlockedHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + } + + /* ========== Performance Trend Charts ========== */ + + private void UpdateQueryDurationTrendChart(List data) + { + ClearChart(QueryDurationTrendChart); + ApplyTheme(QueryDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _queryDurationTrendHover?.Clear(); + var plot = QueryDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Query Duration"; + plot.Color = ScottPlot.Color.FromHex("#4FC3F7"); + _queryDurationTrendHover?.Add(plot, "Query Duration"); + + QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(QueryDurationTrendChart); + QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max()); + ShowChartLegend(QueryDurationTrendChart); + QueryDurationTrendChart.Refresh(); + } + + private void UpdateProcDurationTrendChart(List data) + { + ClearChart(ProcDurationTrendChart); + ApplyTheme(ProcDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _procDurationTrendHover?.Clear(); + var plot = ProcDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Procedure Duration"; + plot.Color = ScottPlot.Color.FromHex("#81C784"); + _procDurationTrendHover?.Add(plot, "Procedure Duration"); + + ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(ProcDurationTrendChart); + ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max()); + ShowChartLegend(ProcDurationTrendChart); + ProcDurationTrendChart.Refresh(); + } + + private void UpdateQueryStoreDurationTrendChart(List data) + { + ClearChart(QueryStoreDurationTrendChart); + ApplyTheme(QueryStoreDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _queryStoreDurationTrendHover?.Clear(); + var plot = QueryStoreDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Query Store Duration"; + plot.Color = ScottPlot.Color.FromHex("#FFB74D"); + _queryStoreDurationTrendHover?.Add(plot, "Query Store Duration"); + + QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(QueryStoreDurationTrendChart); + QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max()); + ShowChartLegend(QueryStoreDurationTrendChart); + QueryStoreDurationTrendChart.Refresh(); + } + + private void UpdateExecutionCountTrendChart(List data) + { + ClearChart(ExecutionCountTrendChart); + ApplyTheme(ExecutionCountTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(ExecutionCountTrendChart, "Executions", "Executions/sec"); return; } + + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _executionCountTrendHover?.Clear(); + var plot = ExecutionCountTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Executions"; + plot.Color = ScottPlot.Color.FromHex("#BA68C8"); + _executionCountTrendHover?.Add(plot, "Executions"); + + ExecutionCountTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(ExecutionCountTrendChart); + ExecutionCountTrendChart.Plot.YLabel("Executions/sec"); + SetChartYLimitsWithLegendPadding(ExecutionCountTrendChart, 0, values.Max()); + ShowChartLegend(ExecutionCountTrendChart); + ExecutionCountTrendChart.Refresh(); + } + + /* ========== Query Heatmap ========== */ + + private void UpdateQueryHeatmapChart(HeatmapResult result) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] UpdateQueryHeatmapChart called: TimeBuckets={result.TimeBuckets.Length}, Grid={result.Intensities.GetLength(0)}x{result.Intensities.GetLength(1)}, BucketLabels={result.BucketLabels.Length}"); + ClearChart(QueryHeatmapChart); + ApplyTheme(QueryHeatmapChart); + + _lastHeatmapResult = result; + + if (result.TimeBuckets.Length == 0 || result.BucketLabels.Length == 0) + { + RefreshEmptyChart(QueryHeatmapChart, "Query Heatmap", ""); + return; + } + + int numRows = result.Intensities.GetLength(0); + int numCols = result.Intensities.GetLength(1); + + // Log1p scaling; NaN for empty cells so they render as background. + var scaled = new double[numRows, numCols]; + for (int r = 0; r < numRows; r++) + { + for (int c = 0; c < numCols; c++) + { + scaled[r, c] = result.Intensities[r, c] > 0 + ? Math.Log(1 + result.Intensities[r, c]) + : double.NaN; + } + } + + var heatmap = QueryHeatmapChart.Plot.Add.Heatmap(scaled); + _heatmapPlottable = heatmap; + heatmap.FlipVertically = true; // row 0 ("0-1ms") at bottom, row 6 (">100s") at top + heatmap.Colormap = new ScottPlot.Colormaps.Viridis(); + heatmap.NaNCellColor = QueryHeatmapChart.Plot.DataBackground.Color; + + // Let ScottPlot use default extent (0..numCols, 0..numRows). + // No custom Position — avoids cell-centering offset issues. + // Use manual tick labels for both axes instead. + ReapplyAxisColors(QueryHeatmapChart); + + // X-axis: time labels at column positions + var xTicks = new ScottPlot.TickGenerators.NumericManual(); + int xStep = Math.Max(1, numCols / 12); // ~12 labels max + for (int i = 0; i < numCols; i += xStep) + { + var t = result.TimeBuckets[i].AddMinutes(UtcOffsetMinutes); + xTicks.AddMajor(i, t.ToString("M/d\nh:mm tt")); + } + QueryHeatmapChart.Plot.Axes.Bottom.TickGenerator = xTicks; + QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Left.TickLabelStyle.ForeColor; + + // Y-axis: bucket labels + var yTicks = new ScottPlot.TickGenerators.NumericManual(); + for (int i = 0; i < result.BucketLabels.Length; i++) + { + yTicks.AddMajor(i, result.BucketLabels[i]); + } + QueryHeatmapChart.Plot.Axes.Left.TickGenerator = yTicks; + + // Axis limits match default heatmap extent + QueryHeatmapChart.Plot.Axes.SetLimitsX(-0.5, numCols - 0.5); + QueryHeatmapChart.Plot.Axes.SetLimitsY(-0.5, numRows - 0.5); + + // Colorbar with real query counts (undo log1p for tick labels) + var colorBar = new ScottPlot.Panels.ColorBar(heatmap, ScottPlot.Edge.Right); + colorBar.Label = "Query Count"; + colorBar.LabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + colorBar.Axis.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + double maxRaw = 0; + for (int r = 0; r < numRows; r++) + for (int c = 0; c < numCols; c++) + if (result.Intensities[r, c] > maxRaw) maxRaw = result.Intensities[r, c]; + var cbTicks = new ScottPlot.TickGenerators.NumericManual(); + cbTicks.AddMajor(0, "0"); + int[] niceValues = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000 }; + foreach (var n in niceValues) + { + if (n > maxRaw) break; + cbTicks.AddMajor(Math.Log(1 + n), n.ToString("N0")); + } + cbTicks.AddMajor(Math.Log(1 + maxRaw), ((int)maxRaw).ToString("N0")); + colorBar.Axis.TickGenerator = cbTicks; + QueryHeatmapChart.Plot.Axes.AddPanel(colorBar); + _legendPanels[QueryHeatmapChart] = colorBar; + + var metricName = ((ComboBoxItem)HeatmapMetricCombo.SelectedItem).Content?.ToString() ?? "Duration (ms)"; + QueryHeatmapChart.Plot.Title($"Query Distribution by {metricName}"); + QueryHeatmapChart.Plot.Axes.Title.Label.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + + QueryHeatmapChart.Refresh(); + } + + private DateTime _lastHeatmapHoverUpdate; + + private void HeatmapChart_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) + { + if (_heatmapPopup != null) _heatmapPopup.IsOpen = false; + } + + private void HeatmapChart_MouseMove(object sender, System.Windows.Input.MouseEventArgs e) + { + if (_heatmapPopup == null || _heatmapPopupText == null || _heatmapPlottable == null) return; + if (_lastHeatmapResult == null || _lastHeatmapResult.TimeBuckets.Length == 0) return; + + var now = DateTime.UtcNow; + if ((now - _lastHeatmapHoverUpdate).TotalMilliseconds < 50) return; + _lastHeatmapHoverUpdate = now; + + var pos = e.GetPosition(QueryHeatmapChart); + var dpi = VisualTreeHelper.GetDpi(QueryHeatmapChart); + var pixel = new ScottPlot.Pixel( + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); + var coords = QueryHeatmapChart.Plot.GetCoordinates(pixel); + + int numRows = _lastHeatmapResult.Intensities.GetLength(0); + int numCols = _lastHeatmapResult.Intensities.GetLength(1); + + // Default heatmap extent (no custom Position): cols = 0..numCols, rows = 0..numRows. + // GetIndexes returns bitmap indices. With FlipVertically=true, flip row for data index. + var (col, rowIdx) = _heatmapPlottable.GetIndexes(coords); + int row = (numRows - 1) - rowIdx; + + if (row < 0 || row >= numRows || col < 0 || col >= numCols) + { + _heatmapPopup.IsOpen = false; + return; + } + + long count = (long)_lastHeatmapResult.Intensities[row, col]; + if (count == 0) + { + _heatmapPopup.IsOpen = false; + return; + } + + var cell = _lastHeatmapResult.CellDetails[row, col]; + var time = ServerTimeHelper.ConvertForDisplay( + _lastHeatmapResult.TimeBuckets[col].AddMinutes(UtcOffsetMinutes), + ServerTimeHelper.CurrentDisplayMode); + var bucketLabel = row < _lastHeatmapResult.BucketLabels.Length + ? _lastHeatmapResult.BucketLabels[row] + : "?"; + + var tipText = $"{time:HH:mm:ss} | {bucketLabel} | {count:N0} queries"; + if (cell != null && !string.IsNullOrEmpty(cell.TopQueryText)) + { + // Single line, collapse whitespace, truncate + var flat = System.Text.RegularExpressions.Regex.Replace(cell.TopQueryText, @"\s+", " ").Trim(); + if (flat.Length > 60) flat = flat[..60] + "..."; + tipText += $"\n{flat}"; + } + _heatmapPopupText.Text = tipText; + + _heatmapPopup.HorizontalOffset = pos.X + 15; + _heatmapPopup.VerticalOffset = pos.Y + 15; + _heatmapPopup.IsOpen = true; + } + + private async void HeatmapMetric_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + try + { + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + var metric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; + var result = await _dataService.GetQueryHeatmapAsync(_serverId, metric, hoursBack, fromDate, toDate); + UpdateQueryHeatmapChart(result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] HeatmapMetric_SelectionChanged failed: {ex.Message}"); + } + } + + /// + /// Clears a chart and removes any existing legend panel to prevent duplication. + /// + private void ClearChart(ScottPlot.WPF.WpfPlot chart) + { + if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) + { + chart.Plot.Axes.Remove(existingPanel); + _legendPanels[chart] = null; + } + + /* Reset fully — Plot.Clear() leaves stale DateTime axes behind, + and DateTimeTicksBottom() replaces the axis object entirely. + Resetting the plot object avoids tick generator type mismatches. */ + chart.Reset(); + chart.Plot.Clear(); + } + + /// + /// Sets up an empty chart with dark theme, Y-axis label, legend, and "No Data" annotation. + /// Matches Full Dashboard behavior for consistent UX. + /// + private void RefreshEmptyChart(ScottPlot.WPF.WpfPlot chart, string legendText, string yAxisLabel) + { + ReapplyAxisColors(chart); + + /* Add invisible scatter to create legend entry (matches data chart layout) */ + var placeholder = chart.Plot.Add.Scatter(new double[] { 0 }, new double[] { 0 }); + placeholder.LegendText = legendText; + placeholder.Color = ScottPlot.Color.FromHex("#888888"); + placeholder.MarkerSize = 0; + placeholder.LineWidth = 0; + + /* Add centered "No Data" text */ + var text = chart.Plot.Add.Text($"{legendText}\nNo Data", 0, 0); + text.LabelFontColor = ScottPlot.Color.FromHex("#888888"); + text.LabelFontSize = 14; + text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + + /* Configure axes */ + chart.Plot.HideGrid(); + chart.Plot.Axes.SetLimitsX(-1, 1); + chart.Plot.Axes.SetLimitsY(-1, 1); + chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.YLabel(yAxisLabel); + + /* Show legend to match data chart layout */ + ShowChartLegend(chart); + chart.Refresh(); + } + + /// + /// Shows legend on chart and tracks it for proper cleanup on next refresh. + /// + private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) + { + _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + chart.Plot.Legend.FontSize = 13; + } + + /// + /// Applies the Darling Data dark theme to a ScottPlot chart. + /// Matches Dashboard TabHelpers.ApplyThemeToChart exactly. + /// + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) + { + ScottPlot.Color figureBackground, dataBackground, textColor, gridColor, legendBg, legendFg, legendOutline; + + if (Helpers.ThemeManager.CurrentTheme == "CoolBreeze") + { + figureBackground = ScottPlot.Color.FromHex("#EEF4FA"); + dataBackground = ScottPlot.Color.FromHex("#DAE6F0"); + textColor = ScottPlot.Color.FromHex("#1A2A3A"); + gridColor = ScottPlot.Color.FromHex("#A8BDD0").WithAlpha(120); + legendBg = ScottPlot.Color.FromHex("#EEF4FA"); + legendFg = ScottPlot.Color.FromHex("#1A2A3A"); + legendOutline = ScottPlot.Color.FromHex("#A8BDD0"); + } + else if (Helpers.ThemeManager.HasLightBackground) + { + figureBackground = ScottPlot.Color.FromHex("#FFFFFF"); + dataBackground = ScottPlot.Color.FromHex("#F5F7FA"); + textColor = ScottPlot.Color.FromHex("#1A1D23"); + gridColor = ScottPlot.Colors.Black.WithAlpha(20); + legendBg = ScottPlot.Color.FromHex("#FFFFFF"); + legendFg = ScottPlot.Color.FromHex("#1A1D23"); + legendOutline = ScottPlot.Color.FromHex("#DEE2E6"); + } + else + { + figureBackground = ScottPlot.Color.FromHex("#22252b"); + dataBackground = ScottPlot.Color.FromHex("#111217"); + textColor = ScottPlot.Color.FromHex("#E4E6EB"); + gridColor = ScottPlot.Colors.White.WithAlpha(40); + legendBg = ScottPlot.Color.FromHex("#22252b"); + legendFg = ScottPlot.Color.FromHex("#E4E6EB"); + legendOutline = ScottPlot.Color.FromHex("#2a2d35"); + } + + chart.Plot.FigureBackground.Color = figureBackground; + chart.Plot.DataBackground.Color = dataBackground; + chart.Plot.Axes.Color(textColor); + chart.Plot.Grid.MajorLineColor = gridColor; + chart.Plot.Legend.BackgroundColor = legendBg; + chart.Plot.Legend.FontColor = legendFg; + chart.Plot.Legend.OutlineColor = legendOutline; + chart.Plot.Legend.Alignment = ScottPlot.Alignment.LowerCenter; + chart.Plot.Legend.Orientation = ScottPlot.Orientation.Horizontal; + chart.Plot.Axes.Margins(bottom: 0); /* No bottom margin - SetChartYLimitsWithLegendPadding handles Y-axis */ + + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Bottom.Label.ForeColor = textColor; + chart.Plot.Axes.Left.Label.ForeColor = textColor; + chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; + chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; + + // Set the WPF control Background to match so no white flash appears before ScottPlot's render loop fires + chart.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(figureBackground.R, figureBackground.G, figureBackground.B)); + + // Ensure ScottPlot renders with the correct colors the very first time it gets pixel dimensions. + chart.Loaded -= HandleChartFirstLoaded; + if (!chart.IsLoaded) + chart.Loaded += HandleChartFirstLoaded; + } + + private static void HandleChartFirstLoaded(object sender, RoutedEventArgs e) + { + var chart = (ScottPlot.WPF.WpfPlot)sender; + chart.Loaded -= HandleChartFirstLoaded; + chart.Refresh(); + } + + private void OnThemeChanged(string _) + { + foreach (var field in GetType().GetFields( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) + { + if (field.GetValue(this) is ScottPlot.WPF.WpfPlot chart) + { + ApplyTheme(chart); + chart.Refresh(); + } + } + + CorrelatedLanes.ReapplyTheme(); + } + + private static IEnumerable GetAllCharts(DependencyObject root) + { + foreach (var child in LogicalTreeHelper.GetChildren(root).OfType()) + { + if (child is ScottPlot.WPF.WpfPlot plot) + yield return plot; + foreach (var nested in GetAllCharts(child)) + yield return nested; + } + } + + /// + /// Reapplies theme-appropriate text colors and font sizes after DateTimeTicksBottom() resets them. + /// + private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart) + { + var textColor = Helpers.ThemeManager.CurrentTheme == "CoolBreeze" + ? ScottPlot.Color.FromHex("#1A2A3A") + : Helpers.ThemeManager.HasLightBackground + ? ScottPlot.Color.FromHex("#1A1D23") + : ScottPlot.Color.FromHex("#E4E6EB"); + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Bottom.Label.ForeColor = textColor; + chart.Plot.Axes.Left.Label.ForeColor = textColor; + chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; + chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; + } + + /// + /// Sets Y-axis limits with padding for bottom legend and top breathing room. + /// + private static void SetChartYLimitsWithLegendPadding(ScottPlot.WPF.WpfPlot chart, double dataYMin = 0, double dataYMax = 0) + { + if (dataYMin == 0 && dataYMax == 0) + { + var limits = chart.Plot.Axes.GetLimits(); + dataYMin = limits.Bottom; + dataYMax = limits.Top; + } + if (dataYMax <= dataYMin) dataYMax = dataYMin + 1; + + double range = dataYMax - dataYMin; + double topPadding = range * 0.05; + + /* Add small bottom margin when dataYMin is zero so flat lines at Y=0 are visible above the axis */ + double yMin = dataYMin > 0 ? 0 : dataYMin == 0 ? -(range * 0.05) : dataYMin - (range * 0.10); + double yMax = dataYMax + topPadding; + + chart.Plot.Axes.SetLimitsY(yMin, yMax); + } + + /* ========== Collection Health ========== */ + + private void UpdateCollectorDurationChart(List data) + { + ClearChart(CollectorDurationChart); + ApplyTheme(CollectorDurationChart); + + if (data.Count == 0) { CollectorDurationChart.Refresh(); return; } + + /* Group by collector, plot each as a separate series */ + var groups = data + .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS") + .GroupBy(d => d.CollectorName) + .OrderBy(g => g.Key) + .ToList(); + + _collectorDurationHover?.Clear(); + int colorIdx = 0; + foreach (var group in groups) + { + var points = group.OrderBy(d => d.CollectionTime).ToList(); + if (points.Count < 2) continue; + + var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray(); + + var scatter = CollectorDurationChart.Plot.Add.Scatter(times, durations); + scatter.LegendText = group.Key; + scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + scatter.LineWidth = 2; + scatter.MarkerSize = 0; + _collectorDurationHover?.Add(scatter, group.Key); + colorIdx++; + } + + CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(CollectorDurationChart); + CollectorDurationChart.Plot.YLabel("Duration (ms)"); + CollectorDurationChart.Plot.Axes.AutoScale(); + ShowChartLegend(CollectorDurationChart); + CollectorDurationChart.Refresh(); + } +} diff --git a/Lite/Controls/ServerTab.Comparison.cs b/Lite/Controls/ServerTab.Comparison.cs new file mode 100644 index 0000000..26fd74f --- /dev/null +++ b/Lite/Controls/ServerTab.Comparison.cs @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + private async void CompareToCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _isRefreshing) return; + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + await RefreshOverviewAsync(hoursBack, fromDate, toDate); + + // Also refresh comparison grids + try + { + var currentEnd = toDate ?? DateTime.UtcNow; + var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack); + await RefreshQueryStatsComparisonAsync(currentStart, currentEnd); + await RefreshProcStatsComparisonAsync(currentStart, currentEnd); + await RefreshQueryStoreComparisonAsync(currentStart, currentEnd); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] Comparison refresh failed: {ex.Message}"); + } + } + + /// + /// Computes the reference time range for the comparison overlay based on the + /// current Compare dropdown selection and the active time range. + /// Returns null if "None" is selected. + /// + private (DateTime From, DateTime To)? GetComparisonRange() + { + if (CompareToCombo == null || CompareToCombo.SelectedIndex <= 0) return null; + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + var currentEnd = toDate ?? DateTime.UtcNow; + var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack); + + return CompareToCombo.SelectedIndex switch + { + 1 => (currentStart.AddDays(-1), currentEnd.AddDays(-1)), // Yesterday + 2 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Last week + 3 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Same day last week + _ => null + }; + } + + private bool IsQueryStatsComparisonActive => GetComparisonRange() != null; + + private void SetQueryStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + QueryStatsGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + QueryStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + QueryStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); + var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); + QueryStatsComparisonBanner.Text = $"Comparing against baseline: {from} → {to}"; + } + } + + private async System.Threading.Tasks.Task RefreshQueryStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baselineRange = GetComparisonRange(); + if (baselineRange == null) + { + SetQueryStatsComparisonMode(false); + return; + } + + SetQueryStatsComparisonMode(true, baselineRange); + + var items = await _dataService.GetQueryStatsComparisonAsync( + _serverId, currentStart, currentEnd, + baselineRange.Value.From, baselineRange.Value.To); + + // Sort: NEW first, then by duration delta descending, GONE last + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + QueryStatsComparisonGrid.ItemsSource = sorted; + } + + private void SetProcStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + ProcedureStatsGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + ProcStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + ProcStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); + var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); + ProcStatsComparisonBanner.Text = $"Comparing against baseline: {from} → {to}"; + } + } + + private async System.Threading.Tasks.Task RefreshProcStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baselineRange = GetComparisonRange(); + if (baselineRange == null) + { + SetProcStatsComparisonMode(false); + return; + } + + SetProcStatsComparisonMode(true, baselineRange); + + var items = await _dataService.GetProcedureStatsComparisonAsync( + _serverId, currentStart, currentEnd, + baselineRange.Value.From, baselineRange.Value.To); + + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + ProcStatsComparisonGrid.ItemsSource = sorted; + } + + private void SetQueryStoreComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + QueryStoreGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + QueryStoreComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + QueryStoreComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); + var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); + QueryStoreComparisonBanner.Text = $"Comparing against baseline: {from} → {to}"; + } + } + + private async System.Threading.Tasks.Task RefreshQueryStoreComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baselineRange = GetComparisonRange(); + if (baselineRange == null) + { + SetQueryStoreComparisonMode(false); + return; + } + + SetQueryStoreComparisonMode(true, baselineRange); + + var items = await _dataService.GetQueryStoreComparisonAsync( + _serverId, currentStart, currentEnd, + baselineRange.Value.From, baselineRange.Value.To); + + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + QueryStoreComparisonGrid.ItemsSource = sorted; + } + + private bool IsComparisonSupportedOnCurrentTab() + { + return MainTabControl.SelectedIndex switch + { + 0 => true, // Overview — correlated timeline lanes + 2 => QueriesSubTabControl.SelectedIndex is 2 or 3 or 4, // Top Queries / Top Procedures / Query Store + _ => false + }; + } + + private void UpdateCompareDropdownState() + { + var supported = IsComparisonSupportedOnCurrentTab(); + + if (supported) + { + CompareToCombo.IsEnabled = true; + CompareToCombo.Opacity = 1.0; + CompareToCombo.ToolTip = "Compare current period against a baseline"; + } + else + { + CompareToCombo.SelectedIndex = 0; + CompareToCombo.IsEnabled = false; + CompareToCombo.Opacity = 0.5; + CompareToCombo.ToolTip = "Comparison is not available for this tab"; + } + } +} diff --git a/Lite/Controls/ServerTab.CopyExport.cs b/Lite/Controls/ServerTab.CopyExport.cs new file mode 100644 index 0000000..c3b7397 --- /dev/null +++ b/Lite/Controls/ServerTab.CopyExport.cs @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + /* DataGrid copy helpers */ + /// + /// Finds the parent DataGrid from a context menu opened on a DataGridRow. + /// + private static DataGrid? FindParentDataGrid(MenuItem menuItem) + { + var contextMenu = menuItem.Parent as ContextMenu; + var target = contextMenu?.PlacementTarget as FrameworkElement; + while (target != null && target is not DataGrid) + { + target = System.Windows.Media.VisualTreeHelper.GetParent(target) as FrameworkElement; + } + return target as DataGrid; + } + + /// + /// Gets a cell value from a row item for any column type (bound or template). + /// Template columns are inspected for a TextBlock binding in their CellTemplate. + /// + private static string GetCellValue(DataGridColumn col, object item) + { + /* DataGridBoundColumn — binding is directly accessible */ + if (col is DataGridBoundColumn boundCol + && boundCol.Binding is System.Windows.Data.Binding binding) + { + var prop = item.GetType().GetProperty(binding.Path.Path); + return FormatForExport(prop?.GetValue(item)); + } + + /* DataGridTemplateColumn — instantiate the template and find a TextBlock binding */ + if (col is DataGridTemplateColumn templateCol && templateCol.CellTemplate != null) + { + var content = templateCol.CellTemplate.LoadContent(); + if (content is TextBlock textBlock) + { + var textBinding = System.Windows.Data.BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); + if (textBinding != null) + { + var prop = item.GetType().GetProperty(textBinding.Path.Path); + return FormatForExport(prop?.GetValue(item)); + } + } + } + + return ""; + } + + private static string FormatForExport(object? value) + { + if (value == null) return ""; + if (value is IFormattable formattable) + return formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture); + return value.ToString() ?? ""; + } + + private void CopyCell_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; + + var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); + if (value.Length > 0) Clipboard.SetDataObject(value, false); + } + + private void CopyRow_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem == null) return; + + var sb = new StringBuilder(); + foreach (var col in grid.Columns) + { + sb.Append(GetCellValue(col, grid.CurrentItem)); + sb.Append('\t'); + } + Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); + } + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.Items == null) return; + + var sb = new StringBuilder(); + + /* Header */ + foreach (var col in grid.Columns) + { + sb.Append(Helpers.DataGridClipboardBehavior.GetHeaderText(col)); + sb.Append('\t'); + } + sb.AppendLine(); + + /* Rows */ + foreach (var item in grid.Items) + { + foreach (var col in grid.Columns) + { + sb.Append(GetCellValue(col, item)); + sb.Append('\t'); + } + sb.AppendLine(); + } + + Clipboard.SetDataObject(sb.ToString(), false); + } + + private async void CopyReproScript_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem == null) return; + + string? queryText = null; + string? databaseName = null; + string? planXml = null; + string? isolationLevel = null; + string source = "Query"; + + switch (grid.CurrentItem) + { + case QuerySnapshotRow snapshot: + queryText = snapshot.QueryText; + databaseName = snapshot.DatabaseName; + planXml = snapshot.QueryPlan; + isolationLevel = snapshot.TransactionIsolationLevel; + source = "Active Queries"; + break; + + case QueryStatsRow stats: + queryText = stats.QueryText; + databaseName = stats.DatabaseName; + source = "Top Queries (dm_exec_query_stats)"; + /* Fetch plan on-demand from SQL Server */ + if (!string.IsNullOrEmpty(stats.QueryHash)) + { + try + { + var connStr = _server.GetConnectionString(_credentialService); + planXml = await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, stats.QueryHash); + } + catch { /* Plan fetch failed — continue without plan */ } + } + break; + + case QueryStoreRow qs: + queryText = qs.QueryText; + databaseName = qs.DatabaseName; + source = "Query Store"; + /* Fetch plan on-demand from Query Store */ + if (qs.PlanId > 0 && !string.IsNullOrEmpty(qs.DatabaseName)) + { + try + { + var connStr = _server.GetConnectionString(_credentialService); + planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); + } + catch { /* Plan fetch failed — continue without plan */ } + } + break; + + default: + /* Not a supported grid for repro scripts — copy query text if available */ + var textProp = grid.CurrentItem.GetType().GetProperty("QueryText"); + queryText = textProp?.GetValue(grid.CurrentItem)?.ToString(); + if (string.IsNullOrEmpty(queryText)) + { + return; + } + var dbProp = grid.CurrentItem.GetType().GetProperty("DatabaseName"); + databaseName = dbProp?.GetValue(grid.CurrentItem)?.ToString(); + break; + } + + if (string.IsNullOrEmpty(queryText)) + { + return; + } + + var script = ReproScriptBuilder.BuildReproScript(queryText, databaseName, planXml, isolationLevel, source); + + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() operation. + See: https://github.com/dotnet/wpf/issues/9901 */ + Clipboard.SetDataObject(script, false); + } + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.Items == null || grid.Items.Count == 0) return; + + var dialog = new SaveFileDialog + { + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = ".csv", + FileName = $"{_server.DisplayName}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" + }; + + if (dialog.ShowDialog() != true) return; + + var sb = new StringBuilder(); + var sep = App.CsvSeparator; + + /* Header */ + var headers = new List(); + foreach (var col in grid.Columns) + { + headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col), sep)); + } + sb.AppendLine(string.Join(sep, headers)); + + /* Rows */ + foreach (var item in grid.Items) + { + var values = new List(); + foreach (var col in grid.Columns) + { + values.Add(CsvEscape(GetCellValue(col, item), sep)); + } + sb.AppendLine(string.Join(sep, values)); + } + + try + { + File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private static string CsvEscape(string value, string separator) + { + if (value.Contains(separator, StringComparison.Ordinal) || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + return value; + } +} diff --git a/Lite/Controls/ServerTab.DrillDown.cs b/Lite/Controls/ServerTab.DrillDown.cs new file mode 100644 index 0000000..e61b4a3 --- /dev/null +++ b/Lite/Controls/ServerTab.DrillDown.cs @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) + { + contextMenu.Items.Insert(0, new Separator()); + var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; + drillDownItem.Click += ShowQueriesForWaitType_Click; + contextMenu.Items.Insert(0, drillDownItem); + + contextMenu.Opened += (s, _) => + { + if (s is not ContextMenu cm) return; + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = _waitStatsHover?.GetNearestSeries(pos); + if (nearest.HasValue) + { + drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); + drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; + drillDownItem.IsEnabled = true; + } + else + { + drillDownItem.Tag = null; + drillDownItem.Header = "Show Queries With This Wait"; + drillDownItem.IsEnabled = false; + } + }; + } + + private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Tag is not (string waitType, DateTime time)) return; + + // ±15 minute window around the clicked point (already in server local time from chart) + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + + var window = new Windows.WaitDrillDownWindow( + _dataService, _serverId, waitType, 1, fromDate, toDate); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } + + // ── Generic Chart Drill-Down (#682) ── + + private void AddChartDrillDownMenuItem( + ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu, + Helpers.ChartHoverHelper? hover, string label, Action handler) + { + contextMenu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = label }; + contextMenu.Items.Insert(0, item); + + contextMenu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = hover?.GetNearestSeries(pos); + if (nearest.HasValue) + { + item.Tag = nearest.Value.Time; + item.IsEnabled = true; + } + else + { + item.Tag = null; + item.IsEnabled = false; + } + }; + + item.Click += (s, _) => + { + if (item.Tag is DateTime time) + handler(time); + }; + } + + private async void OnCpuDrillDown(DateTime time) + { + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + + // Populate custom date pickers so user can explore other tabs + SetDrillDownTimeRange(fromDate, toDate); + + // Navigate to Queries > Active Queries with ±15 min window + MainTabControl.SelectedIndex = 2; // Queries + QueriesSubTabControl.SelectedIndex = 1; // Active Queries + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; + _ = LoadActiveQueriesSlicerAsync(); + } + + private async void OnMemoryDrillDown(DateTime time) + { + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + SetDrillDownTimeRange(fromDate, toDate); + + MainTabControl.SelectedIndex = 2; // Queries + QueriesSubTabControl.SelectedIndex = 1; // Active Queries + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; + _ = LoadActiveQueriesSlicerAsync(); + } + + private async void OnTempDbDrillDown(DateTime time) + { + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + SetDrillDownTimeRange(fromDate, toDate); + + // Navigate to Active Queries — TempDB spills are visible there + MainTabControl.SelectedIndex = 2; // Queries + QueriesSubTabControl.SelectedIndex = 1; // Active Queries + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; + _ = LoadActiveQueriesSlicerAsync(); + } + + private async void OnBlockingDrillDown(DateTime time) + { + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + SetDrillDownTimeRange(fromDate, toDate); + + MainTabControl.SelectedIndex = 8; // Blocking + BlockingSubTabControl.SelectedIndex = 2; // Blocked Process Reports + var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromDate, toDate); + _blockedProcessFilterMgr!.UpdateData(bpr); + } + + private async void OnDeadlockDrillDown(DateTime time) + { + var fromDate = time.AddMinutes(-30); + var toDate = time.AddMinutes(30); + SetDrillDownTimeRange(fromDate, toDate); + + MainTabControl.SelectedIndex = 8; // Blocking + BlockingSubTabControl.SelectedIndex = 3; // Deadlocks + var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, 0, fromDate, toDate); + _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); + } + + private async void OnHeatmapDrillDown(DateTime bucketTimeUtc) + { + var serverTime = bucketTimeUtc.AddMinutes(UtcOffsetMinutes); + var fromDate = serverTime.AddMinutes(-5); + var toDate = serverTime.AddMinutes(10); + + AppLogger.Info("DrillDown", $"OnHeatmapDrillDown: bucketTimeUtc={bucketTimeUtc:O}, UtcOffsetMinutes={UtcOffsetMinutes}, serverTime={serverTime:O}, fromDate={fromDate:O}, toDate={toDate:O}"); + + SetDrillDownTimeRange(fromDate, toDate); + + MainTabControl.SelectedIndex = 2; // Queries + QueriesSubTabControl.SelectedIndex = 1; // Active Queries + + AppLogger.Info("DrillDown", $"Calling GetLatestQuerySnapshotsAsync with fromDate={fromDate:O}, toDate={toDate:O}"); + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); + AppLogger.Info("DrillDown", $"Got {snapshots.Count} snapshots"); + + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LiveSnapshotIndicator.Text = $"Drill-down: {fromDate:HH:mm} → {toDate:HH:mm} (server time)"; + _ = LoadActiveQueriesSlicerAsync(); + } + + /// + /// Sets the time range combo to Custom and populates the date/time pickers + /// so the user can navigate other tabs at the same time window. + /// + private void SetDrillDownTimeRange(DateTime fromServer, DateTime toServer) + { + // Pickers store time in the current display mode. Downstream reads use + // DisplayTimeToServerTime() to convert back. + var fromDisplay = ServerTimeHelper.ConvertForDisplay(fromServer, ServerTimeHelper.CurrentDisplayMode); + var toDisplay = ServerTimeHelper.ConvertForDisplay(toServer, ServerTimeHelper.CurrentDisplayMode); + + // Switch to Custom without triggering a refresh + _isRefreshing = true; + try + { + TimeRangeCombo.SelectedIndex = 5; // Custom + FromDatePicker.SelectedDate = fromDisplay.Date; + FromHourCombo.SelectedIndex = fromDisplay.Hour; + FromMinuteCombo.SelectedIndex = fromDisplay.Minute / 15; + ToDatePicker.SelectedDate = toDisplay.Date; + ToHourCombo.SelectedIndex = toDisplay.Hour; + ToMinuteCombo.SelectedIndex = toDisplay.Minute / 15; + + // Make pickers visible + var visibility = Visibility.Visible; + FromDatePicker.Visibility = visibility; + FromHourCombo.Visibility = visibility; + FromMinuteCombo.Visibility = visibility; + ToLabel.Visibility = visibility; + ToDatePicker.Visibility = visibility; + ToHourCombo.Visibility = visibility; + ToMinuteCombo.Visibility = visibility; + } + finally + { + _isRefreshing = false; + } + } +} diff --git a/Lite/Controls/ServerTab.Filters.cs b/Lite/Controls/ServerTab.Filters.cs new file mode 100644 index 0000000..0476a3f --- /dev/null +++ b/Lite/Controls/ServerTab.Filters.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + /* ========== Column Filtering ========== */ + + private void InitializeFilterManagers() + { + _querySnapshotsFilterMgr = new DataGridFilterManager(QuerySnapshotsGrid); + _queryStatsFilterMgr = new DataGridFilterManager(QueryStatsGrid); + _procStatsFilterMgr = new DataGridFilterManager(ProcedureStatsGrid); + _queryStoreFilterMgr = new DataGridFilterManager(QueryStoreGrid); + _blockedProcessFilterMgr = new DataGridFilterManager(BlockedProcessReportGrid); + _deadlockFilterMgr = new DataGridFilterManager(DeadlockGrid); + _runningJobsFilterMgr = new DataGridFilterManager(RunningJobsGrid); + _serverConfigFilterMgr = new DataGridFilterManager(ServerConfigGrid); + _databaseConfigFilterMgr = new DataGridFilterManager(DatabaseConfigGrid); + _dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid); + _traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid); + _collectionHealthFilterMgr = new DataGridFilterManager(CollectionHealthGrid); + _collectionLogFilterMgr = new DataGridFilterManager(CollectionLogGrid); + + _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr; + _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr; + _filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr; + _filterManagers[QueryStoreGrid] = _queryStoreFilterMgr; + _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr; + _filterManagers[DeadlockGrid] = _deadlockFilterMgr; + _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr; + _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr; + _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr; + _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr; + _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr; + _filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr; + _filterManagers[CollectionLogGrid] = _collectionLogFilterMgr; + } + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private DataGrid? _currentFilterGrid; + + private void FilterButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + /* Walk up visual tree to find the parent DataGrid */ + var dataGrid = FindParentDataGridFromElement(button); + if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return; + + _currentFilterGrid = dataGrid; + + EnsureFilterPopup(); + + /* Rewire events to the current grid */ + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + /* Initialize with existing filter state */ + manager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + + if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager)) + { + manager.SetFilter(e.FilterState); + } + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + } + + private static DataGrid? FindParentDataGridFromElement(DependencyObject element) + { + var current = element; + while (current != null) + { + if (current is DataGrid dg) + return dg; + current = VisualTreeHelper.GetParent(current); + } + return null; + } +} diff --git a/Lite/Controls/ServerTab.Pickers.cs b/Lite/Controls/ServerTab.Pickers.cs new file mode 100644 index 0000000..c6a2946 --- /dev/null +++ b/Lite/Controls/ServerTab.Pickers.cs @@ -0,0 +1,569 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using ScottPlot; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + private static readonly HashSet _defaultPerfmonCounters = new( + Helpers.PerfmonPacks.Packs["General Throughput"], + StringComparer.OrdinalIgnoreCase); + + /* ========== Wait Stats Picker ========== */ + + private static readonly string[] PoisonWaits = { "THREADPOOL", "RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE_QUERY_COMPILE" }; + private static readonly string[] UsualSuspectWaits = { "SOS_SCHEDULER_YIELD", "CXPACKET", "CXCONSUMER", "PAGEIOLATCH_SH", "PAGEIOLATCH_EX", "WRITELOG" }; + private static readonly string[] UsualSuspectPrefixes = { "PAGELATCH_" }; + + private static HashSet GetDefaultWaitTypes(List availableWaitTypes) + { + var defaults = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var w in PoisonWaits) + if (availableWaitTypes.Contains(w)) defaults.Add(w); + foreach (var w in UsualSuspectWaits) + if (availableWaitTypes.Contains(w)) defaults.Add(w); + foreach (var prefix in UsualSuspectPrefixes) + foreach (var w in availableWaitTypes) + if (w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + defaults.Add(w); + int added = 0; + foreach (var w in availableWaitTypes) + { + if (defaults.Count >= 30) break; + if (added >= 10) break; + if (defaults.Add(w)) { added++; } + } + return defaults; + } + + private bool _isUpdatingWaitTypeSelection; + + private void PopulateWaitTypePicker(List waitTypes) + { + var previouslySelected = new HashSet(_waitTypeItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + var topWaits = previouslySelected.Count == 0 ? GetDefaultWaitTypes(waitTypes) : null; + _waitTypeItems = waitTypes.Select(w => new SelectableItem + { + DisplayName = w, + IsSelected = previouslySelected.Contains(w) || (topWaits != null && topWaits.Contains(w)) + }).ToList(); + /* Sort checked items to top, then preserve original order (by total wait time desc) */ + RefreshWaitTypeListOrder(); + } + + private void RefreshWaitTypeListOrder() + { + if (_waitTypeItems == null) return; + _waitTypeItems = _waitTypeItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.DisplayName) + .ToList(); + ApplyWaitTypeFilter(); + UpdateWaitTypeCount(); + } + + private void UpdateWaitTypeCount() + { + if (_waitTypeItems == null || WaitTypeCountText == null) return; + int count = _waitTypeItems.Count(x => x.IsSelected); + WaitTypeCountText.Text = $"{count} / 30 selected"; + WaitTypeCountText.Foreground = count >= 30 + ? new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#E57373")!) + : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + + private void ApplyWaitTypeFilter() + { + var search = WaitTypeSearchBox?.Text?.Trim() ?? ""; + WaitTypesList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + WaitTypesList.ItemsSource = _waitTypeItems; + else + WaitTypesList.ItemsSource = _waitTypeItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyWaitTypeFilter(); + + private void WaitTypeSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingWaitTypeSelection = true; + var topWaits = GetDefaultWaitTypes(_waitTypeItems.Select(x => x.DisplayName).ToList()); + foreach (var item in _waitTypeItems) + { + item.IsSelected = topWaits.Contains(item.DisplayName); + } + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitTypeClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingWaitTypeSelection = true; + var visible = (WaitTypesList.ItemsSource as IEnumerable)?.ToList() ?? _waitTypeItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitType_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingWaitTypeSelection) return; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync() + { + try + { + var selected = _waitTypeItems.Where(i => i.IsSelected).Take(20).ToList(); + + ClearChart(WaitStatsChart); + ApplyTheme(WaitStatsChart); + _waitStatsHover?.Clear(); + + if (selected.Count == 0) { WaitStatsChart.Refresh(); return; } + + bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; + if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null; + DateTime? toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + double globalMax = 0; + + for (int i = 0; i < selected.Count; i++) + { + var trend = await _dataService.GetWaitStatsTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); + if (trend.Count == 0) continue; + + var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = useAvgPerWait + ? trend.Select(t => t.AvgMsPerWait).ToArray() + : trend.Select(t => t.WaitTimeMsPerSecond).ToArray(); + + var plot = WaitStatsChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _waitStatsHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + WaitStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DateTime rangeStart, rangeEnd; + if (IsCustomRange && fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + WaitStatsChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(WaitStatsChart); + WaitStatsChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(WaitStatsChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(WaitStatsChart); + WaitStatsChart.Refresh(); + } + catch + { + /* Ignore chart update errors */ + } + } + + /* ========== Memory Clerks Picker ========== */ + + private void PopulateMemoryClerkPicker(List clerkTypes) + { + var previouslySelected = new HashSet(_memoryClerkItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + var topClerks = previouslySelected.Count == 0 ? new HashSet(clerkTypes.Take(5)) : null; + _memoryClerkItems = clerkTypes.Select(c => new SelectableItem + { + DisplayName = c, + IsSelected = previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c)) + }).ToList(); + RefreshMemoryClerkListOrder(); + } + + private void RefreshMemoryClerkListOrder() + { + if (_memoryClerkItems == null) return; + _memoryClerkItems = _memoryClerkItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.DisplayName) + .ToList(); + ApplyMemoryClerkFilter(); + UpdateMemoryClerkCount(); + } + + private void UpdateMemoryClerkCount() + { + if (_memoryClerkItems == null || MemoryClerkCountText == null) return; + int count = _memoryClerkItems.Count(x => x.IsSelected); + MemoryClerkCountText.Text = $"{count} selected"; + } + + private void ApplyMemoryClerkFilter() + { + var search = MemoryClerkSearchBox?.Text?.Trim() ?? ""; + MemoryClerksList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + MemoryClerksList.ItemsSource = _memoryClerkItems; + else + MemoryClerksList.ItemsSource = _memoryClerkItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void MemoryClerkSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyMemoryClerkFilter(); + + private void MemoryClerkSelectTop_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var topClerks = new HashSet(_memoryClerkItems.Take(5).Select(x => x.DisplayName)); + foreach (var item in _memoryClerkItems) + { + item.IsSelected = topClerks.Contains(item.DisplayName); + } + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerkClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var visible = (MemoryClerksList.ItemsSource as IEnumerable)?.ToList() ?? _memoryClerkItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerk_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingMemoryClerkSelection) return; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync() + { + try + { + var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); + + ClearChart(MemoryClerksChart); + ApplyTheme(MemoryClerksChart); + _memoryClerksHover?.Clear(); + + if (selected.Count == 0) + { + MemoryClerksTotalText.Text = "--"; + MemoryClerksTopText.Text = "--"; + MemoryClerksChart.Refresh(); + return; + } + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null; + DateTime? toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + double globalMax = 0; + double nonBpTotal = 0; + string topNonBpClerk = ""; + double topNonBpMb = 0; + + for (int i = 0; i < selected.Count; i++) + { + var trend = await _dataService.GetMemoryClerkTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); + if (trend.Count == 0) continue; + + var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = trend.Select(t => t.MemoryMb).ToArray(); + + var plot = MemoryClerksChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _memoryClerksHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + + /* Summary: use latest value, exclude buffer pool */ + var latestMb = values.Last(); + if (!selected[i].DisplayName.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) + { + nonBpTotal += latestMb; + if (latestMb > topNonBpMb) + { + topNonBpMb = latestMb; + topNonBpClerk = selected[i].DisplayName; + } + } + } + + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(MemoryClerksChart); + MemoryClerksChart.Plot.YLabel("Memory (MB)"); + SetChartYLimitsWithLegendPadding(MemoryClerksChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(MemoryClerksChart); + MemoryClerksChart.Refresh(); + + /* Update summary panel */ + MemoryClerksTotalText.Text = nonBpTotal >= 1024 ? $"{nonBpTotal / 1024:F1} GB" : $"{nonBpTotal:N0} MB"; + if (!string.IsNullOrEmpty(topNonBpClerk)) + { + var name = topNonBpClerk; + if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(12); + MemoryClerksTopText.Text = topNonBpMb >= 1024 ? $"{name} ({topNonBpMb / 1024:F1} GB)" : $"{name} ({topNonBpMb:N0} MB)"; + } + else + { + MemoryClerksTopText.Text = "--"; + } + } + catch + { + /* Ignore chart update errors */ + } + } + + /* ========== Perfmon Picker ========== */ + + private bool _isUpdatingPerfmonSelection; + + private void PopulatePerfmonPicker(List counters) + { + /* Initialize pack ComboBox once */ + if (PerfmonPackCombo.Items.Count == 0) + { + PerfmonPackCombo.ItemsSource = Helpers.PerfmonPacks.PackNames; + PerfmonPackCombo.SelectedItem = "General Throughput"; + } + + var previouslySelected = new HashSet(_perfmonCounterItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + _perfmonCounterItems = counters.Select(c => new SelectableItem + { + DisplayName = c, + IsSelected = previouslySelected.Contains(c) + || (previouslySelected.Count == 0 && _defaultPerfmonCounters.Contains(c)) + }).ToList(); + RefreshPerfmonListOrder(); + } + + private void PerfmonPack_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_perfmonCounterItems == null || _perfmonCounterItems.Count == 0) return; + if (PerfmonPackCombo.SelectedItem is not string pack) return; + + _isUpdatingPerfmonSelection = true; + + /* Clear search so all counters are visible */ + if (PerfmonSearchBox != null) + PerfmonSearchBox.Text = ""; + + /* Uncheck everything first */ + foreach (var item in _perfmonCounterItems) + item.IsSelected = false; + + if (pack == Helpers.PerfmonPacks.AllCounters) + { + /* "All Counters" selects the General Throughput defaults */ + foreach (var item in _perfmonCounterItems) + { + if (_defaultPerfmonCounters.Contains(item.DisplayName)) + item.IsSelected = true; + } + } + else if (Helpers.PerfmonPacks.Packs.TryGetValue(pack, out var packCounters)) + { + var packSet = new HashSet(packCounters, StringComparer.OrdinalIgnoreCase); + int count = 0; + foreach (var item in _perfmonCounterItems) + { + if (count >= 12) break; + if (packSet.Contains(item.DisplayName)) + { + item.IsSelected = true; + count++; + } + } + } + + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void RefreshPerfmonListOrder() + { + if (_perfmonCounterItems == null) return; + _perfmonCounterItems = _perfmonCounterItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => _perfmonCounterItems.IndexOf(x)) + .ToList(); + ApplyPerfmonFilter(); + } + + private void ApplyPerfmonFilter() + { + var search = PerfmonSearchBox?.Text?.Trim() ?? ""; + PerfmonCountersList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + PerfmonCountersList.ItemsSource = _perfmonCounterItems; + else + PerfmonCountersList.ItemsSource = _perfmonCounterItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void PerfmonSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyPerfmonFilter(); + + private void PerfmonSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingPerfmonSelection = true; + var visible = (PerfmonCountersList.ItemsSource as IEnumerable)?.ToList() ?? _perfmonCounterItems; + int count = visible.Count(i => i.IsSelected); + foreach (var item in visible) + { + if (!item.IsSelected && count < 12) + { + item.IsSelected = true; + count++; + } + } + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void PerfmonClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingPerfmonSelection = true; + var visible = (PerfmonCountersList.ItemsSource as IEnumerable)?.ToList() ?? _perfmonCounterItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void PerfmonCounter_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingPerfmonSelection) return; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private async System.Threading.Tasks.Task UpdatePerfmonChartFromPickerAsync() + { + try + { + var selected = _perfmonCounterItems.Where(i => i.IsSelected).Take(12).ToList(); + + ClearChart(PerfmonChart); + _perfmonHover?.Clear(); + ApplyTheme(PerfmonChart); + + if (selected.Count == 0) { PerfmonChart.Refresh(); return; } + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null; + DateTime? toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + double globalMax = 0; + + for (int i = 0; i < selected.Count; i++) + { + var trend = await _dataService.GetPerfmonTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); + if (trend.Count == 0) continue; + + var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = trend.Select(t => (double)t.DeltaValue).ToArray(); + + var plot = PerfmonChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _perfmonHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + PerfmonChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DateTime rangeStart, rangeEnd; + if (IsCustomRange && fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + PerfmonChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(PerfmonChart); + PerfmonChart.Plot.YLabel("Value"); + SetChartYLimitsWithLegendPadding(PerfmonChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(PerfmonChart); + PerfmonChart.Refresh(); + } + catch + { + /* Ignore chart update errors */ + } + } +} diff --git a/Lite/Controls/ServerTab.Plans.cs b/Lite/Controls/ServerTab.Plans.cs new file mode 100644 index 0000000..64924ba --- /dev/null +++ b/Lite/Controls/ServerTab.Plans.cs @@ -0,0 +1,718 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + private async void DownloadQueryStatsPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not QueryStatsRow row) return; + if (string.IsNullOrEmpty(row.QueryHash)) return; + + btn.IsEnabled = false; + btn.Content = "..."; + try + { + string? plan = null; + var source = "collected data"; + + // Try DuckDB first + try + { + plan = await _dataService.GetCachedQueryPlanAsync(_serverId, row.QueryHash); + } + catch + { + // DuckDB lookup failed, fall through to live server + } + + // Fall back to live server + if (string.IsNullOrEmpty(plan)) + { + var connStr = _server.GetConnectionString(_credentialService); + plan = await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, row.QueryHash); + source = "live server"; + } + + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show("No query plan found in collected data or the live plan cache for this query hash.", "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + SavePlanFile(plan, $"QueryPlan_{row.QueryHash}"); + btn.Content = $"Saved ({source})"; + return; + } + catch (Exception ex) + { + MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn.Content is "...") + btn.Content = "Download"; + btn.IsEnabled = true; + } + } + + private async void DownloadProcedurePlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not ProcedureStatsRow row) return; + if (string.IsNullOrEmpty(row.ObjectName)) return; + + btn.IsEnabled = false; + btn.Content = "..."; + try + { + string? plan = null; + var source = "collected data"; + + // Try DuckDB first — match by plan_handle in query_stats + if (!string.IsNullOrEmpty(row.PlanHandle)) + { + try + { + plan = await _dataService.GetCachedProcedurePlanAsync(_serverId, row.PlanHandle); + } + catch + { + // DuckDB lookup failed, fall through to live server + } + } + + // Fall back to live server + if (string.IsNullOrEmpty(plan)) + { + var connStr = _server.GetConnectionString(_credentialService); + plan = await LocalDataService.FetchProcedurePlanOnDemandAsync(connStr, row.DatabaseName, row.SchemaName, row.ObjectName); + source = "live server"; + } + + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show("No query plan found in collected data or the live plan cache for this procedure.", "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + SavePlanFile(plan, $"ProcPlan_{row.FullName}"); + btn.Content = $"Saved ({source})"; + return; + } + catch (Exception ex) + { + MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn.Content is "...") + btn.Content = "Download"; + btn.IsEnabled = true; + } + } + + private void DownloadSnapshotPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not QuerySnapshotRow row) return; + + if (row.QueryPlan == null) + { + MessageBox.Show( + "No estimated plan is available for this snapshot. The plan may have been evicted from the plan cache.", + "No Plan Available", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + SavePlanFile(row.QueryPlan, $"EstimatedPlan_Session{row.SessionId}"); + } + + private void DownloadSnapshotLivePlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not QuerySnapshotRow row) return; + + if (row.LiveQueryPlan == null) + { + MessageBox.Show( + "No live query plan is available for this snapshot. The query may have completed before the plan could be captured.", + "No Plan Available", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + SavePlanFile(row.LiveQueryPlan, $"ActualPlan_Session{row.SessionId}"); + } + + private void ShowPlanLoading(string label) + { + PlanLoadingLabel.Text = $"Executing: {label}"; + PlanEmptyState.Visibility = Visibility.Collapsed; + PlanTabControl.Visibility = Visibility.Collapsed; + PlanLoadingState.Visibility = Visibility.Visible; + PlanViewerTabItem.IsSelected = true; + } + + private void HidePlanLoading() + { + PlanLoadingState.Visibility = Visibility.Collapsed; + if (PlanTabControl.Items.Count > 0) + PlanTabControl.Visibility = Visibility.Visible; + else + PlanEmptyState.Visibility = Visibility.Visible; + } + + private void OpenPlanTab(string planXml, string label, string? queryText = null) + { + try + { + System.Xml.Linq.XDocument.Parse(planXml); + } + catch (System.Xml.XmlException ex) + { + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + HidePlanLoading(); + var viewer = new PlanViewerControl(); + viewer.LoadPlan(planXml, label, queryText); + + var header = new StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal }; + header.Children.Add(new TextBlock + { + Text = label.Length > 30 ? label[..30] + "…" : label, + VerticalAlignment = System.Windows.VerticalAlignment.Center, + ToolTip = label + }); + var closeBtn = new Button + { + Style = (Style)FindResource("TabCloseButton") + }; + header.Children.Add(closeBtn); + + var tab = new TabItem { Header = header, Content = viewer }; + closeBtn.Tag = tab; + closeBtn.Click += ClosePlanTab_Click; + + PlanTabControl.Items.Add(tab); + PlanTabControl.SelectedItem = tab; + PlanEmptyState.Visibility = Visibility.Collapsed; + PlanTabControl.Visibility = Visibility.Visible; + } + + private void ClosePlanTab_Click(object sender, RoutedEventArgs e) + { + if (sender is Button btn && btn.Tag is TabItem tab) + { + PlanTabControl.Items.Remove(tab); + if (PlanTabControl.Items.Count == 0) + { + PlanTabControl.Visibility = Visibility.Collapsed; + PlanEmptyState.Visibility = Visibility.Visible; + } + } + } + + private void CancelPlanButton_Click(object sender, RoutedEventArgs e) + { + _actualPlanCts?.Cancel(); + } + + private async void ViewEstimatedPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem == null) return; + + string? planXml = null; + string? queryText = null; + string label = "Estimated Plan"; + + switch (grid.CurrentItem) + { + case QuerySnapshotRow snap: + planXml = snap.LiveQueryPlan ?? snap.QueryPlan; + queryText = snap.QueryText; + label = snap.LiveQueryPlan != null + ? $"Plan - SPID {snap.SessionId}" + : $"Est Plan - SPID {snap.SessionId}"; + break; + case QueryStatsRow stats: + planXml = stats.QueryPlan; + queryText = stats.QueryText; + label = $"Est Plan - {stats.QueryHash}"; + // Fetch on demand if not already loaded + if (string.IsNullOrEmpty(planXml)) + planXml = await FetchPlanByHash(stats.QueryHash); + break; + case QueryStatsHistoryRow hist: + planXml = hist.QueryPlan; + label = "Est Plan - History"; + break; + case ProcedureStatsRow proc: + label = $"Est Plan - {proc.FullName}"; + queryText = proc.FullName; + try + { + var connStr = _server.GetConnectionString(_credentialService); + planXml = await LocalDataService.FetchProcedurePlanOnDemandAsync( + connStr, proc.DatabaseName, proc.SchemaName, proc.ObjectName); + } + catch { } + break; + case QueryStoreRow qs: + label = $"Est Plan - QS {qs.QueryId}"; + queryText = qs.QueryText; + if (qs.PlanId > 0) + { + try + { + var connStr = _server.GetConnectionString(_credentialService); + planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); + } + catch { } + } + break; + } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, queryText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + "No query plan is available for this row. The plan may have been evicted from the plan cache since it was last collected.", + "No Plan Available", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + } + + private async void GetActualPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem == null) return; + + string? queryText = null; + string? databaseName = null; + string? planXml = null; + string? isolationLevel = null; + string label = "Actual Plan"; + + switch (grid.CurrentItem) + { + case QuerySnapshotRow snapshot: + queryText = snapshot.QueryText; + databaseName = snapshot.DatabaseName; + planXml = snapshot.LiveQueryPlan ?? snapshot.QueryPlan; + isolationLevel = snapshot.TransactionIsolationLevel; + label = $"Actual Plan - SPID {snapshot.SessionId}"; + break; + case QueryStatsRow stats: + queryText = stats.QueryText; + databaseName = stats.DatabaseName; + label = $"Actual Plan - {stats.QueryHash}"; + if (!string.IsNullOrEmpty(stats.QueryHash)) + { + try { planXml = await FetchPlanByHash(stats.QueryHash); } + catch { } + } + break; + case QueryStoreRow qs: + queryText = qs.QueryText; + databaseName = qs.DatabaseName; + label = $"Actual Plan - QS {qs.QueryId}"; + if (qs.PlanId > 0) + { + try + { + var connStr = _server.GetConnectionString(_credentialService); + planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); + } + catch { } + } + break; + } + + if (string.IsNullOrWhiteSpace(queryText)) + { + MessageBox.Show("No query text available for this row.", "No Query Text", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var result = MessageBox.Show( + $"You are about to execute this query against {_server.ServerName} in database [{databaseName ?? "default"}].\n\n" + + "Make sure you understand what the query does before proceeding.\n" + + "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n" + + "All data results will be discarded.", + "Get Actual Plan", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.OK) return; + + ShowPlanLoading(label); + + _actualPlanCts?.Dispose(); + _actualPlanCts = new CancellationTokenSource(); + + try + { + var connectionString = _server.GetConnectionString(_credentialService); + + var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( + connectionString, + databaseName ?? "", + queryText, + planXml, + isolationLevel, + isAzureSqlDb: false, + timeoutSeconds: 0, + _actualPlanCts.Token); + + if (!string.IsNullOrEmpty(actualPlanXml)) + { + OpenPlanTab(actualPlanXml, label, queryText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show("Query executed but no execution plan was captured.", + "No Plan", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + catch (OperationCanceledException) + { + MessageBox.Show("The query was cancelled or timed out.", + "Cancelled", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to get actual plan:\n\n{ex.Message}", + "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + HidePlanLoading(); + } + } + + private async Task FetchPlanByHash(string queryHash) + { + if (string.IsNullOrEmpty(queryHash)) return null; + + // Try DuckDB cache first + try + { + var plan = await _dataService.GetCachedQueryPlanAsync(_serverId, queryHash); + if (!string.IsNullOrEmpty(plan)) return plan; + } + catch { } + + // Fall back to live server + try + { + var connStr = _server.GetConnectionString(_credentialService); + return await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, queryHash); + } + catch { return null; } + } + + // ── Blocked Process Report plan lookup ── + + /* SQL Server writes this 42-byte all-zero handle into executionStack frames + for dynamic SQL / system contexts where no persistent sql_handle exists. + Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */ + private static readonly string ZeroSqlHandle = "0x" + new string('0', 84); + + private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: false); + + private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: true); + + private async System.Threading.Tasks.Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not BlockedProcessReportRow row) return; + + var sideLabel = blockingSide ? "Blocking" : "Blocked"; + var spid = blockingSide ? row.BlockingSpid : row.BlockedSpid; + var queryText = blockingSide ? row.BlockingSqlText : row.BlockedSqlText; + var label = $"Est Plan - {sideLabel} SPID {spid}"; + + var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); + if (frames.Count == 0) + { + MessageBox.Show( + $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _server.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await LocalDataService.FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, queryText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_server.ServerName}. " + + "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( + string bprXml, bool blockingSide) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(bprXml)) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(bprXml); + var processContainer = blockingSide + ? doc.Element("blocking-process") + : doc.Element("blocked-process"); + var stack = processContainer?.Element("process")?.Element("executionStack"); + if (stack == null) return empty; + + var frames = new List<(string, int, int)>(); + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int stmtStart = 0; + int stmtEnd = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; + + frames.Add((handle!, stmtStart, stmtEnd)); + } + return frames; + } + catch + { + return empty; + } + } + + // ── Deadlock process plan lookup ── + + /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the + node, with optional + children for the call stack. Try process-level first, then walk frames + top-down like sp_HumanEventsBlockViewer does for BPRs. */ + private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not DeadlockProcessDetail row) return; + + var sideLabel = row.IsVictim ? "Victim" : "Deadlocker"; + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + + var frames = ExtractDeadlockProcessFrames(row.DeadlockGraphXml, row.ProcessId); + if (frames.Count == 0) + { + MessageBox.Show( + $"The process has no resolvable sql_handle in the deadlock graph. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _server.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await LocalDataService.FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, row.SqlText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for this {sideLabel.ToLowerInvariant()} process is no longer in the plan cache on {_server.ServerName}. " + + "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( + string graphXml, string processId) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(graphXml) || string.IsNullOrWhiteSpace(processId)) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(graphXml); + var process = doc.Descendants("process") + .FirstOrDefault(p => string.Equals(p.Attribute("id")?.Value, processId, StringComparison.OrdinalIgnoreCase)); + if (process == null) return empty; + + var frames = new List<(string, int, int)>(); + + /* Try process-level sqlhandle first — deadlock graphs frequently put it on . */ + var procHandle = process.Attribute("sqlhandle")?.Value; + if (!string.IsNullOrWhiteSpace(procHandle) && + !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) + { + int ps = 0, pe = -1; + int.TryParse(process.Attribute("stmtstart")?.Value, out ps); + if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; + frames.Add((procHandle!, ps, pe)); + } + + /* Then walk the executionStack frames. */ + var stack = process.Element("executionStack"); + if (stack != null) + { + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int fs = 0, fe = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; + frames.Add((handle!, fs, fe)); + } + } + + return frames; + } + catch + { + return empty; + } + } + + private void SavePlanFile(string planXml, string defaultName) + { + var dialog = new SaveFileDialog + { + Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"{defaultName}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, planXml, Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save plan: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void DownloadDeadlockXml_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not DeadlockProcessDetail row || string.IsNullOrEmpty(row.DeadlockGraphXml)) return; + + var dialog = new SaveFileDialog + { + Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", + DefaultExt = ".xml", + FileName = $"deadlock_{row.DeadlockTime:yyyyMMdd_HHmmss}.xml" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, row.DeadlockGraphXml, Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save deadlock XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void DownloadBlockedProcessXml_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not BlockedProcessReportRow row || string.IsNullOrEmpty(row.BlockedProcessReportXml)) return; + + var dialog = new SaveFileDialog + { + Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", + DefaultExt = ".xml", + FileName = $"blocked_process_{row.EventTime:yyyyMMdd_HHmmss}.xml" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, row.BlockedProcessReportXml, Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save blocked process XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs new file mode 100644 index 0000000..94fa61e --- /dev/null +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -0,0 +1,815 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + /// + /// Public entry point to trigger a data refresh from outside. + /// Loads only the visible tab — other tabs load on demand when clicked. + /// + public async void RefreshData() + { + await RefreshAllDataAsync(fullRefresh: false); + } + + private async System.Threading.Tasks.Task RefreshAllDataAsync(bool fullRefresh = false) + { + if (_isRefreshing) return; + _isRefreshing = true; + + var hoursBack = GetHoursBack(); + + /* Get custom date range if selected, converting local picker dates/times to server time */ + DateTime? fromDate = null; + DateTime? toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + try + { + using var _profiler = Helpers.MethodProfiler.StartTiming($"ServerTab-{_server?.DisplayName}"); + + if (fullRefresh) + { + await RefreshAllTabsAsync(hoursBack, fromDate, toDate); + } + else + { + await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true); + /* Always keep alert badge current even when Blocking tab is not visible */ + if (MainTabControl.SelectedIndex != 8) + await RefreshAlertCountsAsync(hoursBack, fromDate, toDate); + } + + var tz = ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode); + ConnectionStatusText.Text = $"Last refresh: {DateTime.Now:HH:mm:ss} ({tz})"; + } + catch (Exception ex) + { + ConnectionStatusText.Text = $"Error: {ex.Message}"; + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAllDataAsync failed: {ex}"); + } + finally + { + _isRefreshing = false; + } + } + + private async System.Threading.Tasks.Task RefreshVisibleTabAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) + { + switch (MainTabControl.SelectedIndex) + { + case 0: await RefreshOverviewAsync(hoursBack, fromDate, toDate); break; + case 1: await RefreshWaitStatsAsync(hoursBack, fromDate, toDate); break; + case 2: await RefreshQueriesAsync(hoursBack, fromDate, toDate, subTabOnly); break; + case 3: break; // Plan Viewer — no queries + case 4: await RefreshCpuAsync(hoursBack, fromDate, toDate); break; + case 5: await RefreshMemoryAsync(hoursBack, fromDate, toDate, subTabOnly); break; + case 6: await RefreshFileIoAsync(hoursBack, fromDate, toDate); break; + case 7: await RefreshTempDbAsync(hoursBack, fromDate, toDate); break; + case 8: await RefreshBlockingAsync(hoursBack, fromDate, toDate, subTabOnly); break; + case 9: await RefreshPerfmonAsync(hoursBack, fromDate, toDate); break; + case 10: await RefreshRunningJobsAsync(hoursBack, fromDate, toDate); break; + case 11: await RefreshConfigurationAsync(hoursBack, fromDate, toDate); break; + case 12: await RefreshDailySummaryAsync(hoursBack, fromDate, toDate); break; + case 13: await RefreshCollectionHealthAsync(hoursBack, fromDate, toDate); break; + } + } + + /// + /// Lightweight alert-only refresh — fetches blocking + deadlock counts and fires AlertCountsChanged. + /// Runs on every timer tick when the Blocking tab is NOT visible so the tab badge stays current. + /// + private async System.Threading.Tasks.Task RefreshAlertCountsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var (blockingCount, deadlockCount, latestEventTime) = await _dataService.GetAlertCountsAsync(_serverId, hoursBack, fromDate, toDate); + AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAlertCountsAsync failed: {ex.Message}"); + } + } + + /// + /// Full refresh of all tabs — used for first load, manual refresh, and time range changes. + /// + private async System.Threading.Tasks.Task RefreshAllTabsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var loadSw = Stopwatch.StartNew(); + + /* Load all tabs in parallel */ + var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); + var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate); + var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId); + var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); + var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate); + var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate); + var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate); + var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate); + var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); + var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); + var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate); + var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); + var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate); + var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); + var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); + var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); + var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); + var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); + var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); + var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId)); + var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId)); + var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)); + var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack)); + var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); + /* Core data tasks */ + await System.Threading.Tasks.Task.WhenAll( + snapshotsTask, cpuTask, memoryTask, memoryTrendTask, + queryStatsTask, procStatsTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask, + deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask, + queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask, + serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask, + runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask); + + /* Trend chart tasks - run separately so failures don't kill the whole refresh */ + var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); + + await System.Threading.Tasks.Task.WhenAll( + lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, + queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask, + currentWaitsDurationTask, currentWaitsBlockedTask); + + loadSw.Stop(); + + /* Log data counts and timing for diagnostics */ + AppLogger.DataDiag("ServerTab", $"[{_server.DisplayName}] serverId={_serverId} hoursBack={hoursBack} dataLoad={loadSw.ElapsedMilliseconds}ms"); + AppLogger.DataDiag("ServerTab", $" Snapshots: {snapshotsTask.Result.Count}, CPU: {cpuTask.Result.Count}"); + AppLogger.DataDiag("ServerTab", $" Memory: {(memoryTask.Result != null ? "1" : "null")}, MemoryTrend: {memoryTrendTask.Result.Count}"); + AppLogger.DataDiag("ServerTab", $" QueryStats: {queryStatsTask.Result.Count}, ProcStats: {procStatsTask.Result.Count}"); + AppLogger.DataDiag("ServerTab", $" FileIoTrend: {fileIoTrendTask.Result.Count}"); + AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}"); + AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}"); + + /* Update grids (via filter managers to preserve active filters) */ + _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result); + LiveSnapshotIndicator.Text = ""; + _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result); + SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + { + var cEnd = toDate ?? DateTime.UtcNow; + var cStart = fromDate ?? cEnd.AddHours(-hoursBack); + await RefreshQueryStatsComparisonAsync(cStart, cEnd); + } + _procStatsFilterMgr!.UpdateData(procStatsTask.Result); + SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + { + var cEnd2 = toDate ?? DateTime.UtcNow; + var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); + await RefreshProcStatsComparisonAsync(cStart2, cEnd2); + } + _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result); + _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result)); + _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); + SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); + { + var cEnd3 = toDate ?? DateTime.UtcNow; + var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); + await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); + } + _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result); + _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result); + _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result); + _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result); + _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result); + _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); + _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); + var dailySummary = await dailySummaryTask; + DailySummaryGrid.ItemsSource = dailySummary != null + ? new List { dailySummary } : null; + DailySummaryNoData.Visibility = dailySummary == null + ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + UpdateCollectorDurationChart(collectionLogTask.Result); + + /* Update memory summary */ + UpdateMemorySummary(memoryTask.Result); + + /* Update charts */ + UpdateCpuChart(cpuTask.Result); + UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); + UpdateTempDbChart(tempDbTask.Result); + UpdateTempDbFileIoChart(tempDbFileIoTask.Result); + UpdateFileIoCharts(fileIoTrendTask.Result); + UpdateFileIoThroughputCharts(fileIoThroughputTask.Result); + UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate); + UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate); + UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); + UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); + UpdateProcDurationTrendChart(procDurationTrendTask.Result); + UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); + UpdateExecutionCountTrendChart(executionCountTrendTask.Result); + UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); + + /* Populate pickers (preserve selections) */ + PopulateWaitTypePicker(waitTypesTask.Result); + PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); + PopulatePerfmonPicker(perfmonCountersTask.Result); + + /* Update picker-driven charts */ + await UpdateWaitStatsChartFromPickerAsync(); + await UpdateMemoryClerksChartFromPickerAsync(); + await UpdatePerfmonChartFromPickerAsync(); + + /* Notify parent of alert counts for tab badge. + Include the latest event timestamp so acknowledgement is only + cleared when genuinely new events arrive, not when the time range changes. */ + var blockingCount = blockedProcessTask.Result.Count; + var deadlockCount = deadlockTask.Result.Count; + DateTime? latestEventTime = null; + if (blockingCount > 0 || deadlockCount > 0) + { + var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime); + var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime); + latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; + } + AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); + } + + /* ───────────────────────────── Per-tab refresh methods ───────────────────────────── */ + + /// Tab 0 — Wait Stats + private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate); + await waitTypesTask; + PopulateWaitTypePicker(waitTypesTask.Result); + await UpdateWaitStatsChartFromPickerAsync(); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshWaitStatsAsync failed: {ex.Message}"); + } + } + + /// Tab 1 — Queries + private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) + { + try + { + if (subTabOnly) + { + /* Timer tick: only refresh the visible sub-tab (8 queries → 1-4) */ + switch (QueriesSubTabControl.SelectedIndex) + { + case 0: // Performance Trends — 4 trend charts + var qdt = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var pdt = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var qsdt = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var ect = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); + await System.Threading.Tasks.Task.WhenAll(qdt, pdt, qsdt, ect); + UpdateQueryDurationTrendChart(qdt.Result); + UpdateProcDurationTrendChart(pdt.Result); + UpdateQueryStoreDurationTrendChart(qsdt.Result); + UpdateExecutionCountTrendChart(ect.Result); + break; + case 1: // Active Queries + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LiveSnapshotIndicator.Text = ""; + _ = LoadActiveQueriesSlicerAsync(); + break; + case 2: // Top Queries by Duration + var queryStats = await _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + _queryStatsFilterMgr!.UpdateData(queryStats); + SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + _ = LoadQueryStatsSlicerAsync(); + { + var cEnd = toDate ?? DateTime.UtcNow; + var cStart = fromDate ?? cEnd.AddHours(-hoursBack); + await RefreshQueryStatsComparisonAsync(cStart, cEnd); + } + break; + case 3: // Top Procedures by Duration + var procStats = await _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + _procStatsFilterMgr!.UpdateData(procStats); + SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + _ = LoadProcStatsSlicerAsync(); + { + var cEnd = toDate ?? DateTime.UtcNow; + var cStart = fromDate ?? cEnd.AddHours(-hoursBack); + await RefreshProcStatsComparisonAsync(cStart, cEnd); + } + break; + case 4: // Query Store by Duration + var qsData = await _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); + _queryStoreFilterMgr!.UpdateData(qsData); + SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); + _ = LoadQueryStoreSlicerAsync(); + { + var cEnd = toDate ?? DateTime.UtcNow; + var cStart = fromDate ?? cEnd.AddHours(-hoursBack); + await RefreshQueryStoreComparisonAsync(cStart, cEnd); + } + break; + case 5: // Query Heatmap + var hmMetric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; + var hmData = await _dataService.GetQueryHeatmapAsync(_serverId, hmMetric, hoursBack, fromDate, toDate); + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] Heatmap: {hmData.TimeBuckets.Length} time buckets, {hmData.Intensities.GetLength(0)}x{hmData.Intensities.GetLength(1)} grid"); + UpdateQueryHeatmapChart(hmData); + break; + } + return; + } + + /* Full refresh: load all sub-tabs */ + var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); + var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); + var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); + var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var heatmapTask = Task.Run(async () => + { + try { return await _dataService.GetQueryHeatmapAsync(_serverId, (HeatmapMetric)Dispatcher.Invoke(() => HeatmapMetricCombo.SelectedIndex), hoursBack, fromDate, toDate); } + catch { return new HeatmapResult(); } + }); + + await System.Threading.Tasks.Task.WhenAll( + snapshotsTask, queryStatsTask, procStatsTask, queryStoreTask, + queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask, + heatmapTask); + + _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result); + LiveSnapshotIndicator.Text = ""; + + _ = LoadActiveQueriesSlicerAsync(); + + _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result); + SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + _ = LoadQueryStatsSlicerAsync(); + { + var cEnd = toDate ?? DateTime.UtcNow; + var cStart = fromDate ?? cEnd.AddHours(-hoursBack); + await RefreshQueryStatsComparisonAsync(cStart, cEnd); + } + _procStatsFilterMgr!.UpdateData(procStatsTask.Result); + SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + _ = LoadProcStatsSlicerAsync(); + { + var cEnd2 = toDate ?? DateTime.UtcNow; + var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); + await RefreshProcStatsComparisonAsync(cStart2, cEnd2); + } + _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); + SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); + _ = LoadQueryStoreSlicerAsync(); + { + var cEnd3 = toDate ?? DateTime.UtcNow; + var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); + await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); + } + + UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); + UpdateProcDurationTrendChart(procDurationTrendTask.Result); + UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); + UpdateExecutionCountTrendChart(executionCountTrendTask.Result); + UpdateQueryHeatmapChart(heatmapTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshQueriesAsync failed: {ex.Message}"); + } + } + + /// Tab 3 — CPU + /// Tab 0 — Overview (Correlated Timeline Lanes) + private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var comparison = GetComparisonRange(); + await CorrelatedLanes.RefreshAsync(hoursBack, fromDate, toDate, comparison); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshOverviewAsync failed: {ex.Message}"); + } + } + + private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate); + await cpuTask; + UpdateCpuChart(cpuTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCpuAsync failed: {ex.Message}"); + } + } + + /// Tab 4 — Memory + private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) + { + try + { + if (subTabOnly) + { + /* Timer tick: only refresh the visible sub-tab (5 queries → 1-2) */ + switch (MemorySubTabControl.SelectedIndex) + { + case 0: // Overview — memory stats + trend + var memStats = await _dataService.GetLatestMemoryStatsAsync(_serverId); + var memTrend = await _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); + var memGrantTrend = await _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); + UpdateMemorySummary(memStats); + UpdateMemoryChart(memTrend, memGrantTrend); + break; + case 1: // Memory Clerks + var clerkTypes = await _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); + PopulateMemoryClerkPicker(clerkTypes); + await UpdateMemoryClerksChartFromPickerAsync(); + break; + case 2: // Memory Grants + var grantChart = await _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + UpdateMemoryGrantCharts(grantChart); + break; + case 3: // Memory Pressure Events + var pressureEvents = await _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); + UpdateMemoryPressureEventsChart(pressureEvents, hoursBack, fromDate, toDate); + break; + } + return; + } + + /* Full refresh: load all sub-tabs */ + var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId); + var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); + var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); + var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); + var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); + + await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask); + + UpdateMemorySummary(memoryTask.Result); + UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); + UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); + PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); + await UpdateMemoryClerksChartFromPickerAsync(); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshMemoryAsync failed: {ex.Message}"); + } + } + + /// Tab 5 — File I/O + private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate); + var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate); + + await System.Threading.Tasks.Task.WhenAll(fileIoTrendTask, fileIoThroughputTask); + + UpdateFileIoCharts(fileIoTrendTask.Result); + UpdateFileIoThroughputCharts(fileIoThroughputTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshFileIoAsync failed: {ex.Message}"); + } + } + + /// Tab 6 — TempDB + private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate); + var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate); + + await System.Threading.Tasks.Task.WhenAll(tempDbTask, tempDbFileIoTask); + + UpdateTempDbChart(tempDbTask.Result); + UpdateTempDbFileIoChart(tempDbFileIoTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshTempDbAsync failed: {ex.Message}"); + } + } + + /// Tab 7 — Blocking + private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) + { + try + { + if (subTabOnly) + { + /* Timer tick: only refresh the visible sub-tab (7 queries → 1-3) + lightweight alert counts */ + switch (BlockingSubTabControl.SelectedIndex) + { + case 0: // Trends — 3 trend charts + var lwt = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var bt = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var dt = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); + await System.Threading.Tasks.Task.WhenAll(lwt, bt, dt); + UpdateLockWaitTrendChart(lwt.Result, hoursBack, fromDate, toDate); + UpdateBlockingTrendChart(bt.Result, hoursBack, fromDate, toDate); + UpdateDeadlockTrendChart(dt.Result, hoursBack, fromDate, toDate); + break; + case 1: // Current Waits — 2 charts + var cwd = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var cwb = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); + await System.Threading.Tasks.Task.WhenAll(cwd, cwb); + UpdateCurrentWaitsDurationChart(cwd.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsBlockedChart(cwb.Result, hoursBack, fromDate, toDate); + break; + case 2: // Blocked Process Reports + var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); + _blockedProcessFilterMgr!.UpdateData(bpr); + await LoadBlockingSlicerAsync(); + break; + case 3: // Deadlocks + var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); + _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); + await LoadDeadlockSlicerAsync(); + break; + } + /* Always keep alert badge current when Blocking tab is visible */ + await RefreshAlertCountsAsync(hoursBack, fromDate, toDate); + return; + } + + /* Full refresh: load all sub-tabs */ + var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); + var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); + var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); + + await System.Threading.Tasks.Task.WhenAll( + blockedProcessTask, deadlockTask, + lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, + currentWaitsDurationTask, currentWaitsBlockedTask); + + _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result); + _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result)); + + UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate); + UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate); + UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); + + await LoadBlockingSlicerAsync(); + await LoadDeadlockSlicerAsync(); + + /* Notify parent of alert counts for tab badge */ + var blockingCount = blockedProcessTask.Result.Count; + var deadlockCount = deadlockTask.Result.Count; + DateTime? latestEventTime = null; + if (blockingCount > 0 || deadlockCount > 0) + { + var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime); + var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime); + latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; + } + AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshBlockingAsync failed: {ex.Message}"); + } + } + + // ── Blocking Slicer ── + + private string _blockingSlicerMetric = "Events"; + private List? _blockingSlicerData; + + private async System.Threading.Tasks.Task LoadBlockingSlicerAsync() + { + try + { + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + var data = await _dataService.GetBlockingSlicerDataAsync(_serverId, hoursBack, fromDate, toDate); + _blockingSlicerData = data; + _blockingSlicerMetric = "Events"; + var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); + if (data.Count > 0) + BlockingSlicer.LoadData(data, "Blocking Events", slicerStart, slicerEnd); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadBlockingSlicerAsync failed: {ex.Message}"); + } + } + + // ── Deadlock Slicer ── + + private List? _deadlockSlicerData; + + private async System.Threading.Tasks.Task LoadDeadlockSlicerAsync() + { + try + { + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + var data = await _dataService.GetDeadlockSlicerDataAsync(_serverId, hoursBack, fromDate, toDate); + _deadlockSlicerData = data; + var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); + if (data.Count > 0) + DeadlockSlicer.LoadData(data, "Deadlocks", slicerStart, slicerEnd); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadDeadlockSlicerAsync failed: {ex.Message}"); + } + } + + /// Tab 8 — Perfmon + private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate); + await perfmonCountersTask; + PopulatePerfmonPicker(perfmonCountersTask.Result); + await UpdatePerfmonChartFromPickerAsync(); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshPerfmonAsync failed: {ex.Message}"); + } + } + + /// Tab 9 — Running Jobs + private async System.Threading.Tasks.Task RefreshRunningJobsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId)); + await runningJobsTask; + _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshRunningJobsAsync failed: {ex.Message}"); + } + } + + /// Tab 10 — Configuration + private async System.Threading.Tasks.Task RefreshConfigurationAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); + var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); + var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); + var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId)); + + await System.Threading.Tasks.Task.WhenAll(serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask); + + _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result); + _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result); + _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result); + _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshConfigurationAsync failed: {ex.Message}"); + } + } + + /// Tab 11 — Daily Summary + private async System.Threading.Tasks.Task RefreshDailySummaryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); + var dailySummary = await dailySummaryTask; + DailySummaryGrid.ItemsSource = dailySummary != null + ? new List { dailySummary } : null; + DailySummaryNoData.Visibility = dailySummary == null + ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshDailySummaryAsync failed: {ex.Message}"); + } + } + + /// Tab 12 — Collection Health + private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)); + var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack)); + + await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask); + + _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); + _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); + UpdateCollectorDurationChart(collectionLogTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCollectionHealthAsync failed: {ex.Message}"); + } + } + + /// + /// Wraps a query in a try/catch so it returns an empty list on failure instead of faulting. + /// + private static async Task> SafeQueryAsync(Func>> query) + { + try + { + return await query(); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"Trend query failed: {ex.Message}"); + return new List(); + } + } +} diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 1cffb93..b8e0a64 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -99,18 +99,6 @@ public partial class ServerTab : UserControl private DateTime? _dailySummaryDate; // null = today private CancellationTokenSource? _actualPlanCts; - private static readonly HashSet _defaultPerfmonCounters = new( - Helpers.PerfmonPacks.Packs["General Throughput"], - StringComparer.OrdinalIgnoreCase); - - private static readonly string[] SeriesColors = new[] - { - "#4FC3F7", "#E57373", "#81C784", "#FFD54F", "#BA68C8", - "#FFB74D", "#4DD0E1", "#F06292", "#AED581", "#7986CB", - "#FFF176", "#A1887F", "#FF7043", "#80DEEA", "#FFE082", - "#CE93D8", "#EF9A9A", "#C5E1A5", "#FFCC80", "#B0BEC5" - }; - public int UtcOffsetMinutes { get; } private readonly bool _hasMsdbAccess; private readonly bool _isAzureSqlDatabase; @@ -614,74 +602,6 @@ private async void TimeRangeCombo_SelectionChanged(object sender, SelectionChang } } - private async void CompareToCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!IsLoaded || _isRefreshing) return; - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - - await RefreshOverviewAsync(hoursBack, fromDate, toDate); - - // Also refresh comparison grids - try - { - var currentEnd = toDate ?? DateTime.UtcNow; - var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack); - await RefreshQueryStatsComparisonAsync(currentStart, currentEnd); - await RefreshProcStatsComparisonAsync(currentStart, currentEnd); - await RefreshQueryStoreComparisonAsync(currentStart, currentEnd); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] Comparison refresh failed: {ex.Message}"); - } - } - - /// - /// Computes the reference time range for the comparison overlay based on the - /// current Compare dropdown selection and the active time range. - /// Returns null if "None" is selected. - /// - private (DateTime From, DateTime To)? GetComparisonRange() - { - if (CompareToCombo == null || CompareToCombo.SelectedIndex <= 0) return null; - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - - var currentEnd = toDate ?? DateTime.UtcNow; - var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack); - - return CompareToCombo.SelectedIndex switch - { - 1 => (currentStart.AddDays(-1), currentEnd.AddDays(-1)), // Yesterday - 2 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Last week - 3 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Same day last week - _ => null - }; - } - private async void CustomDateRange_Changed(object sender, SelectionChangedEventArgs e) { if (!IsLoaded || _isRefreshing) return; @@ -797,4268 +717,320 @@ private void ApplyThemeRecursively(DependencyObject parent, Brush primaryBg, Bru && FromDatePicker?.SelectedDate != null && ToDatePicker?.SelectedDate != null; - /// - /// Public entry point to trigger a data refresh from outside. - /// Loads only the visible tab — other tabs load on demand when clicked. - /// - public async void RefreshData() + private void BlockedProcessReportGrid_Sorting(object sender, DataGridSortingEventArgs e) { - await RefreshAllDataAsync(fullRefresh: false); - } + if (_blockingSlicerData == null || _blockingSlicerData.Count == 0) return; - private async System.Threading.Tasks.Task RefreshAllDataAsync(bool fullRefresh = false) - { - if (_isRefreshing) return; - _isRefreshing = true; + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col)) + { + if (e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b) + col = b.Path.Path; + } + var (metric, label) = col switch + { + "WaitTimeMs" => ("TotalCpu", "Total Wait (sec)"), + "BlockingSpid" => ("TotalElapsed", "Distinct Blockers"), + "BlockedSpid" => ("TotalReads", "Distinct Blocked"), + "DatabaseName" => ("TotalLogicalReads", "Distinct Databases"), + _ => ("Events", "Blocking Events"), + }; - var hoursBack = GetHoursBack(); + if (metric == _blockingSlicerMetric) return; + _blockingSlicerMetric = metric; - /* Get custom date range if selected, converting local picker dates/times to server time */ - DateTime? fromDate = null; - DateTime? toDate = null; - if (IsCustomRange) + foreach (var bucket in _blockingSlicerData) { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) + bucket.Value = metric switch { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } + "TotalCpu" => bucket.TotalCpu, + "TotalElapsed" => bucket.TotalElapsed, + "TotalReads" => bucket.TotalReads, + "TotalLogicalReads" => bucket.TotalLogicalReads, + _ => bucket.SessionCount, + }; } + BlockingSlicer.UpdateMetric(label); + } + + private async void OnBlockingSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { try { - using var _profiler = Helpers.MethodProfiler.StartTiming($"ServerTab-{_server?.DisplayName}"); - - if (fullRefresh) - { - await RefreshAllTabsAsync(hoursBack, fromDate, toDate); - } - else - { - await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true); - /* Always keep alert badge current even when Blocking tab is not visible */ - if (MainTabControl.SelectedIndex != 8) - await RefreshAlertCountsAsync(hoursBack, fromDate, toDate); - } + var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); + var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var tz = ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode); - ConnectionStatusText.Text = $"Last refresh: {DateTime.Now:HH:mm:ss} ({tz})"; + var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromServer, toServer); + _blockedProcessFilterMgr!.UpdateData(bpr); } catch (Exception ex) { - ConnectionStatusText.Text = $"Error: {ex.Message}"; - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAllDataAsync failed: {ex}"); - } - finally - { - _isRefreshing = false; - } - } - - private async System.Threading.Tasks.Task RefreshVisibleTabAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) - { - switch (MainTabControl.SelectedIndex) - { - case 0: await RefreshOverviewAsync(hoursBack, fromDate, toDate); break; - case 1: await RefreshWaitStatsAsync(hoursBack, fromDate, toDate); break; - case 2: await RefreshQueriesAsync(hoursBack, fromDate, toDate, subTabOnly); break; - case 3: break; // Plan Viewer — no queries - case 4: await RefreshCpuAsync(hoursBack, fromDate, toDate); break; - case 5: await RefreshMemoryAsync(hoursBack, fromDate, toDate, subTabOnly); break; - case 6: await RefreshFileIoAsync(hoursBack, fromDate, toDate); break; - case 7: await RefreshTempDbAsync(hoursBack, fromDate, toDate); break; - case 8: await RefreshBlockingAsync(hoursBack, fromDate, toDate, subTabOnly); break; - case 9: await RefreshPerfmonAsync(hoursBack, fromDate, toDate); break; - case 10: await RefreshRunningJobsAsync(hoursBack, fromDate, toDate); break; - case 11: await RefreshConfigurationAsync(hoursBack, fromDate, toDate); break; - case 12: await RefreshDailySummaryAsync(hoursBack, fromDate, toDate); break; - case 13: await RefreshCollectionHealthAsync(hoursBack, fromDate, toDate); break; + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnBlockingSlicerChanged failed: {ex.Message}"); } } - /// - /// Lightweight alert-only refresh — fetches blocking + deadlock counts and fires AlertCountsChanged. - /// Runs on every timer tick when the Blocking tab is NOT visible so the tab badge stays current. - /// - private async System.Threading.Tasks.Task RefreshAlertCountsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + private async void OnDeadlockSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) { try { - var (blockingCount, deadlockCount, latestEventTime) = await _dataService.GetAlertCountsAsync(_serverId, hoursBack, fromDate, toDate); - AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); + var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); + var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); + + var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, 0, fromServer, toServer); + _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); } catch (Exception ex) { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAlertCountsAsync failed: {ex.Message}"); + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnDeadlockSlicerChanged failed: {ex.Message}"); } } /// - /// Full refresh of all tabs — used for first load, manual refresh, and time range changes. + /// When the user switches main tabs or sub-tabs, refresh only the visible sub-tab. + /// All sub-tabs are loaded on first load and manual refresh — tab/sub-tab switches + /// only need to refresh the one the user is looking at. /// - private async System.Threading.Tasks.Task RefreshAllTabsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + private async void MainTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) { - var loadSw = Stopwatch.StartNew(); - - /* Load all tabs in parallel */ - var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); - var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate); - var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId); - var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); - var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate); - var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate); - var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate); - var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate); - var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); - var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); - var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate); - var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); - var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate); - var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); - var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); - var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); - var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); - var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); - var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); - var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); - var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId)); - var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId)); - var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)); - var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack)); - var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); - /* Core data tasks */ - await System.Threading.Tasks.Task.WhenAll( - snapshotsTask, cpuTask, memoryTask, memoryTrendTask, - queryStatsTask, procStatsTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask, - deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask, - queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask, - serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask, - runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask); - - /* Trend chart tasks - run separately so failures don't kill the whole refresh */ - var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); - - await System.Threading.Tasks.Task.WhenAll( - lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, - queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask, - currentWaitsDurationTask, currentWaitsBlockedTask); - - loadSw.Stop(); - - /* Log data counts and timing for diagnostics */ - AppLogger.DataDiag("ServerTab", $"[{_server.DisplayName}] serverId={_serverId} hoursBack={hoursBack} dataLoad={loadSw.ElapsedMilliseconds}ms"); - AppLogger.DataDiag("ServerTab", $" Snapshots: {snapshotsTask.Result.Count}, CPU: {cpuTask.Result.Count}"); - AppLogger.DataDiag("ServerTab", $" Memory: {(memoryTask.Result != null ? "1" : "null")}, MemoryTrend: {memoryTrendTask.Result.Count}"); - AppLogger.DataDiag("ServerTab", $" QueryStats: {queryStatsTask.Result.Count}, ProcStats: {procStatsTask.Result.Count}"); - AppLogger.DataDiag("ServerTab", $" FileIoTrend: {fileIoTrendTask.Result.Count}"); - AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}"); - AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}"); - - /* Update grids (via filter managers to preserve active filters) */ - _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result); - LiveSnapshotIndicator.Text = ""; - _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result); - SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - { - var cEnd = toDate ?? DateTime.UtcNow; - var cStart = fromDate ?? cEnd.AddHours(-hoursBack); - await RefreshQueryStatsComparisonAsync(cStart, cEnd); - } - _procStatsFilterMgr!.UpdateData(procStatsTask.Result); - SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - { - var cEnd2 = toDate ?? DateTime.UtcNow; - var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); - await RefreshProcStatsComparisonAsync(cStart2, cEnd2); - } - _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result); - _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result)); - _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); - SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); - { - var cEnd3 = toDate ?? DateTime.UtcNow; - var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); - await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); - } - _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result); - _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result); - _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result); - _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result); - _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result); - _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); - _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); - var dailySummary = await dailySummaryTask; - DailySummaryGrid.ItemsSource = dailySummary != null - ? new List { dailySummary } : null; - DailySummaryNoData.Visibility = dailySummary == null - ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - UpdateCollectorDurationChart(collectionLogTask.Result); - - /* Update memory summary */ - UpdateMemorySummary(memoryTask.Result); - - /* Update charts */ - UpdateCpuChart(cpuTask.Result); - UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); - UpdateTempDbChart(tempDbTask.Result); - UpdateTempDbFileIoChart(tempDbFileIoTask.Result); - UpdateFileIoCharts(fileIoTrendTask.Result); - UpdateFileIoThroughputCharts(fileIoThroughputTask.Result); - UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate); - UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate); - UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); - UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); - UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); - UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); - UpdateProcDurationTrendChart(procDurationTrendTask.Result); - UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); - UpdateExecutionCountTrendChart(executionCountTrendTask.Result); - UpdateMemoryGrantCharts(memoryGrantChartTask.Result); - UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); - - /* Populate pickers (preserve selections) */ - PopulateWaitTypePicker(waitTypesTask.Result); - PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); - PopulatePerfmonPicker(perfmonCountersTask.Result); - - /* Update picker-driven charts */ - await UpdateWaitStatsChartFromPickerAsync(); - await UpdateMemoryClerksChartFromPickerAsync(); - await UpdatePerfmonChartFromPickerAsync(); - - /* Notify parent of alert counts for tab badge. - Include the latest event timestamp so acknowledgement is only - cleared when genuinely new events arrive, not when the time range changes. */ - var blockingCount = blockedProcessTask.Result.Count; - var deadlockCount = deadlockTask.Result.Count; - DateTime? latestEventTime = null; - if (blockingCount > 0 || deadlockCount > 0) + if (!IsLoaded || _dataService == null) return; + if (_isRefreshing) return; + if (e.Source != MainTabControl && e.Source != QueriesSubTabControl + && e.Source != MemorySubTabControl && e.Source != BlockingSubTabControl) return; + + UpdateCompareDropdownState(); + + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) { - var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime); - var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime); - latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } } - AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); + await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true); } - /* ───────────────────────────── Per-tab refresh methods ───────────────────────────── */ + // ── Grid → Slicer Overlay (#683) ── - /// Tab 0 — Wait Stats - private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + private (DateTime? fromDate, DateTime? toDate) GetCurrentViewDates() { - try - { - var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate); - await waitTypesTask; - PopulateWaitTypePicker(waitTypesTask.Result); - await UpdateWaitStatsChartFromPickerAsync(); - } - catch (Exception ex) + if (IsCustomRange) { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshWaitStatsAsync failed: {ex.Message}"); + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + return (ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode), + ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode)); } + return (null, null); } - /// Tab 1 — Queries - private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) + /// + /// Computes per-interval deltas from cumulative history values. + /// Picks the metric field based on the current slicer sort metric. + /// + private static List<(DateTime TimeUtc, double Value)> ComputeQueryOverlayPoints( + List history, string slicerMetric) { - try + Func selector = slicerMetric switch { - if (subTabOnly) - { - /* Timer tick: only refresh the visible sub-tab (8 queries → 1-4) */ - switch (QueriesSubTabControl.SelectedIndex) - { - case 0: // Performance Trends — 4 trend charts - var qdt = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var pdt = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var qsdt = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var ect = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); - await System.Threading.Tasks.Task.WhenAll(qdt, pdt, qsdt, ect); - UpdateQueryDurationTrendChart(qdt.Result); - UpdateProcDurationTrendChart(pdt.Result); - UpdateQueryStoreDurationTrendChart(qsdt.Result); - UpdateExecutionCountTrendChart(ect.Result); - break; - case 1: // Active Queries - var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = ""; - _ = LoadActiveQueriesSlicerAsync(); - break; - case 2: // Top Queries by Duration - var queryStats = await _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - _queryStatsFilterMgr!.UpdateData(queryStats); - SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - _ = LoadQueryStatsSlicerAsync(); - { - var cEnd = toDate ?? DateTime.UtcNow; - var cStart = fromDate ?? cEnd.AddHours(-hoursBack); - await RefreshQueryStatsComparisonAsync(cStart, cEnd); - } - break; - case 3: // Top Procedures by Duration - var procStats = await _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - _procStatsFilterMgr!.UpdateData(procStats); - SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - _ = LoadProcStatsSlicerAsync(); - { - var cEnd = toDate ?? DateTime.UtcNow; - var cStart = fromDate ?? cEnd.AddHours(-hoursBack); - await RefreshProcStatsComparisonAsync(cStart, cEnd); - } - break; - case 4: // Query Store by Duration - var qsData = await _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); - _queryStoreFilterMgr!.UpdateData(qsData); - SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); - _ = LoadQueryStoreSlicerAsync(); - { - var cEnd = toDate ?? DateTime.UtcNow; - var cStart = fromDate ?? cEnd.AddHours(-hoursBack); - await RefreshQueryStoreComparisonAsync(cStart, cEnd); - } - break; - case 5: // Query Heatmap - var hmMetric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; - var hmData = await _dataService.GetQueryHeatmapAsync(_serverId, hmMetric, hoursBack, fromDate, toDate); - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] Heatmap: {hmData.TimeBuckets.Length} time buckets, {hmData.Intensities.GetLength(0)}x{hmData.Intensities.GetLength(1)} grid"); - UpdateQueryHeatmapChart(hmData); - break; - } - return; - } - - /* Full refresh: load all sub-tabs */ - var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate); - var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes); - var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); - var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var heatmapTask = Task.Run(async () => - { - try { return await _dataService.GetQueryHeatmapAsync(_serverId, (HeatmapMetric)Dispatcher.Invoke(() => HeatmapMetricCombo.SelectedIndex), hoursBack, fromDate, toDate); } - catch { return new HeatmapResult(); } - }); - - await System.Threading.Tasks.Task.WhenAll( - snapshotsTask, queryStatsTask, procStatsTask, queryStoreTask, - queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask, - heatmapTask); - - _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result); - LiveSnapshotIndicator.Text = ""; - - _ = LoadActiveQueriesSlicerAsync(); - - _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result); - SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - _ = LoadQueryStatsSlicerAsync(); - { - var cEnd = toDate ?? DateTime.UtcNow; - var cStart = fromDate ?? cEnd.AddHours(-hoursBack); - await RefreshQueryStatsComparisonAsync(cStart, cEnd); - } - _procStatsFilterMgr!.UpdateData(procStatsTask.Result); - SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - _ = LoadProcStatsSlicerAsync(); - { - var cEnd2 = toDate ?? DateTime.UtcNow; - var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); - await RefreshProcStatsComparisonAsync(cStart2, cEnd2); - } - _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); - SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); - _ = LoadQueryStoreSlicerAsync(); - { - var cEnd3 = toDate ?? DateTime.UtcNow; - var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); - await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); - } + "TotalCpu" or "AvgCpu" => h => h.DeltaCpuUs, + "TotalReads" or "AvgReads" => h => h.DeltaLogicalReads, + "TotalWrites" => h => h.DeltaLogicalWrites, + "TotalPhysReads" => h => h.DeltaPhysicalReads, + _ => h => h.DeltaElapsedUs, // TotalElapsed, AvgElapsed, default + }; + bool isMicroseconds = slicerMetric is "TotalCpu" or "AvgCpu" or "TotalElapsed" or "AvgElapsed"; - UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); - UpdateProcDurationTrendChart(procDurationTrendTask.Result); - UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); - UpdateExecutionCountTrendChart(executionCountTrendTask.Result); - UpdateQueryHeatmapChart(heatmapTask.Result); - } - catch (Exception ex) + var points = new List<(DateTime TimeUtc, double Value)>(); + for (int i = 1; i < history.Count; i++) { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshQueriesAsync failed: {ex.Message}"); + var delta = selector(history[i]) - selector(history[i - 1]); + if (delta > 0) + points.Add((history[i].CollectionTime, isMicroseconds ? delta / 1000.0 : delta)); } + return points; } - private bool IsQueryStatsComparisonActive => GetComparisonRange() != null; - - private void SetQueryStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + private static List<(DateTime TimeUtc, double Value)> ComputeProcOverlayPoints( + List history, string slicerMetric) { - QueryStatsGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; - QueryStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - QueryStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + Func selector = slicerMetric switch + { + "TotalCpu" or "AvgCpu" => h => h.DeltaCpuUs, + "TotalReads" or "AvgReads" => h => h.DeltaLogicalReads, + "TotalWrites" => h => h.DeltaLogicalWrites, + "TotalPhysReads" => h => h.DeltaPhysicalReads, + _ => h => h.DeltaElapsedUs, + }; + bool isMicroseconds = slicerMetric is "TotalCpu" or "AvgCpu" or "TotalElapsed" or "AvgElapsed"; - if (active && baselineRange.HasValue) + var points = new List<(DateTime TimeUtc, double Value)>(); + for (int i = 1; i < history.Count; i++) { - var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); - var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); - QueryStatsComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; + var delta = selector(history[i]) - selector(history[i - 1]); + if (delta > 0) + points.Add((history[i].CollectionTime, isMicroseconds ? delta / 1000.0 : delta)); } + return points; } - private async System.Threading.Tasks.Task RefreshQueryStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + private async void QueryStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) { - var baselineRange = GetComparisonRange(); - if (baselineRange == null) + if (QueryStatsGrid.SelectedItem is not QueryStatsRow row || string.IsNullOrEmpty(row.QueryHash)) { - SetQueryStatsComparisonMode(false); + if (!_isRefreshing) QueryStatsSlicer.ClearOverlay(); return; } - SetQueryStatsComparisonMode(true, baselineRange); - - var items = await _dataService.GetQueryStatsComparisonAsync( - _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To); - - // Sort: NEW first, then by duration delta descending, GONE last - var sorted = items - .OrderBy(x => x.SortGroup) - .ThenByDescending(x => x.SortableDurationDelta) - .ToList(); + try + { + var hoursBack = GetHoursBack(); + var (fromDate, toDate) = GetCurrentViewDates(); + var history = await _dataService.GetQueryStatsHistoryAsync(_serverId, row.DatabaseName, row.QueryHash, hoursBack, fromDate, toDate); - QueryStatsComparisonGrid.ItemsSource = sorted; + var points = ComputeQueryOverlayPoints(history, _queryStatsSlicerMetric); + QueryStatsSlicer.SetOverlay(points, row.QueryHash); + } + catch { QueryStatsSlicer.ClearOverlay(); } } - private void SetProcStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + private async void ProcedureStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) { - ProcedureStatsGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; - ProcStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - ProcStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + if (ProcedureStatsGrid.SelectedItem is not ProcedureStatsRow row || string.IsNullOrEmpty(row.ObjectName)) + { + if (!_isRefreshing) ProcStatsSlicer.ClearOverlay(); + return; + } - if (active && baselineRange.HasValue) + try { - var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); - var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); - ProcStatsComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; + var hoursBack = GetHoursBack(); + var (fromDate, toDate) = GetCurrentViewDates(); + var history = await _dataService.GetProcedureStatsHistoryAsync(_serverId, row.DatabaseName, row.SchemaName, row.ObjectName, hoursBack, fromDate, toDate); + + var points = ComputeProcOverlayPoints(history, _procStatsSlicerMetric); + var label = row.ObjectName.Length > 30 ? row.ObjectName[..30] + "..." : row.ObjectName; + ProcStatsSlicer.SetOverlay(points, label); } + catch { ProcStatsSlicer.ClearOverlay(); } } - private async System.Threading.Tasks.Task RefreshProcStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + private async void QueryStoreGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) { - var baselineRange = GetComparisonRange(); - if (baselineRange == null) + if (QueryStoreGrid.SelectedItem is not QueryStoreRow row) { - SetProcStatsComparisonMode(false); + if (!_isRefreshing) QueryStoreSlicer.ClearOverlay(); return; } - SetProcStatsComparisonMode(true, baselineRange); - - var items = await _dataService.GetProcedureStatsComparisonAsync( - _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To); - - var sorted = items - .OrderBy(x => x.SortGroup) - .ThenByDescending(x => x.SortableDurationDelta) - .ToList(); - - ProcStatsComparisonGrid.ItemsSource = sorted; - } - - private void SetQueryStoreComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) - { - QueryStoreGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; - QueryStoreComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - QueryStoreComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - - if (active && baselineRange.HasValue) - { - var from = ServerTimeHelper.FormatServerTime(baselineRange.Value.From); - var to = ServerTimeHelper.FormatServerTime(baselineRange.Value.To); - QueryStoreComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; - } - } - - private async System.Threading.Tasks.Task RefreshQueryStoreComparisonAsync(DateTime currentStart, DateTime currentEnd) - { - var baselineRange = GetComparisonRange(); - if (baselineRange == null) - { - SetQueryStoreComparisonMode(false); - return; - } - - SetQueryStoreComparisonMode(true, baselineRange); - - var items = await _dataService.GetQueryStoreComparisonAsync( - _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To); - - var sorted = items - .OrderBy(x => x.SortGroup) - .ThenByDescending(x => x.SortableDurationDelta) - .ToList(); - - QueryStoreComparisonGrid.ItemsSource = sorted; - } - - /// Tab 3 — CPU - /// Tab 0 — Overview (Correlated Timeline Lanes) - private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var comparison = GetComparisonRange(); - await CorrelatedLanes.RefreshAsync(hoursBack, fromDate, toDate, comparison); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshOverviewAsync failed: {ex.Message}"); - } - } - - private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate); - await cpuTask; - UpdateCpuChart(cpuTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCpuAsync failed: {ex.Message}"); - } - } - - /// Tab 4 — Memory - private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) - { - try - { - if (subTabOnly) - { - /* Timer tick: only refresh the visible sub-tab (5 queries → 1-2) */ - switch (MemorySubTabControl.SelectedIndex) - { - case 0: // Overview — memory stats + trend - var memStats = await _dataService.GetLatestMemoryStatsAsync(_serverId); - var memTrend = await _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); - var memGrantTrend = await _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); - UpdateMemorySummary(memStats); - UpdateMemoryChart(memTrend, memGrantTrend); - break; - case 1: // Memory Clerks - var clerkTypes = await _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); - PopulateMemoryClerkPicker(clerkTypes); - await UpdateMemoryClerksChartFromPickerAsync(); - break; - case 2: // Memory Grants - var grantChart = await _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); - UpdateMemoryGrantCharts(grantChart); - break; - case 3: // Memory Pressure Events - var pressureEvents = await _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); - UpdateMemoryPressureEventsChart(pressureEvents, hoursBack, fromDate, toDate); - break; - } - return; - } - - /* Full refresh: load all sub-tabs */ - var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId); - var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); - var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); - var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); - var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); - var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); - - await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask); - - UpdateMemorySummary(memoryTask.Result); - UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); - UpdateMemoryGrantCharts(memoryGrantChartTask.Result); - UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); - PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); - await UpdateMemoryClerksChartFromPickerAsync(); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshMemoryAsync failed: {ex.Message}"); - } - } - - /// Tab 5 — File I/O - private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate); - var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate); - - await System.Threading.Tasks.Task.WhenAll(fileIoTrendTask, fileIoThroughputTask); - - UpdateFileIoCharts(fileIoTrendTask.Result); - UpdateFileIoThroughputCharts(fileIoThroughputTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshFileIoAsync failed: {ex.Message}"); - } - } - - /// Tab 6 — TempDB - private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate); - var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate); - - await System.Threading.Tasks.Task.WhenAll(tempDbTask, tempDbFileIoTask); - - UpdateTempDbChart(tempDbTask.Result); - UpdateTempDbFileIoChart(tempDbFileIoTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshTempDbAsync failed: {ex.Message}"); - } - } - - /// Tab 7 — Blocking - private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) - { - try - { - if (subTabOnly) - { - /* Timer tick: only refresh the visible sub-tab (7 queries → 1-3) + lightweight alert counts */ - switch (BlockingSubTabControl.SelectedIndex) - { - case 0: // Trends — 3 trend charts - var lwt = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var bt = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var dt = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); - await System.Threading.Tasks.Task.WhenAll(lwt, bt, dt); - UpdateLockWaitTrendChart(lwt.Result, hoursBack, fromDate, toDate); - UpdateBlockingTrendChart(bt.Result, hoursBack, fromDate, toDate); - UpdateDeadlockTrendChart(dt.Result, hoursBack, fromDate, toDate); - break; - case 1: // Current Waits — 2 charts - var cwd = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var cwb = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); - await System.Threading.Tasks.Task.WhenAll(cwd, cwb); - UpdateCurrentWaitsDurationChart(cwd.Result, hoursBack, fromDate, toDate); - UpdateCurrentWaitsBlockedChart(cwb.Result, hoursBack, fromDate, toDate); - break; - case 2: // Blocked Process Reports - var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); - _blockedProcessFilterMgr!.UpdateData(bpr); - await LoadBlockingSlicerAsync(); - break; - case 3: // Deadlocks - var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); - _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); - await LoadDeadlockSlicerAsync(); - break; - } - /* Always keep alert badge current when Blocking tab is visible */ - await RefreshAlertCountsAsync(hoursBack, fromDate, toDate); - return; - } - - /* Full refresh: load all sub-tabs */ - var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate); - var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate); - var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); - - await System.Threading.Tasks.Task.WhenAll( - blockedProcessTask, deadlockTask, - lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, - currentWaitsDurationTask, currentWaitsBlockedTask); - - _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result); - _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result)); - - UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate); - UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate); - UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); - UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); - UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); - - await LoadBlockingSlicerAsync(); - await LoadDeadlockSlicerAsync(); - - /* Notify parent of alert counts for tab badge */ - var blockingCount = blockedProcessTask.Result.Count; - var deadlockCount = deadlockTask.Result.Count; - DateTime? latestEventTime = null; - if (blockingCount > 0 || deadlockCount > 0) - { - var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime); - var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime); - latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock; - } - AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshBlockingAsync failed: {ex.Message}"); - } - } - - // ── Blocking Slicer ── - - private string _blockingSlicerMetric = "Events"; - private List? _blockingSlicerData; - - private async System.Threading.Tasks.Task LoadBlockingSlicerAsync() - { - try - { - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - - var data = await _dataService.GetBlockingSlicerDataAsync(_serverId, hoursBack, fromDate, toDate); - _blockingSlicerData = data; - _blockingSlicerMetric = "Events"; - var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); - if (data.Count > 0) - BlockingSlicer.LoadData(data, "Blocking Events", slicerStart, slicerEnd); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadBlockingSlicerAsync failed: {ex.Message}"); - } - } - - private async void OnBlockingSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) - { - try - { - var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); - var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - - var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromServer, toServer); - _blockedProcessFilterMgr!.UpdateData(bpr); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnBlockingSlicerChanged failed: {ex.Message}"); - } - } - - private void BlockedProcessReportGrid_Sorting(object sender, DataGridSortingEventArgs e) - { - if (_blockingSlicerData == null || _blockingSlicerData.Count == 0) return; - - var col = e.Column.SortMemberPath ?? ""; - if (string.IsNullOrEmpty(col)) - { - if (e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b) - col = b.Path.Path; - } - var (metric, label) = col switch - { - "WaitTimeMs" => ("TotalCpu", "Total Wait (sec)"), - "BlockingSpid" => ("TotalElapsed", "Distinct Blockers"), - "BlockedSpid" => ("TotalReads", "Distinct Blocked"), - "DatabaseName" => ("TotalLogicalReads", "Distinct Databases"), - _ => ("Events", "Blocking Events"), - }; - - if (metric == _blockingSlicerMetric) return; - _blockingSlicerMetric = metric; - - foreach (var bucket in _blockingSlicerData) - { - bucket.Value = metric switch - { - "TotalCpu" => bucket.TotalCpu, - "TotalElapsed" => bucket.TotalElapsed, - "TotalReads" => bucket.TotalReads, - "TotalLogicalReads" => bucket.TotalLogicalReads, - _ => bucket.SessionCount, - }; - } - - BlockingSlicer.UpdateMetric(label); - } - - // ── Deadlock Slicer ── - - private List? _deadlockSlicerData; - - private async System.Threading.Tasks.Task LoadDeadlockSlicerAsync() - { - try - { - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - - var data = await _dataService.GetDeadlockSlicerDataAsync(_serverId, hoursBack, fromDate, toDate); - _deadlockSlicerData = data; - var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); - if (data.Count > 0) - DeadlockSlicer.LoadData(data, "Deadlocks", slicerStart, slicerEnd); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadDeadlockSlicerAsync failed: {ex.Message}"); - } - } - - private async void OnDeadlockSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) - { - try - { - var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); - var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - - var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, 0, fromServer, toServer); - _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnDeadlockSlicerChanged failed: {ex.Message}"); - } - } - - /// Tab 8 — Perfmon - private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate); - await perfmonCountersTask; - PopulatePerfmonPicker(perfmonCountersTask.Result); - await UpdatePerfmonChartFromPickerAsync(); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshPerfmonAsync failed: {ex.Message}"); - } - } - - /// Tab 9 — Running Jobs - private async System.Threading.Tasks.Task RefreshRunningJobsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId)); - await runningJobsTask; - _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshRunningJobsAsync failed: {ex.Message}"); - } - } - - /// Tab 10 — Configuration - private async System.Threading.Tasks.Task RefreshConfigurationAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); - var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); - var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); - var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId)); - - await System.Threading.Tasks.Task.WhenAll(serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask); - - _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result); - _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result); - _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result); - _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshConfigurationAsync failed: {ex.Message}"); - } - } - - /// Tab 11 — Daily Summary - private async System.Threading.Tasks.Task RefreshDailySummaryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); - var dailySummary = await dailySummaryTask; - DailySummaryGrid.ItemsSource = dailySummary != null - ? new List { dailySummary } : null; - DailySummaryNoData.Visibility = dailySummary == null - ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshDailySummaryAsync failed: {ex.Message}"); - } - } - - /// Tab 12 — Collection Health - private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - try - { - var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)); - var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack)); - - await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask); - - _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); - _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); - UpdateCollectorDurationChart(collectionLogTask.Result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCollectionHealthAsync failed: {ex.Message}"); - } - } - - /// - /// When the user switches main tabs or sub-tabs, refresh only the visible sub-tab. - /// All sub-tabs are loaded on first load and manual refresh — tab/sub-tab switches - /// only need to refresh the one the user is looking at. - /// - private async void MainTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!IsLoaded || _dataService == null) return; - if (_isRefreshing) return; - if (e.Source != MainTabControl && e.Source != QueriesSubTabControl - && e.Source != MemorySubTabControl && e.Source != BlockingSubTabControl) return; - - UpdateCompareDropdownState(); - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true); - } - - private bool IsComparisonSupportedOnCurrentTab() - { - return MainTabControl.SelectedIndex switch - { - 0 => true, // Overview — correlated timeline lanes - 2 => QueriesSubTabControl.SelectedIndex is 2 or 3 or 4, // Top Queries / Top Procedures / Query Store - _ => false - }; - } - - private void UpdateCompareDropdownState() - { - var supported = IsComparisonSupportedOnCurrentTab(); - - if (supported) - { - CompareToCombo.IsEnabled = true; - CompareToCombo.Opacity = 1.0; - CompareToCombo.ToolTip = "Compare current period against a baseline"; - } - else - { - CompareToCombo.SelectedIndex = 0; - CompareToCombo.IsEnabled = false; - CompareToCombo.Opacity = 0.5; - CompareToCombo.ToolTip = "Comparison is not available for this tab"; - } - } - - /// - /// Wraps a query in a try/catch so it returns an empty list on failure instead of faulting. - /// - private static async Task> SafeQueryAsync(Func>> query) - { - try - { - return await query(); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"Trend query failed: {ex.Message}"); - return new List(); - } - } - - private void UpdateMemorySummary(MemoryStatsRow? stats) - { - if (stats == null) - { - PhysicalMemoryText.Text = "--"; - AvailablePhysicalMemoryText.Text = "--"; - TotalServerMemoryText.Text = "--"; - TargetServerMemoryText.Text = "--"; - BufferPoolText.Text = "--"; - PlanCacheText.Text = "--"; - TotalPageFileText.Text = "--"; - AvailablePageFileText.Text = "--"; - MemoryStateText.Text = "--"; - SqlMemoryModelText.Text = "--"; - return; - } - - PhysicalMemoryText.Text = FormatMb(stats.TotalPhysicalMemoryMb); - AvailablePhysicalMemoryText.Text = FormatMb(stats.AvailablePhysicalMemoryMb); - TotalServerMemoryText.Text = FormatMb(stats.TotalServerMemoryMb); - TargetServerMemoryText.Text = FormatMb(stats.TargetServerMemoryMb); - BufferPoolText.Text = FormatMb(stats.BufferPoolMb); - PlanCacheText.Text = FormatMb(stats.PlanCacheMb); - TotalPageFileText.Text = FormatMb(stats.TotalPageFileMb); - AvailablePageFileText.Text = FormatMb(stats.AvailablePageFileMb); - MemoryStateText.Text = stats.SystemMemoryState; - SqlMemoryModelText.Text = stats.SqlMemoryModel; - } - - private static string FormatMb(double mb) - { - return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB"; - } - - - private void UpdateCpuChart(List data) - { - ClearChart(CpuChart); - _cpuHover?.Clear(); - ApplyTheme(CpuChart); - - if (data.Count == 0) { CpuChart.Refresh(); return; } - - var times = data.Select(d => d.SampleTime.ToOADate()).ToArray(); - var sqlCpu = data.Select(d => (double)d.SqlServerCpu).ToArray(); - var otherCpu = data.Select(d => (double)d.OtherProcessCpu).ToArray(); - - var sqlPlot = CpuChart.Plot.Add.Scatter(times, sqlCpu); - sqlPlot.LegendText = "SQL Server"; - sqlPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); - _cpuHover?.Add(sqlPlot, "SQL Server"); - - var otherPlot = CpuChart.Plot.Add.Scatter(times, otherCpu); - otherPlot.LegendText = "Other"; - otherPlot.Color = ScottPlot.Color.FromHex("#E57373"); - _cpuHover?.Add(otherPlot, "Other"); - - CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(CpuChart); - CpuChart.Plot.YLabel("CPU %"); - CpuChart.Plot.Axes.SetLimitsY(0, 105); - - ShowChartLegend(CpuChart); - CpuChart.Refresh(); - } - - private void UpdateMemoryChart(List data, List grantData) - { - ClearChart(MemoryChart); - _memoryHover?.Clear(); - ApplyTheme(MemoryChart); - - if (data.Count == 0) { MemoryChart.Refresh(); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var totalMem = data.Select(d => d.TotalServerMemoryMb / 1024.0).ToArray(); - var targetMem = data.Select(d => d.TargetServerMemoryMb / 1024.0).ToArray(); - var bufferPool = data.Select(d => d.BufferPoolMb / 1024.0).ToArray(); - - var totalPlot = MemoryChart.Plot.Add.Scatter(times, totalMem); - totalPlot.LegendText = "Total Server Memory"; - totalPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); - _memoryHover?.Add(totalPlot, "Total Server Memory"); - - var targetPlot = MemoryChart.Plot.Add.Scatter(times, targetMem); - targetPlot.LegendText = "Target Memory"; - targetPlot.Color = ScottPlot.Colors.Gray; - targetPlot.LineStyle.Pattern = LinePattern.Dashed; - _memoryHover?.Add(targetPlot, "Target Memory"); - - var bpPlot = MemoryChart.Plot.Add.Scatter(times, bufferPool); - bpPlot.LegendText = "Buffer Pool"; - bpPlot.Color = ScottPlot.Color.FromHex("#81C784"); - _memoryHover?.Add(bpPlot, "Buffer Pool"); - - /* Memory grants trend line — show zero line when no grant data */ - double[] grantTimes, grantMb; - if (grantData.Count > 0) - { - grantTimes = grantData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - grantMb = grantData.Select(d => d.TotalGrantedMb / 1024.0).ToArray(); - } - else - { - grantTimes = new[] { times.First(), times.Last() }; - grantMb = new[] { 0.0, 0.0 }; - } - - var grantPlot = MemoryChart.Plot.Add.Scatter(grantTimes, grantMb); - grantPlot.LegendText = "Memory Grants"; - grantPlot.Color = ScottPlot.Color.FromHex("#FFB74D"); - _memoryHover?.Add(grantPlot, "Memory Grants"); - - MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(MemoryChart); - MemoryChart.Plot.YLabel("Memory (GB)"); - - var maxVal = totalMem.Max(); - SetChartYLimitsWithLegendPadding(MemoryChart, 0, maxVal); - - ShowChartLegend(MemoryChart); - MemoryChart.Refresh(); - } - - private void UpdateMemoryGrantCharts(List data) - { - ClearChart(MemoryGrantSizingChart); - ClearChart(MemoryGrantActivityChart); - _memoryGrantSizingHover?.Clear(); - _memoryGrantActivityHover?.Clear(); - ApplyTheme(MemoryGrantSizingChart); - ApplyTheme(MemoryGrantActivityChart); - - if (data.Count == 0) - { - MemoryGrantSizingChart.Refresh(); - MemoryGrantActivityChart.Refresh(); - return; - } - - var poolIds = data.Select(d => d.PoolId).Distinct().OrderBy(p => p).ToList(); - int colorIndex = 0; - - /* Chart 1: Memory Grant Sizing — Available, Granted, Used MB per pool */ - double sizingMax = 0; - var sizingMetrics = new (string Name, Func Selector)[] - { - ("Available MB", d => d.AvailableMemoryMb), - ("Granted MB", d => d.GrantedMemoryMb), - ("Used MB", d => d.UsedMemoryMb) - }; - - foreach (var poolId in poolIds) - { - var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - var times = poolData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - - foreach (var metric in sizingMetrics) - { - var values = poolData.Select(d => metric.Selector(d)).ToArray(); - var plot = MemoryGrantSizingChart.Plot.Add.Scatter(times, values); - var label = $"Pool {poolId}: {metric.Name}"; - plot.LegendText = label; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); - _memoryGrantSizingHover?.Add(plot, label); - if (values.Length > 0) sizingMax = Math.Max(sizingMax, values.Max()); - colorIndex++; - } - } - - MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(MemoryGrantSizingChart); - MemoryGrantSizingChart.Plot.YLabel("Memory (MB)"); - SetChartYLimitsWithLegendPadding(MemoryGrantSizingChart, 0, sizingMax > 0 ? sizingMax : 100); - ShowChartLegend(MemoryGrantSizingChart); - MemoryGrantSizingChart.Refresh(); - - /* Chart 2: Memory Grant Activity — Grantees, Waiters, Timeouts, Forced per pool */ - double activityMax = 0; - colorIndex = 0; - var activityMetrics = new (string Name, Func Selector)[] - { - ("Grantees", d => d.GranteeCount), - ("Waiters", d => d.WaiterCount), - ("Timeouts", d => d.TimeoutErrorCountDelta), - ("Forced Grants", d => d.ForcedGrantCountDelta) - }; - - foreach (var poolId in poolIds) - { - var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - var times = poolData.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - - foreach (var metric in activityMetrics) - { - var values = poolData.Select(d => metric.Selector(d)).ToArray(); - var plot = MemoryGrantActivityChart.Plot.Add.Scatter(times, values); - var label = $"Pool {poolId}: {metric.Name}"; - plot.LegendText = label; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); - _memoryGrantActivityHover?.Add(plot, label); - if (values.Length > 0) activityMax = Math.Max(activityMax, values.Max()); - colorIndex++; - } - } - - MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(MemoryGrantActivityChart); - MemoryGrantActivityChart.Plot.YLabel("Count"); - SetChartYLimitsWithLegendPadding(MemoryGrantActivityChart, 0, activityMax > 0 ? activityMax : 10); - ShowChartLegend(MemoryGrantActivityChart); - MemoryGrantActivityChart.Refresh(); - } - - /// - /// Stacked bar chart of memory pressure events per hour, split by SQL Server (process) vs - /// Operating System (system) and stacked by severity (medium=indicator 2, severe=indicator >= 3). - /// - private void UpdateMemoryPressureEventsChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(MemoryPressureEventsChart); - _memoryPressureEventsHover?.Clear(); - ApplyTheme(MemoryPressureEventsChart); - - DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - /* Only count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). */ - var pressureRows = data - .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) - .OrderBy(d => d.SampleTime) - .ToList(); - - bool hasData = false; - int maxBarCount = 0; - - if (pressureRows.Count > 0) - { - var grouped = pressureRows - .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) - .OrderBy(g => g.Key) - .ToList(); - - double hourWidth = 1.0 / 24.0; - double barSize = hourWidth * 0.4; - double barOffset = hourWidth * 0.22; - - var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 - var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 - var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 - var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 - - var sqlMediumBars = new List(); - var sqlSevereBars = new List(); - var osMediumBars = new List(); - var osSevereBars = new List(); - - foreach (var g in grouped) - { - int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); - int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); - int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); - int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); - double x = g.Key.AddMinutes(UtcOffsetMinutes).ToOADate(); - - if (sqlMedium > 0) - sqlMediumBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = 0, Value = sqlMedium, Size = barSize, FillColor = sqlMediumColor, LineWidth = 0 }); - if (sqlSevere > 0) - sqlSevereBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = sqlMedium, Value = sqlMedium + sqlSevere, Size = barSize, FillColor = sqlSevereColor, LineWidth = 0 }); - if (osMedium > 0) - osMediumBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = 0, Value = osMedium, Size = barSize, FillColor = osMediumColor, LineWidth = 0 }); - if (osSevere > 0) - osSevereBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = osMedium, Value = osMedium + osSevere, Size = barSize, FillColor = osSevereColor, LineWidth = 0 }); - - int sqlTotal = sqlMedium + sqlSevere; - int osTotal = osMedium + osSevere; - if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; - if (osTotal > maxBarCount) maxBarCount = osTotal; - } - - if (sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 || osMediumBars.Count > 0 || osSevereBars.Count > 0) - { - hasData = true; - - if (sqlMediumBars.Count > 0) - { - var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); - bp.LegendText = "SQL Server (medium)"; - _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); - } - if (sqlSevereBars.Count > 0) - { - var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); - bp.LegendText = "SQL Server (severe)"; - _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); - } - if (osMediumBars.Count > 0) - { - var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); - bp.LegendText = "Operating System (medium)"; - _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); - } - if (osSevereBars.Count > 0) - { - var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); - bp.LegendText = "Operating System (severe)"; - _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); - } - } - } - - MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); - ReapplyAxisColors(MemoryPressureEventsChart); - MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); - SetChartYLimitsWithLegendPadding(MemoryPressureEventsChart, 0, Math.Max(maxBarCount, 5)); - - if (hasData) - { - ShowChartLegend(MemoryPressureEventsChart); - } - - MemoryPressureEventsChart.Refresh(); - } - - private void UpdateTempDbChart(List data) - { - ClearChart(TempDbChart); - _tempDbHover?.Clear(); - ApplyTheme(TempDbChart); - - if (data.Count == 0) { TempDbChart.Refresh(); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var userObj = data.Select(d => d.UserObjectReservedMb).ToArray(); - var internalObj = data.Select(d => d.InternalObjectReservedMb).ToArray(); - var versionStore = data.Select(d => d.VersionStoreReservedMb).ToArray(); - - var userPlot = TempDbChart.Plot.Add.Scatter(times, userObj); - userPlot.LegendText = "User Objects"; - userPlot.Color = ScottPlot.Color.FromHex("#4FC3F7"); - _tempDbHover?.Add(userPlot, "User Objects"); - - var internalPlot = TempDbChart.Plot.Add.Scatter(times, internalObj); - internalPlot.LegendText = "Internal Objects"; - internalPlot.Color = ScottPlot.Color.FromHex("#FFD54F"); - _tempDbHover?.Add(internalPlot, "Internal Objects"); - - var vsPlot = TempDbChart.Plot.Add.Scatter(times, versionStore); - vsPlot.LegendText = "Version Store"; - vsPlot.Color = ScottPlot.Color.FromHex("#81C784"); - _tempDbHover?.Add(vsPlot, "Version Store"); - - TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(TempDbChart); - TempDbChart.Plot.YLabel("MB"); - - var maxVal = new[] { userObj.Max(), internalObj.Max(), versionStore.Max() }.Max(); - SetChartYLimitsWithLegendPadding(TempDbChart, 0, maxVal); - - ShowChartLegend(TempDbChart); - TempDbChart.Refresh(); - } - - private void UpdateTempDbFileIoChart(List data) - { - ClearChart(TempDbFileIoChart); - _tempDbFileIoHover?.Clear(); - ApplyTheme(TempDbFileIoChart); - - if (data.Count == 0) { TempDbFileIoChart.Refresh(); return; } - - var files = data - .GroupBy(d => d.DatabaseName) - .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) - .Take(12) - .ToList(); - - double maxLatency = 0; - int colorIdx = 0; - - foreach (var fileGroup in files) - { - var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); - var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var latency = points.Select(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs).ToArray(); - var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); - colorIdx++; - - if (latency.Length > 0) - { - var plot = TempDbFileIoChart.Plot.Add.Scatter(times, latency); - plot.LegendText = fileGroup.Key; - plot.Color = color; - _tempDbFileIoHover?.Add(plot, fileGroup.Key); - maxLatency = Math.Max(maxLatency, latency.Max()); - } - } - - TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(TempDbFileIoChart); - TempDbFileIoChart.Plot.YLabel("TempDB File I/O Latency (ms)"); - SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10); - ShowChartLegend(TempDbFileIoChart); - TempDbFileIoChart.Refresh(); - } - - private void UpdateFileIoCharts(List data) - { - ClearChart(FileIoReadChart); - ClearChart(FileIoWriteChart); - _fileIoReadHover?.Clear(); - _fileIoWriteHover?.Clear(); - ApplyTheme(FileIoReadChart); - ApplyTheme(FileIoWriteChart); - - if (data.Count == 0) { FileIoReadChart.Refresh(); FileIoWriteChart.Refresh(); return; } - - /* Group by file, limit to top 10 by total stall */ - var databases = data - .GroupBy(d => $"{d.DatabaseName}.{d.FileName}") - .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) - .Take(10) - .ToList(); - - double readMax = 0, writeMax = 0; - int colorIdx = 0; - - bool hasQueuedData = data.Any(d => d.AvgQueuedReadLatencyMs > 0 || d.AvgQueuedWriteLatencyMs > 0); - - foreach (var dbGroup in databases) - { - var points = dbGroup.OrderBy(d => d.CollectionTime).ToList(); - var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var readLatency = points.Select(d => d.AvgReadLatencyMs).ToArray(); - var writeLatency = points.Select(d => d.AvgWriteLatencyMs).ToArray(); - var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); - colorIdx++; - - if (readLatency.Length > 0) - { - var readPlot = FileIoReadChart.Plot.Add.Scatter(times, readLatency); - readPlot.LegendText = dbGroup.Key; - readPlot.Color = color; - _fileIoReadHover?.Add(readPlot, dbGroup.Key); - readMax = Math.Max(readMax, readLatency.Max()); - } - - if (writeLatency.Length > 0) - { - var writePlot = FileIoWriteChart.Plot.Add.Scatter(times, writeLatency); - writePlot.LegendText = dbGroup.Key; - writePlot.Color = color; - _fileIoWriteHover?.Add(writePlot, dbGroup.Key); - writeMax = Math.Max(writeMax, writeLatency.Max()); - } - - /* Queued I/O overlay — dashed lines showing queue wait portion of latency */ - if (hasQueuedData) - { - var queuedReadLatency = points.Select(d => d.AvgQueuedReadLatencyMs).ToArray(); - var queuedWriteLatency = points.Select(d => d.AvgQueuedWriteLatencyMs).ToArray(); - - if (queuedReadLatency.Any(v => v > 0)) - { - var qReadPlot = FileIoReadChart.Plot.Add.Scatter(times, queuedReadLatency); - qReadPlot.LegendText = $"{dbGroup.Key} (queued)"; - qReadPlot.Color = color; - qReadPlot.LinePattern = ScottPlot.LinePattern.Dashed; - _fileIoReadHover?.Add(qReadPlot, $"{dbGroup.Key} (queued)"); - } - - if (queuedWriteLatency.Any(v => v > 0)) - { - var qWritePlot = FileIoWriteChart.Plot.Add.Scatter(times, queuedWriteLatency); - qWritePlot.LegendText = $"{dbGroup.Key} (queued)"; - qWritePlot.Color = color; - qWritePlot.LinePattern = ScottPlot.LinePattern.Dashed; - _fileIoWriteHover?.Add(qWritePlot, $"{dbGroup.Key} (queued)"); - } - } - } - - FileIoReadChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(FileIoReadChart); - FileIoReadChart.Plot.YLabel("Read Latency (ms)"); - SetChartYLimitsWithLegendPadding(FileIoReadChart, 0, readMax > 0 ? readMax : 10); - ShowChartLegend(FileIoReadChart); - FileIoReadChart.Refresh(); - - FileIoWriteChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(FileIoWriteChart); - FileIoWriteChart.Plot.YLabel("Write Latency (ms)"); - SetChartYLimitsWithLegendPadding(FileIoWriteChart, 0, writeMax > 0 ? writeMax : 10); - ShowChartLegend(FileIoWriteChart); - FileIoWriteChart.Refresh(); - } - - private void UpdateFileIoThroughputCharts(List data) - { - ClearChart(FileIoReadThroughputChart); - ClearChart(FileIoWriteThroughputChart); - _fileIoReadThroughputHover?.Clear(); - _fileIoWriteThroughputHover?.Clear(); - ApplyTheme(FileIoReadThroughputChart); - ApplyTheme(FileIoWriteThroughputChart); - - if (data.Count == 0) { FileIoReadThroughputChart.Refresh(); FileIoWriteThroughputChart.Refresh(); return; } - - /* Group by file label, limit to top 10 by total throughput */ - var files = data - .GroupBy(d => d.FileLabel) - .OrderByDescending(g => g.Sum(d => d.ReadMbPerSec + d.WriteMbPerSec)) - .Take(10) - .ToList(); - - double readMax = 0, writeMax = 0; - int colorIdx = 0; - - foreach (var fileGroup in files) - { - var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); - var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var readThroughput = points.Select(d => d.ReadMbPerSec).ToArray(); - var writeThroughput = points.Select(d => d.WriteMbPerSec).ToArray(); - var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); - colorIdx++; - - if (readThroughput.Length > 0) - { - var readPlot = FileIoReadThroughputChart.Plot.Add.Scatter(times, readThroughput); - readPlot.LegendText = fileGroup.Key; - readPlot.Color = color; - _fileIoReadThroughputHover?.Add(readPlot, fileGroup.Key); - readMax = Math.Max(readMax, readThroughput.Max()); - } - - if (writeThroughput.Length > 0) - { - var writePlot = FileIoWriteThroughputChart.Plot.Add.Scatter(times, writeThroughput); - writePlot.LegendText = fileGroup.Key; - writePlot.Color = color; - _fileIoWriteThroughputHover?.Add(writePlot, fileGroup.Key); - writeMax = Math.Max(writeMax, writeThroughput.Max()); - } - } - - FileIoReadThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(FileIoReadThroughputChart); - FileIoReadThroughputChart.Plot.YLabel("Read Throughput (MB/s)"); - SetChartYLimitsWithLegendPadding(FileIoReadThroughputChart, 0, readMax > 0 ? readMax : 1); - ShowChartLegend(FileIoReadThroughputChart); - FileIoReadThroughputChart.Refresh(); - - FileIoWriteThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(FileIoWriteThroughputChart); - FileIoWriteThroughputChart.Plot.YLabel("Write Throughput (MB/s)"); - SetChartYLimitsWithLegendPadding(FileIoWriteThroughputChart, 0, writeMax > 0 ? writeMax : 1); - ShowChartLegend(FileIoWriteThroughputChart); - FileIoWriteThroughputChart.Refresh(); - } - - /* ========== Blocking/Deadlock Trend Charts ========== */ - - private void UpdateLockWaitTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(LockWaitTrendChart); - ApplyTheme(LockWaitTrendChart); - - DateTime rangeStart, rangeEnd; - if (fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - - _lockWaitTrendHover?.Clear(); - if (data.Count == 0) - { - var zeroLine = LockWaitTrendChart.Plot.Add.Scatter( - new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, - new[] { 0.0, 0.0 }); - zeroLine.LegendText = "Lock Waits"; - zeroLine.Color = ScottPlot.Color.FromHex("#4FC3F7"); - zeroLine.MarkerSize = 0; - LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(LockWaitTrendChart); - LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); - SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, 1); - ShowChartLegend(LockWaitTrendChart); - LockWaitTrendChart.Refresh(); - return; - } - - var grouped = data.GroupBy(d => d.WaitType).ToList(); - double globalMax = 0; - - for (int i = 0; i < grouped.Count; i++) - { - var group = grouped[i]; - var times = group.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = group.Select(t => t.WaitTimeMsPerSecond).ToArray(); - - var plot = LockWaitTrendChart.Plot.Add.Scatter(times, values); - plot.LegendText = group.Key; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _lockWaitTrendHover?.Add(plot, group.Key); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - } - - LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(LockWaitTrendChart); - LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); - SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, globalMax > 0 ? globalMax : 1); - ShowChartLegend(LockWaitTrendChart); - LockWaitTrendChart.Refresh(); - } - - private void UpdateBlockingTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(BlockingTrendChart); - ApplyTheme(BlockingTrendChart); - - /* Calculate X-axis range based on selected time window */ - DateTime rangeStart, rangeEnd; - if (fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - - _blockingTrendHover?.Clear(); - if (data.Count == 0) - { - /* No blocking events — show a flat line at zero so the chart looks active */ - var zeroLine = BlockingTrendChart.Plot.Add.Scatter( - new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, - new[] { 0.0, 0.0 }); - zeroLine.LegendText = "Blocking Incidents"; - zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); - zeroLine.MarkerSize = 0; - BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(BlockingTrendChart); - BlockingTrendChart.Plot.YLabel("Blocking Incidents"); - SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1); - ShowChartLegend(BlockingTrendChart); - BlockingTrendChart.Refresh(); - return; - } - - /* Build arrays with zero baseline between data points for spike effect */ - var expandedTimes = new List(); - var expandedCounts = new List(); - - /* Add zero at start */ - expandedTimes.Add(rangeStart.ToOADate()); - expandedCounts.Add(0); - - foreach (var point in data.OrderBy(d => d.Time)) - { - var time = point.Time.AddMinutes(UtcOffsetMinutes).ToOADate(); - /* Go to zero just before the spike */ - expandedTimes.Add(time - 0.0001); - expandedCounts.Add(0); - /* Spike up */ - expandedTimes.Add(time); - expandedCounts.Add(point.Count); - /* Back to zero just after */ - expandedTimes.Add(time + 0.0001); - expandedCounts.Add(0); - } - - /* Add zero at end */ - expandedTimes.Add(rangeEnd.ToOADate()); - expandedCounts.Add(0); - - var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); - plot.LegendText = "Blocking Incidents"; - plot.Color = ScottPlot.Color.FromHex("#E57373"); - plot.MarkerSize = 0; /* No markers, just lines */ - _blockingTrendHover?.Add(plot, "Blocking Incidents"); - - BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(BlockingTrendChart); - BlockingTrendChart.Plot.YLabel("Blocking Incidents"); - SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, data.Max(d => d.Count)); - ShowChartLegend(BlockingTrendChart); - BlockingTrendChart.Refresh(); - } - - private void UpdateDeadlockTrendChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(DeadlockTrendChart); - ApplyTheme(DeadlockTrendChart); - - /* Calculate X-axis range based on selected time window */ - DateTime rangeStart, rangeEnd; - if (fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - - _deadlockTrendHover?.Clear(); - if (data.Count == 0) - { - /* No deadlocks — show a flat line at zero so the chart looks active */ - var zeroLine = DeadlockTrendChart.Plot.Add.Scatter( - new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, - new[] { 0.0, 0.0 }); - zeroLine.LegendText = "Deadlocks"; - zeroLine.Color = ScottPlot.Color.FromHex("#FFB74D"); - zeroLine.MarkerSize = 0; - DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(DeadlockTrendChart); - DeadlockTrendChart.Plot.YLabel("Deadlocks"); - SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, 1); - ShowChartLegend(DeadlockTrendChart); - DeadlockTrendChart.Refresh(); - return; - } - - /* Build arrays with zero baseline between data points for spike effect */ - var expandedTimes = new List(); - var expandedCounts = new List(); - - /* Add zero at start */ - expandedTimes.Add(rangeStart.ToOADate()); - expandedCounts.Add(0); - - foreach (var point in data.OrderBy(d => d.Time)) - { - var time = point.Time.AddMinutes(UtcOffsetMinutes).ToOADate(); - /* Go to zero just before the spike */ - expandedTimes.Add(time - 0.0001); - expandedCounts.Add(0); - /* Spike up */ - expandedTimes.Add(time); - expandedCounts.Add(point.Count); - /* Back to zero just after */ - expandedTimes.Add(time + 0.0001); - expandedCounts.Add(0); - } - - /* Add zero at end */ - expandedTimes.Add(rangeEnd.ToOADate()); - expandedCounts.Add(0); - - var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); - plot.LegendText = "Deadlocks"; - plot.Color = ScottPlot.Color.FromHex("#FFB74D"); - plot.MarkerSize = 0; /* No markers, just lines */ - _deadlockTrendHover?.Add(plot, "Deadlocks"); - - DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(DeadlockTrendChart); - DeadlockTrendChart.Plot.YLabel("Deadlocks"); - SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, data.Max(d => d.Count)); - ShowChartLegend(DeadlockTrendChart); - DeadlockTrendChart.Refresh(); - } - - /* ========== Current Waits Charts ========== */ - - private void UpdateCurrentWaitsDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(CurrentWaitsDurationChart); - ApplyTheme(CurrentWaitsDurationChart); - - DateTime rangeStart, rangeEnd; - if (fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - - _currentWaitsDurationHover?.Clear(); - if (data.Count == 0) - { - var zeroLine = CurrentWaitsDurationChart.Plot.Add.Scatter( - new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, - new[] { 0.0, 0.0 }); - zeroLine.LegendText = "Current Waits"; - zeroLine.Color = ScottPlot.Color.FromHex("#4FC3F7"); - zeroLine.MarkerSize = 0; - CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); - CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(CurrentWaitsDurationChart); - CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); - SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, 1); - ShowChartLegend(CurrentWaitsDurationChart); - CurrentWaitsDurationChart.Refresh(); - return; - } - - var grouped = data.GroupBy(d => d.WaitType).OrderBy(g => g.Key).ToList(); - double globalMax = 0; - - for (int i = 0; i < grouped.Count; i++) - { - var group = grouped[i]; - var ordered = group.OrderBy(t => t.CollectionTime).ToList(); - var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = ordered.Select(t => (double)t.TotalWaitMs).ToArray(); - - var plot = CurrentWaitsDurationChart.Plot.Add.Scatter(times, values); - plot.LegendText = group.Key; - plot.LineWidth = 2; - plot.MarkerSize = 5; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _currentWaitsDurationHover?.Add(plot, group.Key); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - } - - CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); - CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(CurrentWaitsDurationChart); - CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); - SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, globalMax > 0 ? globalMax : 1); - ShowChartLegend(CurrentWaitsDurationChart); - CurrentWaitsDurationChart.Refresh(); - } - - private void UpdateCurrentWaitsBlockedChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(CurrentWaitsBlockedChart); - ApplyTheme(CurrentWaitsBlockedChart); - - DateTime rangeStart, rangeEnd; - if (fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - - _currentWaitsBlockedHover?.Clear(); - if (data.Count == 0) - { - var zeroLine = CurrentWaitsBlockedChart.Plot.Add.Scatter( - new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, - new[] { 0.0, 0.0 }); - zeroLine.LegendText = "Blocked Sessions"; - zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); - zeroLine.MarkerSize = 0; - CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); - CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(CurrentWaitsBlockedChart); - CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); - SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, 1); - ShowChartLegend(CurrentWaitsBlockedChart); - CurrentWaitsBlockedChart.Refresh(); - return; - } - - var grouped = data.GroupBy(d => d.DatabaseName).OrderBy(g => g.Key).ToList(); - double globalMax = 0; - - for (int i = 0; i < grouped.Count; i++) - { - var group = grouped[i]; - var ordered = group.OrderBy(t => t.CollectionTime).ToList(); - var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = ordered.Select(t => (double)t.BlockedCount).ToArray(); - - var plot = CurrentWaitsBlockedChart.Plot.Add.Scatter(times, values); - plot.LegendText = group.Key; - plot.LineWidth = 2; - plot.MarkerSize = 5; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _currentWaitsBlockedHover?.Add(plot, group.Key); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - } - - CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); - CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(CurrentWaitsBlockedChart); - CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); - SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, globalMax > 0 ? globalMax : 1); - ShowChartLegend(CurrentWaitsBlockedChart); - CurrentWaitsBlockedChart.Refresh(); - } - - /* ========== Performance Trend Charts ========== */ - - private void UpdateQueryDurationTrendChart(List data) - { - ClearChart(QueryDurationTrendChart); - ApplyTheme(QueryDurationTrendChart); - - if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = data.Select(d => d.Value).ToArray(); - - _queryDurationTrendHover?.Clear(); - var plot = QueryDurationTrendChart.Plot.Add.Scatter(times, values); - plot.LegendText = "Query Duration"; - plot.Color = ScottPlot.Color.FromHex("#4FC3F7"); - _queryDurationTrendHover?.Add(plot, "Query Duration"); - - QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(QueryDurationTrendChart); - QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); - SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max()); - ShowChartLegend(QueryDurationTrendChart); - QueryDurationTrendChart.Refresh(); - } - - private void UpdateProcDurationTrendChart(List data) - { - ClearChart(ProcDurationTrendChart); - ApplyTheme(ProcDurationTrendChart); - - if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = data.Select(d => d.Value).ToArray(); - - _procDurationTrendHover?.Clear(); - var plot = ProcDurationTrendChart.Plot.Add.Scatter(times, values); - plot.LegendText = "Procedure Duration"; - plot.Color = ScottPlot.Color.FromHex("#81C784"); - _procDurationTrendHover?.Add(plot, "Procedure Duration"); - - ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(ProcDurationTrendChart); - ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); - SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max()); - ShowChartLegend(ProcDurationTrendChart); - ProcDurationTrendChart.Refresh(); - } - - private void UpdateQueryStoreDurationTrendChart(List data) - { - ClearChart(QueryStoreDurationTrendChart); - ApplyTheme(QueryStoreDurationTrendChart); - - if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = data.Select(d => d.Value).ToArray(); - - _queryStoreDurationTrendHover?.Clear(); - var plot = QueryStoreDurationTrendChart.Plot.Add.Scatter(times, values); - plot.LegendText = "Query Store Duration"; - plot.Color = ScottPlot.Color.FromHex("#FFB74D"); - _queryStoreDurationTrendHover?.Add(plot, "Query Store Duration"); - - QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(QueryStoreDurationTrendChart); - QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); - SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max()); - ShowChartLegend(QueryStoreDurationTrendChart); - QueryStoreDurationTrendChart.Refresh(); - } - - // ── Grid → Slicer Overlay (#683) ── - - private (DateTime? fromDate, DateTime? toDate) GetCurrentViewDates() - { - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - return (ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode), - ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode)); - } - return (null, null); - } - - /// - /// Computes per-interval deltas from cumulative history values. - /// Picks the metric field based on the current slicer sort metric. - /// - private static List<(DateTime TimeUtc, double Value)> ComputeQueryOverlayPoints( - List history, string slicerMetric) - { - Func selector = slicerMetric switch - { - "TotalCpu" or "AvgCpu" => h => h.DeltaCpuUs, - "TotalReads" or "AvgReads" => h => h.DeltaLogicalReads, - "TotalWrites" => h => h.DeltaLogicalWrites, - "TotalPhysReads" => h => h.DeltaPhysicalReads, - _ => h => h.DeltaElapsedUs, // TotalElapsed, AvgElapsed, default - }; - bool isMicroseconds = slicerMetric is "TotalCpu" or "AvgCpu" or "TotalElapsed" or "AvgElapsed"; - - var points = new List<(DateTime TimeUtc, double Value)>(); - for (int i = 1; i < history.Count; i++) - { - var delta = selector(history[i]) - selector(history[i - 1]); - if (delta > 0) - points.Add((history[i].CollectionTime, isMicroseconds ? delta / 1000.0 : delta)); - } - return points; - } - - private static List<(DateTime TimeUtc, double Value)> ComputeProcOverlayPoints( - List history, string slicerMetric) - { - Func selector = slicerMetric switch - { - "TotalCpu" or "AvgCpu" => h => h.DeltaCpuUs, - "TotalReads" or "AvgReads" => h => h.DeltaLogicalReads, - "TotalWrites" => h => h.DeltaLogicalWrites, - "TotalPhysReads" => h => h.DeltaPhysicalReads, - _ => h => h.DeltaElapsedUs, - }; - bool isMicroseconds = slicerMetric is "TotalCpu" or "AvgCpu" or "TotalElapsed" or "AvgElapsed"; - - var points = new List<(DateTime TimeUtc, double Value)>(); - for (int i = 1; i < history.Count; i++) - { - var delta = selector(history[i]) - selector(history[i - 1]); - if (delta > 0) - points.Add((history[i].CollectionTime, isMicroseconds ? delta / 1000.0 : delta)); - } - return points; - } - - private async void QueryStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (QueryStatsGrid.SelectedItem is not QueryStatsRow row || string.IsNullOrEmpty(row.QueryHash)) - { - if (!_isRefreshing) QueryStatsSlicer.ClearOverlay(); - return; - } - - try - { - var hoursBack = GetHoursBack(); - var (fromDate, toDate) = GetCurrentViewDates(); - var history = await _dataService.GetQueryStatsHistoryAsync(_serverId, row.DatabaseName, row.QueryHash, hoursBack, fromDate, toDate); - - var points = ComputeQueryOverlayPoints(history, _queryStatsSlicerMetric); - QueryStatsSlicer.SetOverlay(points, row.QueryHash); - } - catch { QueryStatsSlicer.ClearOverlay(); } - } - - private async void ProcedureStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (ProcedureStatsGrid.SelectedItem is not ProcedureStatsRow row || string.IsNullOrEmpty(row.ObjectName)) - { - if (!_isRefreshing) ProcStatsSlicer.ClearOverlay(); - return; - } - - try - { - var hoursBack = GetHoursBack(); - var (fromDate, toDate) = GetCurrentViewDates(); - var history = await _dataService.GetProcedureStatsHistoryAsync(_serverId, row.DatabaseName, row.SchemaName, row.ObjectName, hoursBack, fromDate, toDate); - - var points = ComputeProcOverlayPoints(history, _procStatsSlicerMetric); - var label = row.ObjectName.Length > 30 ? row.ObjectName[..30] + "..." : row.ObjectName; - ProcStatsSlicer.SetOverlay(points, label); - } - catch { ProcStatsSlicer.ClearOverlay(); } - } - - private async void QueryStoreGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (QueryStoreGrid.SelectedItem is not QueryStoreRow row) - { - if (!_isRefreshing) QueryStoreSlicer.ClearOverlay(); - return; - } - - try - { - var hoursBack = GetHoursBack(); - var (fromDate, toDate) = GetCurrentViewDates(); - var history = await _dataService.GetQueryStoreHistoryAsync(_serverId, row.DatabaseName, row.QueryId, row.PlanId, hoursBack, fromDate, toDate); - - // Query Store values are already per-interval averages, not cumulative - Func selector = _queryStoreSlicerMetric switch - { - "TotalCpu" or "AvgCpu" => h => h.TotalCpuMs, - "TotalReads" or "AvgReads" => h => h.AvgLogicalReads * h.ExecutionCount, - _ => h => h.TotalDurationMs, - }; - - var points = history - .Where(h => selector(h) > 0) - .Select(h => (h.CollectionTime, selector(h))) - .ToList(); - - var qsLabel = !string.IsNullOrWhiteSpace(row.ModuleName) - ? row.ModuleName - : $"Query {row.QueryId} / Plan {row.PlanId}"; - QueryStoreSlicer.SetOverlay(points, qsLabel); - } - catch { QueryStoreSlicer.ClearOverlay(); } - } - - private void UpdateExecutionCountTrendChart(List data) - { - ClearChart(ExecutionCountTrendChart); - ApplyTheme(ExecutionCountTrendChart); - - if (data.Count == 0) { RefreshEmptyChart(ExecutionCountTrendChart, "Executions", "Executions/sec"); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = data.Select(d => d.Value).ToArray(); - - _executionCountTrendHover?.Clear(); - var plot = ExecutionCountTrendChart.Plot.Add.Scatter(times, values); - plot.LegendText = "Executions"; - plot.Color = ScottPlot.Color.FromHex("#BA68C8"); - _executionCountTrendHover?.Add(plot, "Executions"); - - ExecutionCountTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(ExecutionCountTrendChart); - ExecutionCountTrendChart.Plot.YLabel("Executions/sec"); - SetChartYLimitsWithLegendPadding(ExecutionCountTrendChart, 0, values.Max()); - ShowChartLegend(ExecutionCountTrendChart); - ExecutionCountTrendChart.Refresh(); - } - - /* ========== Query Heatmap ========== */ - - private void UpdateQueryHeatmapChart(HeatmapResult result) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] UpdateQueryHeatmapChart called: TimeBuckets={result.TimeBuckets.Length}, Grid={result.Intensities.GetLength(0)}x{result.Intensities.GetLength(1)}, BucketLabels={result.BucketLabels.Length}"); - ClearChart(QueryHeatmapChart); - ApplyTheme(QueryHeatmapChart); - - _lastHeatmapResult = result; - - if (result.TimeBuckets.Length == 0 || result.BucketLabels.Length == 0) - { - RefreshEmptyChart(QueryHeatmapChart, "Query Heatmap", ""); - return; - } - - int numRows = result.Intensities.GetLength(0); - int numCols = result.Intensities.GetLength(1); - - // Log1p scaling; NaN for empty cells so they render as background. - var scaled = new double[numRows, numCols]; - for (int r = 0; r < numRows; r++) - { - for (int c = 0; c < numCols; c++) - { - scaled[r, c] = result.Intensities[r, c] > 0 - ? Math.Log(1 + result.Intensities[r, c]) - : double.NaN; - } - } - - var heatmap = QueryHeatmapChart.Plot.Add.Heatmap(scaled); - _heatmapPlottable = heatmap; - heatmap.FlipVertically = true; // row 0 ("0-1ms") at bottom, row 6 (">100s") at top - heatmap.Colormap = new ScottPlot.Colormaps.Viridis(); - heatmap.NaNCellColor = QueryHeatmapChart.Plot.DataBackground.Color; - - // Let ScottPlot use default extent (0..numCols, 0..numRows). - // No custom Position — avoids cell-centering offset issues. - // Use manual tick labels for both axes instead. - ReapplyAxisColors(QueryHeatmapChart); - - // X-axis: time labels at column positions - var xTicks = new ScottPlot.TickGenerators.NumericManual(); - int xStep = Math.Max(1, numCols / 12); // ~12 labels max - for (int i = 0; i < numCols; i += xStep) - { - var t = result.TimeBuckets[i].AddMinutes(UtcOffsetMinutes); - xTicks.AddMajor(i, t.ToString("M/d\nh:mm tt")); - } - QueryHeatmapChart.Plot.Axes.Bottom.TickGenerator = xTicks; - QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Left.TickLabelStyle.ForeColor; - - // Y-axis: bucket labels - var yTicks = new ScottPlot.TickGenerators.NumericManual(); - for (int i = 0; i < result.BucketLabels.Length; i++) - { - yTicks.AddMajor(i, result.BucketLabels[i]); - } - QueryHeatmapChart.Plot.Axes.Left.TickGenerator = yTicks; - - // Axis limits match default heatmap extent - QueryHeatmapChart.Plot.Axes.SetLimitsX(-0.5, numCols - 0.5); - QueryHeatmapChart.Plot.Axes.SetLimitsY(-0.5, numRows - 0.5); - - // Colorbar with real query counts (undo log1p for tick labels) - var colorBar = new ScottPlot.Panels.ColorBar(heatmap, ScottPlot.Edge.Right); - colorBar.Label = "Query Count"; - colorBar.LabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; - colorBar.Axis.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; - double maxRaw = 0; - for (int r = 0; r < numRows; r++) - for (int c = 0; c < numCols; c++) - if (result.Intensities[r, c] > maxRaw) maxRaw = result.Intensities[r, c]; - var cbTicks = new ScottPlot.TickGenerators.NumericManual(); - cbTicks.AddMajor(0, "0"); - int[] niceValues = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000 }; - foreach (var n in niceValues) - { - if (n > maxRaw) break; - cbTicks.AddMajor(Math.Log(1 + n), n.ToString("N0")); - } - cbTicks.AddMajor(Math.Log(1 + maxRaw), ((int)maxRaw).ToString("N0")); - colorBar.Axis.TickGenerator = cbTicks; - QueryHeatmapChart.Plot.Axes.AddPanel(colorBar); - _legendPanels[QueryHeatmapChart] = colorBar; - - var metricName = ((ComboBoxItem)HeatmapMetricCombo.SelectedItem).Content?.ToString() ?? "Duration (ms)"; - QueryHeatmapChart.Plot.Title($"Query Distribution by {metricName}"); - QueryHeatmapChart.Plot.Axes.Title.Label.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; - - QueryHeatmapChart.Refresh(); - } - - private DateTime _lastHeatmapHoverUpdate; - - private void HeatmapChart_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) - { - if (_heatmapPopup != null) _heatmapPopup.IsOpen = false; - } - - private void HeatmapChart_MouseMove(object sender, System.Windows.Input.MouseEventArgs e) - { - if (_heatmapPopup == null || _heatmapPopupText == null || _heatmapPlottable == null) return; - if (_lastHeatmapResult == null || _lastHeatmapResult.TimeBuckets.Length == 0) return; - - var now = DateTime.UtcNow; - if ((now - _lastHeatmapHoverUpdate).TotalMilliseconds < 50) return; - _lastHeatmapHoverUpdate = now; - - var pos = e.GetPosition(QueryHeatmapChart); - var dpi = VisualTreeHelper.GetDpi(QueryHeatmapChart); - var pixel = new ScottPlot.Pixel( - (float)(pos.X * dpi.DpiScaleX), - (float)(pos.Y * dpi.DpiScaleY)); - var coords = QueryHeatmapChart.Plot.GetCoordinates(pixel); - - int numRows = _lastHeatmapResult.Intensities.GetLength(0); - int numCols = _lastHeatmapResult.Intensities.GetLength(1); - - // Default heatmap extent (no custom Position): cols = 0..numCols, rows = 0..numRows. - // GetIndexes returns bitmap indices. With FlipVertically=true, flip row for data index. - var (col, rowIdx) = _heatmapPlottable.GetIndexes(coords); - int row = (numRows - 1) - rowIdx; - - if (row < 0 || row >= numRows || col < 0 || col >= numCols) - { - _heatmapPopup.IsOpen = false; - return; - } - - long count = (long)_lastHeatmapResult.Intensities[row, col]; - if (count == 0) - { - _heatmapPopup.IsOpen = false; - return; - } - - var cell = _lastHeatmapResult.CellDetails[row, col]; - var time = ServerTimeHelper.ConvertForDisplay( - _lastHeatmapResult.TimeBuckets[col].AddMinutes(UtcOffsetMinutes), - ServerTimeHelper.CurrentDisplayMode); - var bucketLabel = row < _lastHeatmapResult.BucketLabels.Length - ? _lastHeatmapResult.BucketLabels[row] - : "?"; - - var tipText = $"{time:HH:mm:ss} | {bucketLabel} | {count:N0} queries"; - if (cell != null && !string.IsNullOrEmpty(cell.TopQueryText)) - { - // Single line, collapse whitespace, truncate - var flat = System.Text.RegularExpressions.Regex.Replace(cell.TopQueryText, @"\s+", " ").Trim(); - if (flat.Length > 60) flat = flat[..60] + "..."; - tipText += $"\n{flat}"; - } - _heatmapPopupText.Text = tipText; - - _heatmapPopup.HorizontalOffset = pos.X + 15; - _heatmapPopup.VerticalOffset = pos.Y + 15; - _heatmapPopup.IsOpen = true; - } - - private async void HeatmapMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!IsLoaded) return; - try - { - var hoursBack = GetHoursBack(); - DateTime? fromDate = null, toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - var metric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; - var result = await _dataService.GetQueryHeatmapAsync(_serverId, metric, hoursBack, fromDate, toDate); - UpdateQueryHeatmapChart(result); - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] HeatmapMetric_SelectionChanged failed: {ex.Message}"); - } - } - - /* ========== Wait Stats Picker ========== */ - - private static readonly string[] PoisonWaits = { "THREADPOOL", "RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE_QUERY_COMPILE" }; - private static readonly string[] UsualSuspectWaits = { "SOS_SCHEDULER_YIELD", "CXPACKET", "CXCONSUMER", "PAGEIOLATCH_SH", "PAGEIOLATCH_EX", "WRITELOG" }; - private static readonly string[] UsualSuspectPrefixes = { "PAGELATCH_" }; - - private static HashSet GetDefaultWaitTypes(List availableWaitTypes) - { - var defaults = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var w in PoisonWaits) - if (availableWaitTypes.Contains(w)) defaults.Add(w); - foreach (var w in UsualSuspectWaits) - if (availableWaitTypes.Contains(w)) defaults.Add(w); - foreach (var prefix in UsualSuspectPrefixes) - foreach (var w in availableWaitTypes) - if (w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - defaults.Add(w); - int added = 0; - foreach (var w in availableWaitTypes) - { - if (defaults.Count >= 30) break; - if (added >= 10) break; - if (defaults.Add(w)) { added++; } - } - return defaults; - } - - private bool _isUpdatingWaitTypeSelection; - - private void PopulateWaitTypePicker(List waitTypes) - { - var previouslySelected = new HashSet(_waitTypeItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); - var topWaits = previouslySelected.Count == 0 ? GetDefaultWaitTypes(waitTypes) : null; - _waitTypeItems = waitTypes.Select(w => new SelectableItem - { - DisplayName = w, - IsSelected = previouslySelected.Contains(w) || (topWaits != null && topWaits.Contains(w)) - }).ToList(); - /* Sort checked items to top, then preserve original order (by total wait time desc) */ - RefreshWaitTypeListOrder(); - } - - private void RefreshWaitTypeListOrder() - { - if (_waitTypeItems == null) return; - _waitTypeItems = _waitTypeItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => x.DisplayName) - .ToList(); - ApplyWaitTypeFilter(); - UpdateWaitTypeCount(); - } - - private void UpdateWaitTypeCount() - { - if (_waitTypeItems == null || WaitTypeCountText == null) return; - int count = _waitTypeItems.Count(x => x.IsSelected); - WaitTypeCountText.Text = $"{count} / 30 selected"; - WaitTypeCountText.Foreground = count >= 30 - ? new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#E57373")!) - : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - - private void ApplyWaitTypeFilter() - { - var search = WaitTypeSearchBox?.Text?.Trim() ?? ""; - WaitTypesList.ItemsSource = null; - if (string.IsNullOrEmpty(search)) - WaitTypesList.ItemsSource = _waitTypeItems; - else - WaitTypesList.ItemsSource = _waitTypeItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyWaitTypeFilter(); - - private void WaitTypeSelectAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingWaitTypeSelection = true; - var topWaits = GetDefaultWaitTypes(_waitTypeItems.Select(x => x.DisplayName).ToList()); - foreach (var item in _waitTypeItems) - { - item.IsSelected = topWaits.Contains(item.DisplayName); - } - _isUpdatingWaitTypeSelection = false; - RefreshWaitTypeListOrder(); - _ = UpdateWaitStatsChartFromPickerAsync(); - } - - private void WaitTypeClearAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingWaitTypeSelection = true; - var visible = (WaitTypesList.ItemsSource as IEnumerable)?.ToList() ?? _waitTypeItems; - foreach (var item in visible) item.IsSelected = false; - _isUpdatingWaitTypeSelection = false; - RefreshWaitTypeListOrder(); - _ = UpdateWaitStatsChartFromPickerAsync(); - } - - private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - _ = UpdateWaitStatsChartFromPickerAsync(); - } - - private void WaitType_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingWaitTypeSelection) return; - RefreshWaitTypeListOrder(); - _ = UpdateWaitStatsChartFromPickerAsync(); - } - - private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) - { - contextMenu.Items.Insert(0, new Separator()); - var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; - drillDownItem.Click += ShowQueriesForWaitType_Click; - contextMenu.Items.Insert(0, drillDownItem); - - contextMenu.Opened += (s, _) => - { - if (s is not ContextMenu cm) return; - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = _waitStatsHover?.GetNearestSeries(pos); - if (nearest.HasValue) - { - drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); - drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; - drillDownItem.IsEnabled = true; - } - else - { - drillDownItem.Tag = null; - drillDownItem.Header = "Show Queries With This Wait"; - drillDownItem.IsEnabled = false; - } - }; - } - - private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - if (menuItem.Tag is not (string waitType, DateTime time)) return; - - // ±15 minute window around the clicked point (already in server local time from chart) - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - - var window = new Windows.WaitDrillDownWindow( - _dataService, _serverId, waitType, 1, fromDate, toDate); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - // ── Generic Chart Drill-Down (#682) ── - - private void AddChartDrillDownMenuItem( - ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu, - Helpers.ChartHoverHelper? hover, string label, Action handler) - { - contextMenu.Items.Insert(0, new Separator()); - var item = new MenuItem { Header = label }; - contextMenu.Items.Insert(0, item); - - contextMenu.Opened += (s, _) => - { - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = hover?.GetNearestSeries(pos); - if (nearest.HasValue) - { - item.Tag = nearest.Value.Time; - item.IsEnabled = true; - } - else - { - item.Tag = null; - item.IsEnabled = false; - } - }; - - item.Click += (s, _) => - { - if (item.Tag is DateTime time) - handler(time); - }; - } - - private async void OnCpuDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - - // Populate custom date pickers so user can explore other tabs - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Queries > Active Queries with ±15 min window - MainTabControl.SelectedIndex = 2; // Queries - QueriesSubTabControl.SelectedIndex = 1; // Active Queries - var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} \u2192 {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnMemoryDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - MainTabControl.SelectedIndex = 2; // Queries - QueriesSubTabControl.SelectedIndex = 1; // Active Queries - var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} \u2192 {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnTempDbDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Active Queries — TempDB spills are visible there - MainTabControl.SelectedIndex = 2; // Queries - QueriesSubTabControl.SelectedIndex = 1; // Active Queries - var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} \u2192 {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnBlockingDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - MainTabControl.SelectedIndex = 8; // Blocking - BlockingSubTabControl.SelectedIndex = 2; // Blocked Process Reports - var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromDate, toDate); - _blockedProcessFilterMgr!.UpdateData(bpr); - } - - private async void OnDeadlockDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - MainTabControl.SelectedIndex = 8; // Blocking - BlockingSubTabControl.SelectedIndex = 3; // Deadlocks - var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, 0, fromDate, toDate); - _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr)); - } - - private async void OnHeatmapDrillDown(DateTime bucketTimeUtc) - { - var serverTime = bucketTimeUtc.AddMinutes(UtcOffsetMinutes); - var fromDate = serverTime.AddMinutes(-5); - var toDate = serverTime.AddMinutes(10); - - AppLogger.Info("DrillDown", $"OnHeatmapDrillDown: bucketTimeUtc={bucketTimeUtc:O}, UtcOffsetMinutes={UtcOffsetMinutes}, serverTime={serverTime:O}, fromDate={fromDate:O}, toDate={toDate:O}"); - - SetDrillDownTimeRange(fromDate, toDate); - - MainTabControl.SelectedIndex = 2; // Queries - QueriesSubTabControl.SelectedIndex = 1; // Active Queries - - AppLogger.Info("DrillDown", $"Calling GetLatestQuerySnapshotsAsync with fromDate={fromDate:O}, toDate={toDate:O}"); - var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate); - AppLogger.Info("DrillDown", $"Got {snapshots.Count} snapshots"); - - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {fromDate:HH:mm} \u2192 {toDate:HH:mm} (server time)"; - _ = LoadActiveQueriesSlicerAsync(); - } - - /// - /// Sets the time range combo to Custom and populates the date/time pickers - /// so the user can navigate other tabs at the same time window. - /// - private void SetDrillDownTimeRange(DateTime fromServer, DateTime toServer) - { - // Pickers store time in the current display mode. Downstream reads use - // DisplayTimeToServerTime() to convert back. - var fromDisplay = ServerTimeHelper.ConvertForDisplay(fromServer, ServerTimeHelper.CurrentDisplayMode); - var toDisplay = ServerTimeHelper.ConvertForDisplay(toServer, ServerTimeHelper.CurrentDisplayMode); - - // Switch to Custom without triggering a refresh - _isRefreshing = true; - try - { - TimeRangeCombo.SelectedIndex = 5; // Custom - FromDatePicker.SelectedDate = fromDisplay.Date; - FromHourCombo.SelectedIndex = fromDisplay.Hour; - FromMinuteCombo.SelectedIndex = fromDisplay.Minute / 15; - ToDatePicker.SelectedDate = toDisplay.Date; - ToHourCombo.SelectedIndex = toDisplay.Hour; - ToMinuteCombo.SelectedIndex = toDisplay.Minute / 15; - - // Make pickers visible - var visibility = Visibility.Visible; - FromDatePicker.Visibility = visibility; - FromHourCombo.Visibility = visibility; - FromMinuteCombo.Visibility = visibility; - ToLabel.Visibility = visibility; - ToDatePicker.Visibility = visibility; - ToHourCombo.Visibility = visibility; - ToMinuteCombo.Visibility = visibility; - } - finally - { - _isRefreshing = false; - } - } - - private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync() - { - try - { - var selected = _waitTypeItems.Where(i => i.IsSelected).Take(20).ToList(); - - ClearChart(WaitStatsChart); - ApplyTheme(WaitStatsChart); - _waitStatsHover?.Clear(); - - if (selected.Count == 0) { WaitStatsChart.Refresh(); return; } - - bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; - if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null; - DateTime? toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - double globalMax = 0; - - for (int i = 0; i < selected.Count; i++) - { - var trend = await _dataService.GetWaitStatsTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); - if (trend.Count == 0) continue; - - var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = useAvgPerWait - ? trend.Select(t => t.AvgMsPerWait).ToArray() - : trend.Select(t => t.WaitTimeMsPerSecond).ToArray(); - - var plot = WaitStatsChart.Plot.Add.Scatter(times, values); - plot.LegendText = selected[i].DisplayName; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _waitStatsHover?.Add(plot, selected[i].DisplayName); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - } - - WaitStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - DateTime rangeStart, rangeEnd; - if (IsCustomRange && fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - WaitStatsChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(WaitStatsChart); - WaitStatsChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); - SetChartYLimitsWithLegendPadding(WaitStatsChart, 0, globalMax > 0 ? globalMax : 100); - ShowChartLegend(WaitStatsChart); - WaitStatsChart.Refresh(); - } - catch - { - /* Ignore chart update errors */ - } - } - - /* ========== Memory Clerks Picker ========== */ - - private void PopulateMemoryClerkPicker(List clerkTypes) - { - var previouslySelected = new HashSet(_memoryClerkItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); - var topClerks = previouslySelected.Count == 0 ? new HashSet(clerkTypes.Take(5)) : null; - _memoryClerkItems = clerkTypes.Select(c => new SelectableItem - { - DisplayName = c, - IsSelected = previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c)) - }).ToList(); - RefreshMemoryClerkListOrder(); - } - - private void RefreshMemoryClerkListOrder() - { - if (_memoryClerkItems == null) return; - _memoryClerkItems = _memoryClerkItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => x.DisplayName) - .ToList(); - ApplyMemoryClerkFilter(); - UpdateMemoryClerkCount(); - } - - private void UpdateMemoryClerkCount() - { - if (_memoryClerkItems == null || MemoryClerkCountText == null) return; - int count = _memoryClerkItems.Count(x => x.IsSelected); - MemoryClerkCountText.Text = $"{count} selected"; - } - - private void ApplyMemoryClerkFilter() - { - var search = MemoryClerkSearchBox?.Text?.Trim() ?? ""; - MemoryClerksList.ItemsSource = null; - if (string.IsNullOrEmpty(search)) - MemoryClerksList.ItemsSource = _memoryClerkItems; - else - MemoryClerksList.ItemsSource = _memoryClerkItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - private void MemoryClerkSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyMemoryClerkFilter(); - - private void MemoryClerkSelectTop_Click(object sender, RoutedEventArgs e) - { - _isUpdatingMemoryClerkSelection = true; - var topClerks = new HashSet(_memoryClerkItems.Take(5).Select(x => x.DisplayName)); - foreach (var item in _memoryClerkItems) - { - item.IsSelected = topClerks.Contains(item.DisplayName); - } - _isUpdatingMemoryClerkSelection = false; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private void MemoryClerkClearAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingMemoryClerkSelection = true; - var visible = (MemoryClerksList.ItemsSource as IEnumerable)?.ToList() ?? _memoryClerkItems; - foreach (var item in visible) item.IsSelected = false; - _isUpdatingMemoryClerkSelection = false; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private void MemoryClerk_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingMemoryClerkSelection) return; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync() - { - try - { - var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); - - ClearChart(MemoryClerksChart); - ApplyTheme(MemoryClerksChart); - _memoryClerksHover?.Clear(); - - if (selected.Count == 0) - { - MemoryClerksTotalText.Text = "--"; - MemoryClerksTopText.Text = "--"; - MemoryClerksChart.Refresh(); - return; - } - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null; - DateTime? toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - - double globalMax = 0; - double nonBpTotal = 0; - string topNonBpClerk = ""; - double topNonBpMb = 0; - - for (int i = 0; i < selected.Count; i++) - { - var trend = await _dataService.GetMemoryClerkTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); - if (trend.Count == 0) continue; - - var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = trend.Select(t => t.MemoryMb).ToArray(); - - var plot = MemoryClerksChart.Plot.Add.Scatter(times, values); - plot.LegendText = selected[i].DisplayName; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _memoryClerksHover?.Add(plot, selected[i].DisplayName); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - - /* Summary: use latest value, exclude buffer pool */ - var latestMb = values.Last(); - if (!selected[i].DisplayName.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) - { - nonBpTotal += latestMb; - if (latestMb > topNonBpMb) - { - topNonBpMb = latestMb; - topNonBpClerk = selected[i].DisplayName; - } - } - } - - MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(MemoryClerksChart); - MemoryClerksChart.Plot.YLabel("Memory (MB)"); - SetChartYLimitsWithLegendPadding(MemoryClerksChart, 0, globalMax > 0 ? globalMax : 100); - ShowChartLegend(MemoryClerksChart); - MemoryClerksChart.Refresh(); - - /* Update summary panel */ - MemoryClerksTotalText.Text = nonBpTotal >= 1024 ? $"{nonBpTotal / 1024:F1} GB" : $"{nonBpTotal:N0} MB"; - if (!string.IsNullOrEmpty(topNonBpClerk)) - { - var name = topNonBpClerk; - if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) - name = name.Substring(12); - MemoryClerksTopText.Text = topNonBpMb >= 1024 ? $"{name} ({topNonBpMb / 1024:F1} GB)" : $"{name} ({topNonBpMb:N0} MB)"; - } - else - { - MemoryClerksTopText.Text = "--"; - } - } - catch - { - /* Ignore chart update errors */ - } - } - - /* ========== Perfmon Picker ========== */ - - private bool _isUpdatingPerfmonSelection; - - private void PopulatePerfmonPicker(List counters) - { - /* Initialize pack ComboBox once */ - if (PerfmonPackCombo.Items.Count == 0) - { - PerfmonPackCombo.ItemsSource = Helpers.PerfmonPacks.PackNames; - PerfmonPackCombo.SelectedItem = "General Throughput"; - } - - var previouslySelected = new HashSet(_perfmonCounterItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); - _perfmonCounterItems = counters.Select(c => new SelectableItem - { - DisplayName = c, - IsSelected = previouslySelected.Contains(c) - || (previouslySelected.Count == 0 && _defaultPerfmonCounters.Contains(c)) - }).ToList(); - RefreshPerfmonListOrder(); - } - - private void PerfmonPack_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_perfmonCounterItems == null || _perfmonCounterItems.Count == 0) return; - if (PerfmonPackCombo.SelectedItem is not string pack) return; - - _isUpdatingPerfmonSelection = true; - - /* Clear search so all counters are visible */ - if (PerfmonSearchBox != null) - PerfmonSearchBox.Text = ""; - - /* Uncheck everything first */ - foreach (var item in _perfmonCounterItems) - item.IsSelected = false; - - if (pack == Helpers.PerfmonPacks.AllCounters) - { - /* "All Counters" selects the General Throughput defaults */ - foreach (var item in _perfmonCounterItems) - { - if (_defaultPerfmonCounters.Contains(item.DisplayName)) - item.IsSelected = true; - } - } - else if (Helpers.PerfmonPacks.Packs.TryGetValue(pack, out var packCounters)) - { - var packSet = new HashSet(packCounters, StringComparer.OrdinalIgnoreCase); - int count = 0; - foreach (var item in _perfmonCounterItems) - { - if (count >= 12) break; - if (packSet.Contains(item.DisplayName)) - { - item.IsSelected = true; - count++; - } - } - } - - _isUpdatingPerfmonSelection = false; - RefreshPerfmonListOrder(); - _ = UpdatePerfmonChartFromPickerAsync(); - } - - private void RefreshPerfmonListOrder() - { - if (_perfmonCounterItems == null) return; - _perfmonCounterItems = _perfmonCounterItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => _perfmonCounterItems.IndexOf(x)) - .ToList(); - ApplyPerfmonFilter(); - } - - private void ApplyPerfmonFilter() - { - var search = PerfmonSearchBox?.Text?.Trim() ?? ""; - PerfmonCountersList.ItemsSource = null; - if (string.IsNullOrEmpty(search)) - PerfmonCountersList.ItemsSource = _perfmonCounterItems; - else - PerfmonCountersList.ItemsSource = _perfmonCounterItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - private void PerfmonSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyPerfmonFilter(); - - private void PerfmonSelectAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingPerfmonSelection = true; - var visible = (PerfmonCountersList.ItemsSource as IEnumerable)?.ToList() ?? _perfmonCounterItems; - int count = visible.Count(i => i.IsSelected); - foreach (var item in visible) - { - if (!item.IsSelected && count < 12) - { - item.IsSelected = true; - count++; - } - } - _isUpdatingPerfmonSelection = false; - RefreshPerfmonListOrder(); - _ = UpdatePerfmonChartFromPickerAsync(); - } - - private void PerfmonClearAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingPerfmonSelection = true; - var visible = (PerfmonCountersList.ItemsSource as IEnumerable)?.ToList() ?? _perfmonCounterItems; - foreach (var item in visible) item.IsSelected = false; - _isUpdatingPerfmonSelection = false; - RefreshPerfmonListOrder(); - _ = UpdatePerfmonChartFromPickerAsync(); - } - - private void PerfmonCounter_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingPerfmonSelection) return; - RefreshPerfmonListOrder(); - _ = UpdatePerfmonChartFromPickerAsync(); - } - - private async System.Threading.Tasks.Task UpdatePerfmonChartFromPickerAsync() - { - try - { - var selected = _perfmonCounterItems.Where(i => i.IsSelected).Take(12).ToList(); - - ClearChart(PerfmonChart); - _perfmonHover?.Clear(); - ApplyTheme(PerfmonChart); - - if (selected.Count == 0) { PerfmonChart.Refresh(); return; } - - var hoursBack = GetHoursBack(); - DateTime? fromDate = null; - DateTime? toDate = null; - if (IsCustomRange) - { - var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); - var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); - if (fromLocal.HasValue && toLocal.HasValue) - { - fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); - toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); - } - } - double globalMax = 0; - - for (int i = 0; i < selected.Count; i++) - { - var trend = await _dataService.GetPerfmonTrendAsync(_serverId, selected[i].DisplayName, hoursBack, fromDate, toDate); - if (trend.Count == 0) continue; - - var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = trend.Select(t => (double)t.DeltaValue).ToArray(); - - var plot = PerfmonChart.Plot.Add.Scatter(times, values); - plot.LegendText = selected[i].DisplayName; - plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); - _perfmonHover?.Add(plot, selected[i].DisplayName); - - if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); - } - - PerfmonChart.Plot.Axes.DateTimeTicksBottomDateChange(); - DateTime rangeStart, rangeEnd; - if (IsCustomRange && fromDate.HasValue && toDate.HasValue) - { - rangeStart = fromDate.Value; - rangeEnd = toDate.Value; - } - else - { - rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); - rangeStart = rangeEnd.AddHours(-hoursBack); - } - PerfmonChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); - ReapplyAxisColors(PerfmonChart); - PerfmonChart.Plot.YLabel("Value"); - SetChartYLimitsWithLegendPadding(PerfmonChart, 0, globalMax > 0 ? globalMax : 100); - ShowChartLegend(PerfmonChart); - PerfmonChart.Refresh(); - } - catch - { - /* Ignore chart update errors */ - } - } - - /// - /// Clears a chart and removes any existing legend panel to prevent duplication. - /// - private void ClearChart(ScottPlot.WPF.WpfPlot chart) - { - if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) - { - chart.Plot.Axes.Remove(existingPanel); - _legendPanels[chart] = null; - } - - /* Reset fully — Plot.Clear() leaves stale DateTime axes behind, - and DateTimeTicksBottom() replaces the axis object entirely. - Resetting the plot object avoids tick generator type mismatches. */ - chart.Reset(); - chart.Plot.Clear(); - } - - /// - /// Sets up an empty chart with dark theme, Y-axis label, legend, and "No Data" annotation. - /// Matches Full Dashboard behavior for consistent UX. - /// - private void RefreshEmptyChart(ScottPlot.WPF.WpfPlot chart, string legendText, string yAxisLabel) - { - ReapplyAxisColors(chart); - - /* Add invisible scatter to create legend entry (matches data chart layout) */ - var placeholder = chart.Plot.Add.Scatter(new double[] { 0 }, new double[] { 0 }); - placeholder.LegendText = legendText; - placeholder.Color = ScottPlot.Color.FromHex("#888888"); - placeholder.MarkerSize = 0; - placeholder.LineWidth = 0; - - /* Add centered "No Data" text */ - var text = chart.Plot.Add.Text($"{legendText}\nNo Data", 0, 0); - text.LabelFontColor = ScottPlot.Color.FromHex("#888888"); - text.LabelFontSize = 14; - text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - - /* Configure axes */ - chart.Plot.HideGrid(); - chart.Plot.Axes.SetLimitsX(-1, 1); - chart.Plot.Axes.SetLimitsY(-1, 1); - chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); - chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); - chart.Plot.YLabel(yAxisLabel); - - /* Show legend to match data chart layout */ - ShowChartLegend(chart); - chart.Refresh(); - } - - /// - /// Shows legend on chart and tracks it for proper cleanup on next refresh. - /// - private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) - { - _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - chart.Plot.Legend.FontSize = 13; - } - - /// - /// Applies the Darling Data dark theme to a ScottPlot chart. - /// Matches Dashboard TabHelpers.ApplyThemeToChart exactly. - /// - private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) - { - ScottPlot.Color figureBackground, dataBackground, textColor, gridColor, legendBg, legendFg, legendOutline; - - if (Helpers.ThemeManager.CurrentTheme == "CoolBreeze") - { - figureBackground = ScottPlot.Color.FromHex("#EEF4FA"); - dataBackground = ScottPlot.Color.FromHex("#DAE6F0"); - textColor = ScottPlot.Color.FromHex("#1A2A3A"); - gridColor = ScottPlot.Color.FromHex("#A8BDD0").WithAlpha(120); - legendBg = ScottPlot.Color.FromHex("#EEF4FA"); - legendFg = ScottPlot.Color.FromHex("#1A2A3A"); - legendOutline = ScottPlot.Color.FromHex("#A8BDD0"); - } - else if (Helpers.ThemeManager.HasLightBackground) - { - figureBackground = ScottPlot.Color.FromHex("#FFFFFF"); - dataBackground = ScottPlot.Color.FromHex("#F5F7FA"); - textColor = ScottPlot.Color.FromHex("#1A1D23"); - gridColor = ScottPlot.Colors.Black.WithAlpha(20); - legendBg = ScottPlot.Color.FromHex("#FFFFFF"); - legendFg = ScottPlot.Color.FromHex("#1A1D23"); - legendOutline = ScottPlot.Color.FromHex("#DEE2E6"); - } - else - { - figureBackground = ScottPlot.Color.FromHex("#22252b"); - dataBackground = ScottPlot.Color.FromHex("#111217"); - textColor = ScottPlot.Color.FromHex("#E4E6EB"); - gridColor = ScottPlot.Colors.White.WithAlpha(40); - legendBg = ScottPlot.Color.FromHex("#22252b"); - legendFg = ScottPlot.Color.FromHex("#E4E6EB"); - legendOutline = ScottPlot.Color.FromHex("#2a2d35"); - } - - chart.Plot.FigureBackground.Color = figureBackground; - chart.Plot.DataBackground.Color = dataBackground; - chart.Plot.Axes.Color(textColor); - chart.Plot.Grid.MajorLineColor = gridColor; - chart.Plot.Legend.BackgroundColor = legendBg; - chart.Plot.Legend.FontColor = legendFg; - chart.Plot.Legend.OutlineColor = legendOutline; - chart.Plot.Legend.Alignment = ScottPlot.Alignment.LowerCenter; - chart.Plot.Legend.Orientation = ScottPlot.Orientation.Horizontal; - chart.Plot.Axes.Margins(bottom: 0); /* No bottom margin - SetChartYLimitsWithLegendPadding handles Y-axis */ - - chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; - chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; - chart.Plot.Axes.Bottom.Label.ForeColor = textColor; - chart.Plot.Axes.Left.Label.ForeColor = textColor; - chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; - chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; - - // Set the WPF control Background to match so no white flash appears before ScottPlot's render loop fires - chart.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(figureBackground.R, figureBackground.G, figureBackground.B)); - - // Ensure ScottPlot renders with the correct colors the very first time it gets pixel dimensions. - chart.Loaded -= HandleChartFirstLoaded; - if (!chart.IsLoaded) - chart.Loaded += HandleChartFirstLoaded; - } - - private static void HandleChartFirstLoaded(object sender, RoutedEventArgs e) - { - var chart = (ScottPlot.WPF.WpfPlot)sender; - chart.Loaded -= HandleChartFirstLoaded; - chart.Refresh(); - } - - private void OnThemeChanged(string _) - { - foreach (var field in GetType().GetFields( - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) - { - if (field.GetValue(this) is ScottPlot.WPF.WpfPlot chart) - { - ApplyTheme(chart); - chart.Refresh(); - } - } - - CorrelatedLanes.ReapplyTheme(); - } - - private static IEnumerable GetAllCharts(DependencyObject root) - { - foreach (var child in LogicalTreeHelper.GetChildren(root).OfType()) - { - if (child is ScottPlot.WPF.WpfPlot plot) - yield return plot; - foreach (var nested in GetAllCharts(child)) - yield return nested; - } - } - - /// - /// Reapplies theme-appropriate text colors and font sizes after DateTimeTicksBottom() resets them. - /// - private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart) - { - var textColor = Helpers.ThemeManager.CurrentTheme == "CoolBreeze" - ? ScottPlot.Color.FromHex("#1A2A3A") - : Helpers.ThemeManager.HasLightBackground - ? ScottPlot.Color.FromHex("#1A1D23") - : ScottPlot.Color.FromHex("#E4E6EB"); - chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; - chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; - chart.Plot.Axes.Bottom.Label.ForeColor = textColor; - chart.Plot.Axes.Left.Label.ForeColor = textColor; - chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; - chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; - } - - /// - /// Sets Y-axis limits with padding for bottom legend and top breathing room. - /// - private static void SetChartYLimitsWithLegendPadding(ScottPlot.WPF.WpfPlot chart, double dataYMin = 0, double dataYMax = 0) - { - if (dataYMin == 0 && dataYMax == 0) - { - var limits = chart.Plot.Axes.GetLimits(); - dataYMin = limits.Bottom; - dataYMax = limits.Top; - } - if (dataYMax <= dataYMin) dataYMax = dataYMin + 1; - - double range = dataYMax - dataYMin; - double topPadding = range * 0.05; - - /* Add small bottom margin when dataYMin is zero so flat lines at Y=0 are visible above the axis */ - double yMin = dataYMin > 0 ? 0 : dataYMin == 0 ? -(range * 0.05) : dataYMin - (range * 0.10); - double yMax = dataYMax + topPadding; - - chart.Plot.Axes.SetLimitsY(yMin, yMax); - } - - /* DataGrid copy helpers */ - /// - /// Finds the parent DataGrid from a context menu opened on a DataGridRow. - /// - private static DataGrid? FindParentDataGrid(MenuItem menuItem) - { - var contextMenu = menuItem.Parent as ContextMenu; - var target = contextMenu?.PlacementTarget as FrameworkElement; - while (target != null && target is not DataGrid) - { - target = System.Windows.Media.VisualTreeHelper.GetParent(target) as FrameworkElement; - } - return target as DataGrid; - } - - /// - /// Gets a cell value from a row item for any column type (bound or template). - /// Template columns are inspected for a TextBlock binding in their CellTemplate. - /// - private static string GetCellValue(DataGridColumn col, object item) - { - /* DataGridBoundColumn — binding is directly accessible */ - if (col is DataGridBoundColumn boundCol - && boundCol.Binding is System.Windows.Data.Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } - - /* DataGridTemplateColumn — instantiate the template and find a TextBlock binding */ - if (col is DataGridTemplateColumn templateCol && templateCol.CellTemplate != null) - { - var content = templateCol.CellTemplate.LoadContent(); - if (content is TextBlock textBlock) - { - var textBinding = System.Windows.Data.BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); - if (textBinding != null) - { - var prop = item.GetType().GetProperty(textBinding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } - } - } - - return ""; - } - - private static string FormatForExport(object? value) - { - if (value == null) return ""; - if (value is IFormattable formattable) - return formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture); - return value.ToString() ?? ""; - } - - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; - - var sb = new StringBuilder(); - - /* Header */ - foreach (var col in grid.Columns) - { - sb.Append(Helpers.DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - /* Rows */ - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } - - private async void CopyReproScript_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - string? queryText = null; - string? databaseName = null; - string? planXml = null; - string? isolationLevel = null; - string source = "Query"; - - switch (grid.CurrentItem) - { - case QuerySnapshotRow snapshot: - queryText = snapshot.QueryText; - databaseName = snapshot.DatabaseName; - planXml = snapshot.QueryPlan; - isolationLevel = snapshot.TransactionIsolationLevel; - source = "Active Queries"; - break; - - case QueryStatsRow stats: - queryText = stats.QueryText; - databaseName = stats.DatabaseName; - source = "Top Queries (dm_exec_query_stats)"; - /* Fetch plan on-demand from SQL Server */ - if (!string.IsNullOrEmpty(stats.QueryHash)) - { - try - { - var connStr = _server.GetConnectionString(_credentialService); - planXml = await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, stats.QueryHash); - } - catch { /* Plan fetch failed — continue without plan */ } - } - break; - - case QueryStoreRow qs: - queryText = qs.QueryText; - databaseName = qs.DatabaseName; - source = "Query Store"; - /* Fetch plan on-demand from Query Store */ - if (qs.PlanId > 0 && !string.IsNullOrEmpty(qs.DatabaseName)) - { - try - { - var connStr = _server.GetConnectionString(_credentialService); - planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); - } - catch { /* Plan fetch failed — continue without plan */ } - } - break; - - default: - /* Not a supported grid for repro scripts — copy query text if available */ - var textProp = grid.CurrentItem.GetType().GetProperty("QueryText"); - queryText = textProp?.GetValue(grid.CurrentItem)?.ToString(); - if (string.IsNullOrEmpty(queryText)) - { - return; - } - var dbProp = grid.CurrentItem.GetType().GetProperty("DatabaseName"); - databaseName = dbProp?.GetValue(grid.CurrentItem)?.ToString(); - break; - } - - if (string.IsNullOrEmpty(queryText)) - { - return; - } - - var script = ReproScriptBuilder.BuildReproScript(queryText, databaseName, planXml, isolationLevel, source); - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() operation. - See: https://github.com/dotnet/wpf/issues/9901 */ - Clipboard.SetDataObject(script, false); - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; - - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"{_server.DisplayName}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - var sep = App.CsvSeparator; - - /* Header */ - var headers = new List(); - foreach (var col in grid.Columns) - { - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col), sep)); - } - sb.AppendLine(string.Join(sep, headers)); - - /* Rows */ - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var col in grid.Columns) - { - values.Add(CsvEscape(GetCellValue(col, item), sep)); - } - sb.AppendLine(string.Join(sep, values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void QueryStatsGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (QueryStatsGrid.SelectedItem is not QueryStatsRow item) return; - if (string.IsNullOrEmpty(item.DatabaseName) || string.IsNullOrEmpty(item.QueryHash)) return; - - var connStr = _server.GetConnectionString(_credentialService); - var window = new Windows.QueryStatsHistoryWindow(_dataService, _serverId, item.DatabaseName, item.QueryHash, GetHoursBack(), connStr); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - private void ProcedureStatsGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (ProcedureStatsGrid.SelectedItem is not ProcedureStatsRow item) return; - if (string.IsNullOrEmpty(item.DatabaseName) || string.IsNullOrEmpty(item.ObjectName)) return; - - var connStr = _server.GetConnectionString(_credentialService); - var window = new Windows.ProcedureHistoryWindow(_dataService, _serverId, item.DatabaseName, item.SchemaName, item.ObjectName, GetHoursBack(), connStr); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - private void QueryStoreGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (QueryStoreGrid.SelectedItem is not QueryStoreRow item) return; - if (string.IsNullOrEmpty(item.DatabaseName) || item.QueryId == 0) return; - - var connStr = _server.GetConnectionString(_credentialService); - var window = new Windows.QueryStoreHistoryWindow(_dataService, _serverId, item.DatabaseName, item.QueryId, item.PlanId, item.QueryText, GetHoursBack(), connStr); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - - private void CollectionHealthGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (CollectionHealthGrid.SelectedItem is not CollectorHealthRow item) return; - - var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - private void DailySummaryToday_Click(object sender, RoutedEventArgs e) - { - _dailySummaryDate = null; - DailySummaryDatePicker.SelectedDate = null; - DailySummaryTodayButton.FontWeight = FontWeights.Bold; - DailySummaryIndicator.Text = "Showing: Today (UTC)"; - DailySummaryRefresh_Click(sender, e); - } - - private void DailySummaryDate_Changed(object sender, SelectionChangedEventArgs e) - { - if (DailySummaryDatePicker.SelectedDate.HasValue) - { - _dailySummaryDate = DailySummaryDatePicker.SelectedDate.Value.Date; - DailySummaryTodayButton.FontWeight = FontWeights.Normal; - DailySummaryIndicator.Text = $"Showing: {_dailySummaryDate.Value:MMM d, yyyy}"; - } - } - - private async void DailySummaryRefresh_Click(object sender, RoutedEventArgs e) - { - try - { - var result = await _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); - DailySummaryGrid.ItemsSource = result != null - ? new List { result } : null; - DailySummaryNoData.Visibility = result == null - ? Visibility.Visible : Visibility.Collapsed; - } - catch (Exception ex) - { - AppLogger.Error("DailySummary", $"Error refreshing: {ex.Message}"); - } - } - - private async void DownloadQueryStatsPlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not QueryStatsRow row) return; - if (string.IsNullOrEmpty(row.QueryHash)) return; - - btn.IsEnabled = false; - btn.Content = "..."; - try - { - string? plan = null; - var source = "collected data"; - - // Try DuckDB first - try - { - plan = await _dataService.GetCachedQueryPlanAsync(_serverId, row.QueryHash); - } - catch - { - // DuckDB lookup failed, fall through to live server - } - - // Fall back to live server - if (string.IsNullOrEmpty(plan)) - { - var connStr = _server.GetConnectionString(_credentialService); - plan = await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, row.QueryHash); - source = "live server"; - } - - if (string.IsNullOrEmpty(plan)) - { - MessageBox.Show("No query plan found in collected data or the live plan cache for this query hash.", "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); - return; - } - - SavePlanFile(plan, $"QueryPlan_{row.QueryHash}"); - btn.Content = $"Saved ({source})"; - return; - } - catch (Exception ex) - { - MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - finally - { - if (btn.Content is "...") - btn.Content = "Download"; - btn.IsEnabled = true; - } - } - - private async void DownloadProcedurePlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not ProcedureStatsRow row) return; - if (string.IsNullOrEmpty(row.ObjectName)) return; - - btn.IsEnabled = false; - btn.Content = "..."; - try - { - string? plan = null; - var source = "collected data"; - - // Try DuckDB first — match by plan_handle in query_stats - if (!string.IsNullOrEmpty(row.PlanHandle)) - { - try - { - plan = await _dataService.GetCachedProcedurePlanAsync(_serverId, row.PlanHandle); - } - catch - { - // DuckDB lookup failed, fall through to live server - } - } - - // Fall back to live server - if (string.IsNullOrEmpty(plan)) - { - var connStr = _server.GetConnectionString(_credentialService); - plan = await LocalDataService.FetchProcedurePlanOnDemandAsync(connStr, row.DatabaseName, row.SchemaName, row.ObjectName); - source = "live server"; - } - - if (string.IsNullOrEmpty(plan)) - { - MessageBox.Show("No query plan found in collected data or the live plan cache for this procedure.", "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); - return; - } - - SavePlanFile(plan, $"ProcPlan_{row.FullName}"); - btn.Content = $"Saved ({source})"; - return; - } - catch (Exception ex) - { - MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - finally - { - if (btn.Content is "...") - btn.Content = "Download"; - btn.IsEnabled = true; - } - } - - private void DownloadSnapshotPlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not QuerySnapshotRow row) return; - - if (row.QueryPlan == null) - { - MessageBox.Show( - "No estimated plan is available for this snapshot. The plan may have been evicted from the plan cache.", - "No Plan Available", - MessageBoxButton.OK, - MessageBoxImage.Information); - return; - } - - SavePlanFile(row.QueryPlan, $"EstimatedPlan_Session{row.SessionId}"); - } - - private void DownloadSnapshotLivePlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not QuerySnapshotRow row) return; - - if (row.LiveQueryPlan == null) - { - MessageBox.Show( - "No live query plan is available for this snapshot. The query may have completed before the plan could be captured.", - "No Plan Available", - MessageBoxButton.OK, - MessageBoxImage.Information); - return; - } - - SavePlanFile(row.LiveQueryPlan, $"ActualPlan_Session{row.SessionId}"); - } - - private void ShowPlanLoading(string label) - { - PlanLoadingLabel.Text = $"Executing: {label}"; - PlanEmptyState.Visibility = Visibility.Collapsed; - PlanTabControl.Visibility = Visibility.Collapsed; - PlanLoadingState.Visibility = Visibility.Visible; - PlanViewerTabItem.IsSelected = true; - } - - private void HidePlanLoading() - { - PlanLoadingState.Visibility = Visibility.Collapsed; - if (PlanTabControl.Items.Count > 0) - PlanTabControl.Visibility = Visibility.Visible; - else - PlanEmptyState.Visibility = Visibility.Visible; - } - - private void OpenPlanTab(string planXml, string label, string? queryText = null) - { - try - { - System.Xml.Linq.XDocument.Parse(planXml); - } - catch (System.Xml.XmlException ex) - { - MessageBox.Show( - $"The plan XML is not valid:\n\n{ex.Message}", - "Invalid Plan XML", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - - HidePlanLoading(); - var viewer = new PlanViewerControl(); - viewer.LoadPlan(planXml, label, queryText); - - var header = new StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal }; - header.Children.Add(new TextBlock - { - Text = label.Length > 30 ? label[..30] + "\u2026" : label, - VerticalAlignment = System.Windows.VerticalAlignment.Center, - ToolTip = label - }); - var closeBtn = new Button - { - Style = (Style)FindResource("TabCloseButton") - }; - header.Children.Add(closeBtn); - - var tab = new TabItem { Header = header, Content = viewer }; - closeBtn.Tag = tab; - closeBtn.Click += ClosePlanTab_Click; - - PlanTabControl.Items.Add(tab); - PlanTabControl.SelectedItem = tab; - PlanEmptyState.Visibility = Visibility.Collapsed; - PlanTabControl.Visibility = Visibility.Visible; - } - - private void ClosePlanTab_Click(object sender, RoutedEventArgs e) - { - if (sender is Button btn && btn.Tag is TabItem tab) - { - PlanTabControl.Items.Remove(tab); - if (PlanTabControl.Items.Count == 0) - { - PlanTabControl.Visibility = Visibility.Collapsed; - PlanEmptyState.Visibility = Visibility.Visible; - } - } - } - - private void CancelPlanButton_Click(object sender, RoutedEventArgs e) - { - _actualPlanCts?.Cancel(); - } - - private async void ViewEstimatedPlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - string? planXml = null; - string? queryText = null; - string label = "Estimated Plan"; - - switch (grid.CurrentItem) - { - case QuerySnapshotRow snap: - planXml = snap.LiveQueryPlan ?? snap.QueryPlan; - queryText = snap.QueryText; - label = snap.LiveQueryPlan != null - ? $"Plan - SPID {snap.SessionId}" - : $"Est Plan - SPID {snap.SessionId}"; - break; - case QueryStatsRow stats: - planXml = stats.QueryPlan; - queryText = stats.QueryText; - label = $"Est Plan - {stats.QueryHash}"; - // Fetch on demand if not already loaded - if (string.IsNullOrEmpty(planXml)) - planXml = await FetchPlanByHash(stats.QueryHash); - break; - case QueryStatsHistoryRow hist: - planXml = hist.QueryPlan; - label = "Est Plan - History"; - break; - case ProcedureStatsRow proc: - label = $"Est Plan - {proc.FullName}"; - queryText = proc.FullName; - try - { - var connStr = _server.GetConnectionString(_credentialService); - planXml = await LocalDataService.FetchProcedurePlanOnDemandAsync( - connStr, proc.DatabaseName, proc.SchemaName, proc.ObjectName); - } - catch { } - break; - case QueryStoreRow qs: - label = $"Est Plan - QS {qs.QueryId}"; - queryText = qs.QueryText; - if (qs.PlanId > 0) - { - try - { - var connStr = _server.GetConnectionString(_credentialService); - planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); - } - catch { } - } - break; - } - - if (!string.IsNullOrEmpty(planXml)) - { - OpenPlanTab(planXml, label, queryText); - PlanViewerTabItem.IsSelected = true; - } - else - { - MessageBox.Show( - "No query plan is available for this row. The plan may have been evicted from the plan cache since it was last collected.", - "No Plan Available", - MessageBoxButton.OK, - MessageBoxImage.Information); - } - } - - private async void GetActualPlan_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - string? queryText = null; - string? databaseName = null; - string? planXml = null; - string? isolationLevel = null; - string label = "Actual Plan"; - - switch (grid.CurrentItem) - { - case QuerySnapshotRow snapshot: - queryText = snapshot.QueryText; - databaseName = snapshot.DatabaseName; - planXml = snapshot.LiveQueryPlan ?? snapshot.QueryPlan; - isolationLevel = snapshot.TransactionIsolationLevel; - label = $"Actual Plan - SPID {snapshot.SessionId}"; - break; - case QueryStatsRow stats: - queryText = stats.QueryText; - databaseName = stats.DatabaseName; - label = $"Actual Plan - {stats.QueryHash}"; - if (!string.IsNullOrEmpty(stats.QueryHash)) - { - try { planXml = await FetchPlanByHash(stats.QueryHash); } - catch { } - } - break; - case QueryStoreRow qs: - queryText = qs.QueryText; - databaseName = qs.DatabaseName; - label = $"Actual Plan - QS {qs.QueryId}"; - if (qs.PlanId > 0) - { - try - { - var connStr = _server.GetConnectionString(_credentialService); - planXml = await LocalDataService.FetchQueryStorePlanAsync(connStr, qs.DatabaseName, qs.PlanId); - } - catch { } - } - break; - } - - if (string.IsNullOrWhiteSpace(queryText)) - { - MessageBox.Show("No query text available for this row.", "No Query Text", - MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - - var result = MessageBox.Show( - $"You are about to execute this query against {_server.ServerName} in database [{databaseName ?? "default"}].\n\n" + - "Make sure you understand what the query does before proceeding.\n" + - "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n" + - "All data results will be discarded.", - "Get Actual Plan", - MessageBoxButton.OKCancel, - MessageBoxImage.Warning); - - if (result != MessageBoxResult.OK) return; - - ShowPlanLoading(label); - - _actualPlanCts?.Dispose(); - _actualPlanCts = new CancellationTokenSource(); - try { - var connectionString = _server.GetConnectionString(_credentialService); + var hoursBack = GetHoursBack(); + var (fromDate, toDate) = GetCurrentViewDates(); + var history = await _dataService.GetQueryStoreHistoryAsync(_serverId, row.DatabaseName, row.QueryId, row.PlanId, hoursBack, fromDate, toDate); - var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( - connectionString, - databaseName ?? "", - queryText, - planXml, - isolationLevel, - isAzureSqlDb: false, - timeoutSeconds: 0, - _actualPlanCts.Token); - - if (!string.IsNullOrEmpty(actualPlanXml)) - { - OpenPlanTab(actualPlanXml, label, queryText); - PlanViewerTabItem.IsSelected = true; - } - else + // Query Store values are already per-interval averages, not cumulative + Func selector = _queryStoreSlicerMetric switch { - MessageBox.Show("Query executed but no execution plan was captured.", - "No Plan", MessageBoxButton.OK, MessageBoxImage.Information); - } - } - catch (OperationCanceledException) - { - MessageBox.Show("The query was cancelled or timed out.", - "Cancelled", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to get actual plan:\n\n{ex.Message}", - "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - finally - { - HidePlanLoading(); - } - } - - private async Task FetchPlanByHash(string queryHash) - { - if (string.IsNullOrEmpty(queryHash)) return null; + "TotalCpu" or "AvgCpu" => h => h.TotalCpuMs, + "TotalReads" or "AvgReads" => h => h.AvgLogicalReads * h.ExecutionCount, + _ => h => h.TotalDurationMs, + }; - // Try DuckDB cache first - try - { - var plan = await _dataService.GetCachedQueryPlanAsync(_serverId, queryHash); - if (!string.IsNullOrEmpty(plan)) return plan; - } - catch { } + var points = history + .Where(h => selector(h) > 0) + .Select(h => (h.CollectionTime, selector(h))) + .ToList(); - // Fall back to live server - try - { - var connStr = _server.GetConnectionString(_credentialService); - return await LocalDataService.FetchQueryPlanOnDemandAsync(connStr, queryHash); + var qsLabel = !string.IsNullOrWhiteSpace(row.ModuleName) + ? row.ModuleName + : $"Query {row.QueryId} / Plan {row.PlanId}"; + QueryStoreSlicer.SetOverlay(points, qsLabel); } - catch { return null; } + catch { QueryStoreSlicer.ClearOverlay(); } } - // ── Blocked Process Report plan lookup ── - - /* SQL Server writes this 42-byte all-zero handle into executionStack frames - for dynamic SQL / system contexts where no persistent sql_handle exists. - Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */ - private static readonly string ZeroSqlHandle = "0x" + new string('0', 84); - - private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e) - => await ShowBlockedProcessPlanAsync(sender, blockingSide: false); - - private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e) - => await ShowBlockedProcessPlanAsync(sender, blockingSide: true); - - private async System.Threading.Tasks.Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide) + private void QueryStatsGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem is not BlockedProcessReportRow row) return; - - var sideLabel = blockingSide ? "Blocking" : "Blocked"; - var spid = blockingSide ? row.BlockingSpid : row.BlockedSpid; - var queryText = blockingSide ? row.BlockingSqlText : row.BlockedSqlText; - var label = $"Est Plan - {sideLabel} SPID {spid}"; + if (QueryStatsGrid.SelectedItem is not QueryStatsRow item) return; + if (string.IsNullOrEmpty(item.DatabaseName) || string.IsNullOrEmpty(item.QueryHash)) return; - var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); - if (frames.Count == 0) - { - MessageBox.Show( - $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " + - "This usually means the query ran as dynamic SQL or a system context — " + - "SQL Server records a zero handle in that case and the plan can't be recovered.", - "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); - return; - } + var connStr = _server.GetConnectionString(_credentialService); + var window = new Windows.QueryStatsHistoryWindow(_dataService, _serverId, item.DatabaseName, item.QueryHash, GetHoursBack(), connStr); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } - string? planXml = null; - try - { - var connStr = _server.GetConnectionString(_credentialService); - foreach (var f in frames) - { - planXml = await LocalDataService.FetchPlanBySqlHandleAsync( - connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); - if (!string.IsNullOrEmpty(planXml)) break; - } - } - catch { } + private void ProcedureStatsGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (ProcedureStatsGrid.SelectedItem is not ProcedureStatsRow item) return; + if (string.IsNullOrEmpty(item.DatabaseName) || string.IsNullOrEmpty(item.ObjectName)) return; - if (!string.IsNullOrEmpty(planXml)) - { - OpenPlanTab(planXml, label, queryText); - PlanViewerTabItem.IsSelected = true; - } - else - { - MessageBox.Show( - $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_server.ServerName}. " + - "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.", - "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); - } + var connStr = _server.GetConnectionString(_credentialService); + var window = new Windows.ProcedureHistoryWindow(_dataService, _serverId, item.DatabaseName, item.SchemaName, item.ObjectName, GetHoursBack(), connStr); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); } - private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( - string bprXml, bool blockingSide) + private void QueryStoreGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { - var empty = Array.Empty<(string, int, int)>(); - if (string.IsNullOrWhiteSpace(bprXml)) return empty; - try - { - var doc = System.Xml.Linq.XElement.Parse(bprXml); - var processContainer = blockingSide - ? doc.Element("blocking-process") - : doc.Element("blocked-process"); - var stack = processContainer?.Element("process")?.Element("executionStack"); - if (stack == null) return empty; - - var frames = new List<(string, int, int)>(); - foreach (var frame in stack.Elements("frame")) - { - var handle = frame.Attribute("sqlhandle")?.Value; - if (string.IsNullOrWhiteSpace(handle)) continue; - if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; - - int stmtStart = 0; - int stmtEnd = -1; - int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); - if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; + if (QueryStoreGrid.SelectedItem is not QueryStoreRow item) return; + if (string.IsNullOrEmpty(item.DatabaseName) || item.QueryId == 0) return; - frames.Add((handle!, stmtStart, stmtEnd)); - } - return frames; - } - catch - { - return empty; - } + var connStr = _server.GetConnectionString(_credentialService); + var window = new Windows.QueryStoreHistoryWindow(_dataService, _serverId, item.DatabaseName, item.QueryId, item.PlanId, item.QueryText, GetHoursBack(), connStr); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); } - // ── Deadlock process plan lookup ── - /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the - node, with optional - children for the call stack. Try process-level first, then walk frames - top-down like sp_HumanEventsBlockViewer does for BPRs. */ - private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e) + private void CollectionHealthGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem is not DeadlockProcessDetail row) return; - - var sideLabel = row.IsVictim ? "Victim" : "Deadlocker"; - var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + if (CollectionHealthGrid.SelectedItem is not CollectorHealthRow item) return; - var frames = ExtractDeadlockProcessFrames(row.DeadlockGraphXml, row.ProcessId); - if (frames.Count == 0) - { - MessageBox.Show( - $"The process has no resolvable sql_handle in the deadlock graph. " + - "This usually means the query ran as dynamic SQL or a system context — " + - "SQL Server records a zero handle in that case and the plan can't be recovered.", - "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); - return; - } + var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } - string? planXml = null; - try - { - var connStr = _server.GetConnectionString(_credentialService); - foreach (var f in frames) - { - planXml = await LocalDataService.FetchPlanBySqlHandleAsync( - connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); - if (!string.IsNullOrEmpty(planXml)) break; - } - } - catch { } + private void DailySummaryToday_Click(object sender, RoutedEventArgs e) + { + _dailySummaryDate = null; + DailySummaryDatePicker.SelectedDate = null; + DailySummaryTodayButton.FontWeight = FontWeights.Bold; + DailySummaryIndicator.Text = "Showing: Today (UTC)"; + DailySummaryRefresh_Click(sender, e); + } - if (!string.IsNullOrEmpty(planXml)) - { - OpenPlanTab(planXml, label, row.SqlText); - PlanViewerTabItem.IsSelected = true; - } - else + private void DailySummaryDate_Changed(object sender, SelectionChangedEventArgs e) + { + if (DailySummaryDatePicker.SelectedDate.HasValue) { - MessageBox.Show( - $"The plan for this {sideLabel.ToLowerInvariant()} process is no longer in the plan cache on {_server.ServerName}. " + - "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.", - "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + _dailySummaryDate = DailySummaryDatePicker.SelectedDate.Value.Date; + DailySummaryTodayButton.FontWeight = FontWeights.Normal; + DailySummaryIndicator.Text = $"Showing: {_dailySummaryDate.Value:MMM d, yyyy}"; } } - private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( - string graphXml, string processId) + private async void DailySummaryRefresh_Click(object sender, RoutedEventArgs e) { - var empty = Array.Empty<(string, int, int)>(); - if (string.IsNullOrWhiteSpace(graphXml) || string.IsNullOrWhiteSpace(processId)) return empty; try { - var doc = System.Xml.Linq.XElement.Parse(graphXml); - var process = doc.Descendants("process") - .FirstOrDefault(p => string.Equals(p.Attribute("id")?.Value, processId, StringComparison.OrdinalIgnoreCase)); - if (process == null) return empty; - - var frames = new List<(string, int, int)>(); - - /* Try process-level sqlhandle first — deadlock graphs frequently put it on . */ - var procHandle = process.Attribute("sqlhandle")?.Value; - if (!string.IsNullOrWhiteSpace(procHandle) && - !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) - { - int ps = 0, pe = -1; - int.TryParse(process.Attribute("stmtstart")?.Value, out ps); - if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; - frames.Add((procHandle!, ps, pe)); - } - - /* Then walk the executionStack frames. */ - var stack = process.Element("executionStack"); - if (stack != null) - { - foreach (var frame in stack.Elements("frame")) - { - var handle = frame.Attribute("sqlhandle")?.Value; - if (string.IsNullOrWhiteSpace(handle)) continue; - if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; - - int fs = 0, fe = -1; - int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); - if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; - frames.Add((handle!, fs, fe)); - } - } - - return frames; + var result = await _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate); + DailySummaryGrid.ItemsSource = result != null + ? new List { result } : null; + DailySummaryNoData.Visibility = result == null + ? Visibility.Visible : Visibility.Collapsed; } - catch + catch (Exception ex) { - return empty; + AppLogger.Error("DailySummary", $"Error refreshing: {ex.Message}"); } } @@ -5529,125 +1501,6 @@ private async void LiveSnapshot_Click(object sender, RoutedEventArgs e) } } - private void SavePlanFile(string planXml, string defaultName) - { - var dialog = new SaveFileDialog - { - Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", - DefaultExt = ".sqlplan", - FileName = $"{defaultName}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, planXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save plan: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void DownloadDeadlockXml_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not DeadlockProcessDetail row || string.IsNullOrEmpty(row.DeadlockGraphXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", - DefaultExt = ".xml", - FileName = $"deadlock_{row.DeadlockTime:yyyyMMdd_HHmmss}.xml" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, row.DeadlockGraphXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save deadlock XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void DownloadBlockedProcessXml_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button btn || btn.DataContext is not BlockedProcessReportRow row || string.IsNullOrEmpty(row.BlockedProcessReportXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", - DefaultExt = ".xml", - FileName = $"blocked_process_{row.EventTime:yyyyMMdd_HHmmss}.xml" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, row.BlockedProcessReportXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save blocked process XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private static string CsvEscape(string value, string separator) - { - if (value.Contains(separator, StringComparison.Ordinal) || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - { - return "\"" + value.Replace("\"", "\"\"") + "\""; - } - return value; - } - - /* ========== Collection Health ========== */ - - private void UpdateCollectorDurationChart(List data) - { - ClearChart(CollectorDurationChart); - ApplyTheme(CollectorDurationChart); - - if (data.Count == 0) { CollectorDurationChart.Refresh(); return; } - - /* Group by collector, plot each as a separate series */ - var groups = data - .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS") - .GroupBy(d => d.CollectorName) - .OrderBy(g => g.Key) - .ToList(); - - _collectorDurationHover?.Clear(); - int colorIdx = 0; - foreach (var group in groups) - { - var points = group.OrderBy(d => d.CollectionTime).ToList(); - if (points.Count < 2) continue; - - var times = points.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray(); - - var scatter = CollectorDurationChart.Plot.Add.Scatter(times, durations); - scatter.LegendText = group.Key; - scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); - scatter.LineWidth = 2; - scatter.MarkerSize = 0; - _collectorDurationHover?.Add(scatter, group.Key); - colorIdx++; - } - - CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); - ReapplyAxisColors(CollectorDurationChart); - CollectorDurationChart.Plot.YLabel("Duration (ms)"); - CollectorDurationChart.Plot.Axes.AutoScale(); - ShowChartLegend(CollectorDurationChart); - CollectorDurationChart.Refresh(); - } - private void OpenLogFile_Click(object sender, RoutedEventArgs e) { var logDir = System.IO.Path.Combine(App.DataDirectory, "logs"); @@ -5699,111 +1552,6 @@ public void DisposeChartHelpers() _currentWaitsBlockedHover?.Dispose(); } - /* ========== Column Filtering ========== */ - - private void InitializeFilterManagers() - { - _querySnapshotsFilterMgr = new DataGridFilterManager(QuerySnapshotsGrid); - _queryStatsFilterMgr = new DataGridFilterManager(QueryStatsGrid); - _procStatsFilterMgr = new DataGridFilterManager(ProcedureStatsGrid); - _queryStoreFilterMgr = new DataGridFilterManager(QueryStoreGrid); - _blockedProcessFilterMgr = new DataGridFilterManager(BlockedProcessReportGrid); - _deadlockFilterMgr = new DataGridFilterManager(DeadlockGrid); - _runningJobsFilterMgr = new DataGridFilterManager(RunningJobsGrid); - _serverConfigFilterMgr = new DataGridFilterManager(ServerConfigGrid); - _databaseConfigFilterMgr = new DataGridFilterManager(DatabaseConfigGrid); - _dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid); - _traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid); - _collectionHealthFilterMgr = new DataGridFilterManager(CollectionHealthGrid); - _collectionLogFilterMgr = new DataGridFilterManager(CollectionLogGrid); - - _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr; - _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr; - _filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr; - _filterManagers[QueryStoreGrid] = _queryStoreFilterMgr; - _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr; - _filterManagers[DeadlockGrid] = _deadlockFilterMgr; - _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr; - _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr; - _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr; - _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr; - _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr; - _filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr; - _filterManagers[CollectionLogGrid] = _collectionLogFilterMgr; - } - - private void EnsureFilterPopup() - { - if (_filterPopup == null) - { - _filterPopupContent = new ColumnFilterPopup(); - _filterPopup = new Popup - { - Child = _filterPopupContent, - StaysOpen = false, - Placement = PlacementMode.Bottom, - AllowsTransparency = true - }; - } - } - - private DataGrid? _currentFilterGrid; - - private void FilterButton_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - /* Walk up visual tree to find the parent DataGrid */ - var dataGrid = FindParentDataGridFromElement(button); - if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return; - - _currentFilterGrid = dataGrid; - - EnsureFilterPopup(); - - /* Rewire events to the current grid */ - _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; - _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; - - /* Initialize with existing filter state */ - manager.Filters.TryGetValue(columnName, out var existingFilter); - _filterPopupContent.Initialize(columnName, existingFilter); - - _filterPopup!.PlacementTarget = button; - _filterPopup.IsOpen = true; - } - - private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - - if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager)) - { - manager.SetFilter(e.FilterState); - } - } - - private void FilterPopup_FilterCleared(object? sender, EventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - } - - private static DataGrid? FindParentDataGridFromElement(DependencyObject element) - { - var current = element; - while (current != null) - { - if (current is DataGrid dg) - return dg; - current = VisualTreeHelper.GetParent(current); - } - return null; - } - private static void SetDefaultSortIfNone(DataGrid grid, string bindingPath, ListSortDirection direction) { if (grid.Items.SortDescriptions.Count > 0) return; @@ -5821,4 +1569,3 @@ bc.Binding is Binding b && } } } - diff --git a/Lite/MainWindow.PlanViewer.cs b/Lite/MainWindow.PlanViewer.cs new file mode 100644 index 0000000..6b5a709 --- /dev/null +++ b/Lite/MainWindow.PlanViewer.cs @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace PerformanceMonitorLite; + +public partial class MainWindow : Window +{ + private void OpenPlanViewerButton_Click(object sender, RoutedEventArgs e) + { + EnsurePlanTabControlInitialized(); + EmptyStatePanel.Visibility = Visibility.Collapsed; + ServerTabControl.Visibility = Visibility.Visible; + MainWindowPlanViewerTab.Visibility = Visibility.Visible; + if (MainWindowPlanViewerTab.IsSelected) + { + // Already on Plan Viewer — just add a new empty sub-tab + AddNewEmptyPlanSubTab(); + } + else + { + MainWindowPlanViewerTab.IsSelected = true; + AddNewEmptyPlanSubTab(); + } + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => MainWindowPlanTabControl.Focus())); + } + + private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) + { + // Reset inner tab control so next open starts fresh + MainWindowPlanTabControl.Items.Clear(); + _planTabControlInitialized = false; + MainWindowPlanViewerTab.Visibility = Visibility.Collapsed; + // Select first visible tab + foreach (TabItem item in ServerTabControl.Items) + { + if (item.Visibility == Visibility.Visible && item != MainWindowPlanViewerTab) + { + item.IsSelected = true; + break; + } + } + } + + #region Main Window Plan Viewer + + private const string LitePlanAddTabId = "__PLAN_ADD_TAB__"; + private bool _planTabControlInitialized; + + private void EnsurePlanTabControlInitialized() + { + if (_planTabControlInitialized) return; + _planTabControlInitialized = true; + + // "+" tab at the end + var addTabHeader = new TextBlock + { + Text = "+", + FontSize = 14, + FontWeight = FontWeights.Bold, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = "Open a new plan sub-tab" + }; + var addTab = new TabItem + { + Header = addTabHeader, + Tag = LitePlanAddTabId, + Content = new Grid() + }; + MainWindowPlanTabControl.Items.Add(addTab); + + MainWindowPlanTabControl.SelectionChanged += (_, _) => + { + if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) + { + var newSub = AddNewEmptyPlanSubTab(); + MainWindowPlanTabControl.SelectedItem = newSub; + } + }; + } + + /// + /// Adds a new empty "New Plan" sub-tab to the inner plan TabControl and selects it. + /// Returns the newly created sub-tab. + /// + private TabItem AddNewEmptyPlanSubTab() + { + EnsurePlanTabControlInitialized(); + + // --- Empty state layer --- + var emptyState = new Grid(); + var dashedRect = new System.Windows.Shapes.Rectangle + { + Margin = new Thickness(24), + Stroke = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), + StrokeThickness = 1.5, + StrokeDashArray = new System.Windows.Media.DoubleCollection { 6, 4 }, + RadiusX = 10, RadiusY = 10, + Opacity = 0.25 + }; + var emptyStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + emptyStack.Children.Add(new TextBlock + { + Text = "\uE896", + FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), + FontSize = 52, + HorizontalAlignment = HorizontalAlignment.Center, + Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), + Opacity = 0.45, + Margin = new Thickness(0, 0, 0, 12) + }); + emptyStack.Children.Add(new TextBlock + { + Text = "New Plan", + FontSize = 20, + FontWeight = FontWeights.Light, + Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center + }); + emptyStack.Children.Add(new TextBlock + { + Text = "Open or paste execution plan XML to render it", + FontSize = 13, + Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 8, 0, 0) + }); + var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; + var openBtn = new Button { Content = "Open .sqlplan File", Height = 28, Padding = new Thickness(12, 0, 12, 0), ToolTip = "Open a .sqlplan or .xml file from disk" }; + var pasteBtn = new Button { Content = "Paste XML", Height = 28, Padding = new Thickness(12, 0, 12, 0), Margin = new Thickness(8, 0, 0, 0), ToolTip = "Paste execution plan XML to render it (or use Ctrl+V)" }; + btnPanel.Children.Add(openBtn); + btnPanel.Children.Add(pasteBtn); + emptyStack.Children.Add(btnPanel); + emptyStack.Children.Add(new TextBlock + { + Text = "or drag & drop a .sqlplan file anywhere in this area", + FontSize = 11, + Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 12, 0, 0) + }); + emptyState.Children.Add(dashedRect); + emptyState.Children.Add(emptyStack); + + // --- Viewer layer (hidden until a plan is loaded) --- + var viewer = new Controls.PlanViewerControl + { + Visibility = Visibility.Collapsed + }; + + // Sub-tab content grid: index 0 = emptyState, index 1 = viewer + var subTabContent = new Grid(); + subTabContent.Children.Add(emptyState); + subTabContent.Children.Add(viewer); + + // --- Sub-tab header: label + close button --- + var initialLabel = GetUniqueSubTabLabel("New Plan"); + var labelBlock = new TextBlock + { + Text = initialLabel, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = initialLabel + }; + var subCloseBtn = new Button { Style = (Style)FindResource("TabCloseButton") }; + var subTabHeader = new StackPanel { Orientation = Orientation.Horizontal }; + subTabHeader.Children.Add(labelBlock); + subTabHeader.Children.Add(subCloseBtn); + + var subTab = new TabItem { Header = subTabHeader, Content = subTabContent }; + + subCloseBtn.Tag = subTab; + subCloseBtn.Click += (_, _) => + { + MainWindowPlanTabControl.Items.Remove(subTab); + // If only the "+" tab remains, open a fresh empty sub-tab + if (MainWindowPlanTabControl.Items.Count == 1 && + MainWindowPlanTabControl.Items[0] is TabItem { Tag: string t2 } && t2 == LitePlanAddTabId) + { + AddNewEmptyPlanSubTab(); + } + }; + + // Wire per-sub-tab buttons + openBtn.Click += (_, _) => + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", + DefaultExt = ".sqlplan", + Multiselect = true + }; + if (dialog.ShowDialog() != true) return; + var isFirst = true; + foreach (var fileName in dialog.FileNames) + { + try + { + var xml = System.IO.File.ReadAllText(fileName); + var targetTab = isFirst ? subTab : AddNewEmptyPlanSubTab(); + LoadPlanIntoSubTab(targetTab, xml, System.IO.Path.GetFileName(fileName)); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to open file:\n\n{ex.Message}", "Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + isFirst = false; + } + }; + + pasteBtn.Click += (_, _) => + { + var xml = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(xml)) + { + LoadPlanIntoSubTab(subTab, xml, "Pasted Plan"); + return; + } + MessageBox.Show("The clipboard does not contain any text.", "Paste Plan XML", + MessageBoxButton.OK, MessageBoxImage.Information); + }; + + // Insert before "+" tab + var addTabIndex = -1; + for (var i = 0; i < MainWindowPlanTabControl.Items.Count; i++) + { + if (MainWindowPlanTabControl.Items[i] is TabItem { Tag: string t3 } && t3 == LitePlanAddTabId) + { + addTabIndex = i; + break; + } + } + if (addTabIndex >= 0) + MainWindowPlanTabControl.Items.Insert(addTabIndex, subTab); + else + MainWindowPlanTabControl.Items.Add(subTab); + + MainWindowPlanTabControl.SelectedItem = subTab; + return subTab; + } + + /// + /// Loads plan XML into an existing sub-tab (replacing whatever was there before). + /// + private void LoadPlanIntoSubTab(TabItem subTab, string planXml, string label, string? queryText = null) + { + try { System.Xml.Linq.XDocument.Parse(planXml); } + catch (System.Xml.XmlException ex) + { + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + if (subTab.Content is not Grid subTabContent) return; + if (subTabContent.Children.Count < 2) return; + + var emptyState = subTabContent.Children[0] as FrameworkElement; + var viewer = subTabContent.Children[1] as Controls.PlanViewerControl; + if (viewer == null) return; + + viewer.LoadPlan(planXml, label, queryText); + emptyState!.Visibility = Visibility.Collapsed; + viewer.Visibility = Visibility.Visible; + + var uniqueLabel = GetUniqueSubTabLabel(label); + if (subTab.Header is StackPanel headerPanel && + headerPanel.Children[0] is TextBlock headerLabel) + { + headerLabel.Text = uniqueLabel.Length > 30 ? uniqueLabel[..30] + "\u2026" : uniqueLabel; + headerLabel.ToolTip = uniqueLabel; + } + } + + /// + /// Returns a label that is unique among current inner plan sub-tab headers. + /// If is already taken, appends " (1)", " (2)", \u2026 + /// + private string GetUniqueSubTabLabel(string baseLabel) + { + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in MainWindowPlanTabControl.Items) + { + if (item is TabItem { Tag: string t } && t == LitePlanAddTabId) continue; + if (item is TabItem subTab && + subTab.Header is StackPanel sp && + sp.Children[0] is TextBlock tb) + existing.Add(tb.ToolTip as string ?? tb.Text); + } + if (!existing.Contains(baseLabel)) return baseLabel; + var counter = 1; + string candidate; + do { candidate = $"{baseLabel} ({counter++})"; } + while (existing.Contains(candidate)); + return candidate; + } + + /// + /// Returns the currently active real plan sub-tab (skips the "+" tab). + /// + private TabItem? GetActivePlanSubTab() + { + if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) + return null; + return MainWindowPlanTabControl.SelectedItem as TabItem; + } + + private void MainWindowPlanViewer_DragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + var files = e.Data.GetData(DataFormats.FileDrop) as string[]; + if (files?.Any(IsPlanFile) == true) + { + e.Effects = DragDropEffects.Copy; + e.Handled = true; + return; + } + } + e.Effects = DragDropEffects.None; + e.Handled = true; + } + + private void MainWindowPlanViewer_Drop(object sender, DragEventArgs e) + { + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + var planFiles = (e.Data.GetData(DataFormats.FileDrop) as string[]) + ?.Where(IsPlanFile).ToArray(); + if (planFiles == null || planFiles.Length == 0) return; + LoadMainWindowPlanFromFileIntoActiveTab(planFiles[0]); + for (var i = 1; i < planFiles.Length; i++) + { + var newTab = AddNewEmptyPlanSubTab(); + try + { + var xml = System.IO.File.ReadAllText(planFiles[i]); + LoadPlanIntoSubTab(newTab, xml, System.IO.Path.GetFileName(planFiles[i])); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + + private void MainWindowPlanViewer_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == System.Windows.Input.Key.V && + System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control && + e.OriginalSource is not System.Windows.Controls.TextBox) + { + var xml = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(xml)) + { + e.Handled = true; + LoadPlanIntoActivePlanSubTab(xml, "Pasted Plan"); + } + } + } + + private void LoadMainWindowPlanFromFileIntoActiveTab(string path) + { + try + { + var xml = System.IO.File.ReadAllText(path); + LoadPlanIntoActivePlanSubTab(xml, System.IO.Path.GetFileName(path)); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void LoadPlanIntoActivePlanSubTab(string planXml, string label) + { + var activeSubTab = GetActivePlanSubTab(); + if (activeSubTab != null) + LoadPlanIntoSubTab(activeSubTab, planXml, label); + } + + private static bool IsPlanFile(string path) + { + var ext = System.IO.Path.GetExtension(path); + return string.Equals(ext, ".sqlplan", StringComparison.OrdinalIgnoreCase) + || string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase); + } + + #endregion +} diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 2825462..fcd35f4 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1964,389 +1964,4 @@ private static string FormatDuration(long seconds) return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; } - private void OpenPlanViewerButton_Click(object sender, RoutedEventArgs e) - { - EnsurePlanTabControlInitialized(); - EmptyStatePanel.Visibility = Visibility.Collapsed; - ServerTabControl.Visibility = Visibility.Visible; - MainWindowPlanViewerTab.Visibility = Visibility.Visible; - if (MainWindowPlanViewerTab.IsSelected) - { - // Already on Plan Viewer — just add a new empty sub-tab - AddNewEmptyPlanSubTab(); - } - else - { - MainWindowPlanViewerTab.IsSelected = true; - AddNewEmptyPlanSubTab(); - } - Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => MainWindowPlanTabControl.Focus())); - } - - private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) - { - // Reset inner tab control so next open starts fresh - MainWindowPlanTabControl.Items.Clear(); - _planTabControlInitialized = false; - MainWindowPlanViewerTab.Visibility = Visibility.Collapsed; - // Select first visible tab - foreach (TabItem item in ServerTabControl.Items) - { - if (item.Visibility == Visibility.Visible && item != MainWindowPlanViewerTab) - { - item.IsSelected = true; - break; - } - } - } - - #region Main Window Plan Viewer - - private const string LitePlanAddTabId = "__PLAN_ADD_TAB__"; - private bool _planTabControlInitialized; - - private void EnsurePlanTabControlInitialized() - { - if (_planTabControlInitialized) return; - _planTabControlInitialized = true; - - // "+" tab at the end - var addTabHeader = new TextBlock - { - Text = "+", - FontSize = 14, - FontWeight = FontWeights.Bold, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = "Open a new plan sub-tab" - }; - var addTab = new TabItem - { - Header = addTabHeader, - Tag = LitePlanAddTabId, - Content = new Grid() - }; - MainWindowPlanTabControl.Items.Add(addTab); - - MainWindowPlanTabControl.SelectionChanged += (_, _) => - { - if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) - { - var newSub = AddNewEmptyPlanSubTab(); - MainWindowPlanTabControl.SelectedItem = newSub; - } - }; - } - - /// - /// Adds a new empty "New Plan" sub-tab to the inner plan TabControl and selects it. - /// Returns the newly created sub-tab. - /// - private TabItem AddNewEmptyPlanSubTab() - { - EnsurePlanTabControlInitialized(); - - // --- Empty state layer --- - var emptyState = new Grid(); - var dashedRect = new System.Windows.Shapes.Rectangle - { - Margin = new Thickness(24), - Stroke = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - StrokeThickness = 1.5, - StrokeDashArray = new System.Windows.Media.DoubleCollection { 6, 4 }, - RadiusX = 10, RadiusY = 10, - Opacity = 0.25 - }; - var emptyStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; - emptyStack.Children.Add(new TextBlock - { - Text = "\uE896", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - FontSize = 52, - HorizontalAlignment = HorizontalAlignment.Center, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - Opacity = 0.45, - Margin = new Thickness(0, 0, 0, 12) - }); - emptyStack.Children.Add(new TextBlock - { - Text = "New Plan", - FontSize = 20, - FontWeight = FontWeights.Light, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center - }); - emptyStack.Children.Add(new TextBlock - { - Text = "Open or paste execution plan XML to render it", - FontSize = 13, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 8, 0, 0) - }); - var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; - var openBtn = new Button { Content = "Open .sqlplan File", Height = 28, Padding = new Thickness(12, 0, 12, 0), ToolTip = "Open a .sqlplan or .xml file from disk" }; - var pasteBtn = new Button { Content = "Paste XML", Height = 28, Padding = new Thickness(12, 0, 12, 0), Margin = new Thickness(8, 0, 0, 0), ToolTip = "Paste execution plan XML to render it (or use Ctrl+V)" }; - btnPanel.Children.Add(openBtn); - btnPanel.Children.Add(pasteBtn); - emptyStack.Children.Add(btnPanel); - emptyStack.Children.Add(new TextBlock - { - Text = "or drag & drop a .sqlplan file anywhere in this area", - FontSize = 11, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 12, 0, 0) - }); - emptyState.Children.Add(dashedRect); - emptyState.Children.Add(emptyStack); - - // --- Viewer layer (hidden until a plan is loaded) --- - var viewer = new Controls.PlanViewerControl - { - Visibility = Visibility.Collapsed - }; - - // Sub-tab content grid: index 0 = emptyState, index 1 = viewer - var subTabContent = new Grid(); - subTabContent.Children.Add(emptyState); - subTabContent.Children.Add(viewer); - - // --- Sub-tab header: label + close button --- - var initialLabel = GetUniqueSubTabLabel("New Plan"); - var labelBlock = new TextBlock - { - Text = initialLabel, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = initialLabel - }; - var subCloseBtn = new Button { Style = (Style)FindResource("TabCloseButton") }; - var subTabHeader = new StackPanel { Orientation = Orientation.Horizontal }; - subTabHeader.Children.Add(labelBlock); - subTabHeader.Children.Add(subCloseBtn); - - var subTab = new TabItem { Header = subTabHeader, Content = subTabContent }; - - subCloseBtn.Tag = subTab; - subCloseBtn.Click += (_, _) => - { - MainWindowPlanTabControl.Items.Remove(subTab); - // If only the "+" tab remains, open a fresh empty sub-tab - if (MainWindowPlanTabControl.Items.Count == 1 && - MainWindowPlanTabControl.Items[0] is TabItem { Tag: string t2 } && t2 == LitePlanAddTabId) - { - AddNewEmptyPlanSubTab(); - } - }; - - // Wire per-sub-tab buttons - openBtn.Click += (_, _) => - { - var dialog = new Microsoft.Win32.OpenFileDialog - { - Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", - DefaultExt = ".sqlplan", - Multiselect = true - }; - if (dialog.ShowDialog() != true) return; - var isFirst = true; - foreach (var fileName in dialog.FileNames) - { - try - { - var xml = System.IO.File.ReadAllText(fileName); - var targetTab = isFirst ? subTab : AddNewEmptyPlanSubTab(); - LoadPlanIntoSubTab(targetTab, xml, System.IO.Path.GetFileName(fileName)); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to open file:\n\n{ex.Message}", "Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - isFirst = false; - } - }; - - pasteBtn.Click += (_, _) => - { - var xml = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(xml)) - { - LoadPlanIntoSubTab(subTab, xml, "Pasted Plan"); - return; - } - MessageBox.Show("The clipboard does not contain any text.", "Paste Plan XML", - MessageBoxButton.OK, MessageBoxImage.Information); - }; - - // Insert before "+" tab - var addTabIndex = -1; - for (var i = 0; i < MainWindowPlanTabControl.Items.Count; i++) - { - if (MainWindowPlanTabControl.Items[i] is TabItem { Tag: string t3 } && t3 == LitePlanAddTabId) - { - addTabIndex = i; - break; - } - } - if (addTabIndex >= 0) - MainWindowPlanTabControl.Items.Insert(addTabIndex, subTab); - else - MainWindowPlanTabControl.Items.Add(subTab); - - MainWindowPlanTabControl.SelectedItem = subTab; - return subTab; - } - - /// - /// Loads plan XML into an existing sub-tab (replacing whatever was there before). - /// - private void LoadPlanIntoSubTab(TabItem subTab, string planXml, string label, string? queryText = null) - { - try { System.Xml.Linq.XDocument.Parse(planXml); } - catch (System.Xml.XmlException ex) - { - MessageBox.Show( - $"The plan XML is not valid:\n\n{ex.Message}", - "Invalid Plan XML", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - - if (subTab.Content is not Grid subTabContent) return; - if (subTabContent.Children.Count < 2) return; - - var emptyState = subTabContent.Children[0] as FrameworkElement; - var viewer = subTabContent.Children[1] as Controls.PlanViewerControl; - if (viewer == null) return; - - viewer.LoadPlan(planXml, label, queryText); - emptyState!.Visibility = Visibility.Collapsed; - viewer.Visibility = Visibility.Visible; - - var uniqueLabel = GetUniqueSubTabLabel(label); - if (subTab.Header is StackPanel headerPanel && - headerPanel.Children[0] is TextBlock headerLabel) - { - headerLabel.Text = uniqueLabel.Length > 30 ? uniqueLabel[..30] + "\u2026" : uniqueLabel; - headerLabel.ToolTip = uniqueLabel; - } - } - - /// - /// Returns a label that is unique among current inner plan sub-tab headers. - /// If is already taken, appends " (1)", " (2)", … - /// - private string GetUniqueSubTabLabel(string baseLabel) - { - var existing = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in MainWindowPlanTabControl.Items) - { - if (item is TabItem { Tag: string t } && t == LitePlanAddTabId) continue; - if (item is TabItem subTab && - subTab.Header is StackPanel sp && - sp.Children[0] is TextBlock tb) - existing.Add(tb.ToolTip as string ?? tb.Text); - } - if (!existing.Contains(baseLabel)) return baseLabel; - var counter = 1; - string candidate; - do { candidate = $"{baseLabel} ({counter++})"; } - while (existing.Contains(candidate)); - return candidate; - } - - /// - /// Returns the currently active real plan sub-tab (skips the "+" tab). - /// - private TabItem? GetActivePlanSubTab() - { - if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) - return null; - return MainWindowPlanTabControl.SelectedItem as TabItem; - } - - private void MainWindowPlanViewer_DragOver(object sender, DragEventArgs e) - { - if (e.Data.GetDataPresent(DataFormats.FileDrop)) - { - var files = e.Data.GetData(DataFormats.FileDrop) as string[]; - if (files?.Any(IsPlanFile) == true) - { - e.Effects = DragDropEffects.Copy; - e.Handled = true; - return; - } - } - e.Effects = DragDropEffects.None; - e.Handled = true; - } - - private void MainWindowPlanViewer_Drop(object sender, DragEventArgs e) - { - if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; - var planFiles = (e.Data.GetData(DataFormats.FileDrop) as string[]) - ?.Where(IsPlanFile).ToArray(); - if (planFiles == null || planFiles.Length == 0) return; - LoadMainWindowPlanFromFileIntoActiveTab(planFiles[0]); - for (var i = 1; i < planFiles.Length; i++) - { - var newTab = AddNewEmptyPlanSubTab(); - try - { - var xml = System.IO.File.ReadAllText(planFiles[i]); - LoadPlanIntoSubTab(newTab, xml, System.IO.Path.GetFileName(planFiles[i])); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - - private void MainWindowPlanViewer_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) - { - if (e.Key == System.Windows.Input.Key.V && - System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control && - e.OriginalSource is not System.Windows.Controls.TextBox) - { - var xml = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(xml)) - { - e.Handled = true; - LoadPlanIntoActivePlanSubTab(xml, "Pasted Plan"); - } - } - } - - private void LoadMainWindowPlanFromFileIntoActiveTab(string path) - { - try - { - var xml = System.IO.File.ReadAllText(path); - LoadPlanIntoActivePlanSubTab(xml, System.IO.Path.GetFileName(path)); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void LoadPlanIntoActivePlanSubTab(string planXml, string label) - { - var activeSubTab = GetActivePlanSubTab(); - if (activeSubTab != null) - LoadPlanIntoSubTab(activeSubTab, planXml, label); - } - - private static bool IsPlanFile(string path) - { - var ext = System.IO.Path.GetExtension(path); - return string.Equals(ext, ".sqlplan", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase); - } - - #endregion } From 2fb8cd35cb69aefdc8784cddc1696007ff89c3da Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:18:03 -0400 Subject: [PATCH 02/19] Split Dashboard god-class files into partial classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. Five files split: - Services/DatabaseService.QueryPerformance.cs (4941 → 1198) into 6 sub-partials: Snapshots, Stats, Blocking, History, Trends, Mcp - ServerTab.xaml.cs (3871 → 533) into 9 partials: Refresh, Charts, Plans, TimeRange, Filters, DrillDown, Slicers, CopyExport, Alerts - Controls/QueryPerformanceContent.xaml.cs (2869 → 1279) into 6 partials: Filters, Plans, Slicers, Comparison, Heatmap, CopyExport - Controls/PlanViewerControl.xaml.cs (2539 → 368) into 4 partials: Rendering, Properties, Tooltips, Interaction - MainWindow.xaml.cs (2523 → 2112): plan viewer extracted to MainWindow.PlanViewer.cs Build clean: 0 errors, 0 warnings (pre-existing CA1806 warnings now attached to ServerTab.Plans.cs since those methods moved there). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.Interaction.cs | 261 + .../Controls/PlanViewerControl.Properties.cs | 1276 ++++ .../Controls/PlanViewerControl.Rendering.cs | 451 ++ .../Controls/PlanViewerControl.Tooltips.cs | 264 + Dashboard/Controls/PlanViewerControl.xaml.cs | 2907 +------- .../QueryPerformanceContent.Comparison.cs | 152 + .../QueryPerformanceContent.CopyExport.cs | 194 + .../QueryPerformanceContent.Filters.cs | 655 ++ .../QueryPerformanceContent.Heatmap.cs | 192 + .../Controls/QueryPerformanceContent.Plans.cs | 344 + .../QueryPerformanceContent.Slicers.cs | 156 + .../Controls/QueryPerformanceContent.xaml.cs | 4148 ++++------- Dashboard/MainWindow.PlanViewer.cs | 431 ++ Dashboard/MainWindow.xaml.cs | 411 -- Dashboard/ServerTab.Alerts.cs | 204 + Dashboard/ServerTab.Charts.cs | 718 ++ Dashboard/ServerTab.CopyExport.cs | 206 + Dashboard/ServerTab.DrillDown.cs | 253 + Dashboard/ServerTab.Filters.cs | 328 + Dashboard/ServerTab.Plans.cs | 533 ++ Dashboard/ServerTab.Refresh.cs | 394 ++ Dashboard/ServerTab.Slicers.cs | 182 + Dashboard/ServerTab.TimeRange.cs | 693 ++ Dashboard/ServerTab.xaml.cs | 4404 ++---------- ...tabaseService.QueryPerformance.Blocking.cs | 765 ++ ...atabaseService.QueryPerformance.History.cs | 658 ++ .../DatabaseService.QueryPerformance.Mcp.cs | 657 ++ ...abaseService.QueryPerformance.Snapshots.cs | 479 ++ .../DatabaseService.QueryPerformance.Stats.cs | 698 ++ ...DatabaseService.QueryPerformance.Trends.cs | 633 ++ .../DatabaseService.QueryPerformance.cs | 6139 ++++------------- 31 files changed, 15155 insertions(+), 14631 deletions(-) create mode 100644 Dashboard/Controls/PlanViewerControl.Interaction.cs create mode 100644 Dashboard/Controls/PlanViewerControl.Properties.cs create mode 100644 Dashboard/Controls/PlanViewerControl.Rendering.cs create mode 100644 Dashboard/Controls/PlanViewerControl.Tooltips.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.Comparison.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.CopyExport.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.Filters.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.Heatmap.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.Plans.cs create mode 100644 Dashboard/Controls/QueryPerformanceContent.Slicers.cs create mode 100644 Dashboard/MainWindow.PlanViewer.cs create mode 100644 Dashboard/ServerTab.Alerts.cs create mode 100644 Dashboard/ServerTab.Charts.cs create mode 100644 Dashboard/ServerTab.CopyExport.cs create mode 100644 Dashboard/ServerTab.DrillDown.cs create mode 100644 Dashboard/ServerTab.Filters.cs create mode 100644 Dashboard/ServerTab.Plans.cs create mode 100644 Dashboard/ServerTab.Refresh.cs create mode 100644 Dashboard/ServerTab.Slicers.cs create mode 100644 Dashboard/ServerTab.TimeRange.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.Blocking.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.History.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.Mcp.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs create mode 100644 Dashboard/Services/DatabaseService.QueryPerformance.Trends.cs diff --git a/Dashboard/Controls/PlanViewerControl.Interaction.cs b/Dashboard/Controls/PlanViewerControl.Interaction.cs new file mode 100644 index 0000000..b866168 --- /dev/null +++ b/Dashboard/Controls/PlanViewerControl.Interaction.cs @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class PlanViewerControl +{ + #region Node Selection & Context Menu + + private void Node_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border border && border.Tag is PlanNode 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; + _selectedNode = node; + 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 += (_, _) => + { + // Find the border for this node by checking Tags + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && b.Tag == node) + { + SelectNode(b, node); + break; + } + } + }; + menu.Items.Add(propsItem); + + menu.Items.Add(new Separator()); + + var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; + copyOpItem.Click += (_, _) => Clipboard.SetDataObject(node.PhysicalOp, false); + menu.Items.Add(copyOpItem); + + if (!string.IsNullOrEmpty(node.FullObjectName)) + { + var copyObjItem = new MenuItem { Header = "Copy Object Name" }; + copyObjItem.Click += (_, _) => Clipboard.SetDataObject(node.FullObjectName, false); + menu.Items.Add(copyObjItem); + } + + if (!string.IsNullOrEmpty(node.Predicate)) + { + var copyPredItem = new MenuItem { Header = "Copy Predicate" }; + copyPredItem.Click += (_, _) => Clipboard.SetDataObject(node.Predicate, false); + menu.Items.Add(copyPredItem); + } + + if (!string.IsNullOrEmpty(node.SeekPredicates)) + { + var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; + copySeekItem.Click += (_, _) => Clipboard.SetDataObject(node.SeekPredicates, false); + menu.Items.Add(copySeekItem); + } + + return menu; + } + + #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.ActualWidth; + var viewHeight = PlanScrollViewer.ActualHeight; + if (viewWidth <= 0 || viewHeight <= 0) return; + + var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); + SetZoom(Math.Min(fitZoom, 1.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)}%"; + } + + private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + SetZoom(_zoomLevel + (e.Delta > 0 ? ZoomStep : -ZoomStep)); + } + } + + private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + // Don't steal focus from interactive controls (ComboBox, DataGrid, TextBox, etc.) + // ComboBox dropdown items live in a separate visual tree (Popup), so also check + // for ComboBoxItem to avoid stealing focus when selecting dropdown items. + if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase + || e.OriginalSource is ComboBox + || e.OriginalSource is ComboBoxItem + || FindVisualParent(e.OriginalSource as DependencyObject) != null + || FindVisualParent(e.OriginalSource as DependencyObject) != null + || FindVisualParent(e.OriginalSource as DependencyObject) != null) + return; + + Focus(); + } + + private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject + { + while (child != null) + { + if (child is T parent) return parent; + child = VisualTreeHelper.GetParent(child); + } + return null; + } + + private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control + && e.OriginalSource is not TextBox) + { + var text = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(text)) + { + e.Handled = true; + try + { + System.Xml.Linq.XDocument.Parse(text); + } + catch (System.Xml.XmlException ex) + { + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + LoadPlan(text, "Pasted Plan"); + } + } + } + + #endregion + + #region Canvas Panning + + private void PlanScrollViewer_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // Don't intercept scrollbar interactions + if (IsScrollBarAtPoint(e)) + return; + + // Don't pan if clicking on a node + if (IsNodeAtPoint(e)) + return; + + _isPanning = true; + _panStart = e.GetPosition(PlanScrollViewer); + _panStartOffsetX = PlanScrollViewer.HorizontalOffset; + _panStartOffsetY = PlanScrollViewer.VerticalOffset; + PlanScrollViewer.Cursor = Cursors.SizeAll; + PlanScrollViewer.CaptureMouse(); + e.Handled = true; + } + + private void PlanScrollViewer_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (!_isPanning) return; + + var current = e.GetPosition(PlanScrollViewer); + var dx = current.X - _panStart.X; + var dy = current.Y - _panStart.Y; + + PlanScrollViewer.ScrollToHorizontalOffset(Math.Max(0, _panStartOffsetX - dx)); + PlanScrollViewer.ScrollToVerticalOffset(Math.Max(0, _panStartOffsetY - dy)); + e.Handled = true; + } + + private void PlanScrollViewer_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (!_isPanning) return; + _isPanning = false; + PlanScrollViewer.Cursor = Cursors.Arrow; + PlanScrollViewer.ReleaseMouseCapture(); + e.Handled = true; + } + + /// Check if the mouse event originated from a ScrollBar. + private static bool IsScrollBarAtPoint(MouseButtonEventArgs e) + { + var source = e.OriginalSource as DependencyObject; + while (source != null) + { + if (source is System.Windows.Controls.Primitives.ScrollBar) + return true; + source = VisualTreeHelper.GetParent(source); + } + return false; + } + + /// Check if the mouse event originated from a node Border (has PlanNode in Tag). + private static bool IsNodeAtPoint(MouseButtonEventArgs e) + { + var source = e.OriginalSource as DependencyObject; + while (source != null) + { + if (source is Border b && b.Tag is PlanNode) + return true; + source = VisualTreeHelper.GetParent(source); + } + return false; + } + + #endregion +} diff --git a/Dashboard/Controls/PlanViewerControl.Properties.cs b/Dashboard/Controls/PlanViewerControl.Properties.cs new file mode 100644 index 0000000..5aea797 --- /dev/null +++ b/Dashboard/Controls/PlanViewerControl.Properties.cs @@ -0,0 +1,1276 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class PlanViewerControl +{ + #region Properties Panel + + private void ShowPropertiesPanel(PlanNode node) + { + PropertiesContent.Children.Clear(); + _currentPropertySection = null; + + // 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 — always show for operators that have object references + 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 Matching 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) + { + AddPropertySection("Plan Warnings"); + foreach (var w in s.PlanWarnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + warnPanel.Children.Add(new TextBlock + { + Text = $"\u26A0 {w.WarningType}", + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel); + } + } + + // === 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) + { + AddPropertySection("Warnings"); + foreach (var w in node.Warnings) + { + var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" + : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; + warnPanel.Children.Add(new TextBlock + { + Text = $"\u26A0 {w.WarningType}", + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) + }); + warnPanel.Children.Add(new TextBlock + { + Text = w.Message, + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 0, 0, 0) + }); + PropertiesContent.Children.Add(warnPanel); + } + } + + // Show the panel + PropertiesColumn.Width = new GridLength(320); + PropertiesSplitter.Visibility = Visibility.Visible; + PropertiesPanel.Visibility = Visibility.Visible; + } + + private void AddPropertySection(string title) + { + var contentPanel = new StackPanel(); + var expander = new Expander + { + IsExpanded = true, + Header = new TextBlock + { + Text = title, + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Foreground = SectionHeaderBrush + }, + Content = contentPanel, + Margin = new Thickness(0, 2, 0, 0), + Padding = new Thickness(0), + Foreground = SectionHeaderBrush, + Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + BorderBrush = PropSeparatorBrush, + BorderThickness = new Thickness(0, 0, 0, 1) + }; + PropertiesContent.Children.Add(expander); + _currentPropertySection = contentPanel; + } + + private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) + { + var grid = new Grid { Margin = new Thickness(10, 3, 10, 3) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var labelBlock = new TextBlock + { + Text = label, + FontSize = indent ? 10 : 11, + Foreground = MutedBrush, + VerticalAlignment = VerticalAlignment.Top, + TextWrapping = TextWrapping.Wrap, + Margin = indent ? new Thickness(16, 0, 0, 0) : new Thickness(0) + }; + Grid.SetColumn(labelBlock, 0); + grid.Children.Add(labelBlock); + + 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), + VerticalAlignment = VerticalAlignment.Top + }; + if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); + Grid.SetColumn(valueBox, 1); + grid.Children.Add(valueBox); + + var target = _currentPropertySection ?? PropertiesContent; + target.Children.Add(grid); + } + + private void CloseProperties_Click(object sender, RoutedEventArgs e) + { + ClosePropertiesPanel(); + } + + private void ClosePropertiesPanel() + { + PropertiesPanel.Visibility = Visibility.Collapsed; + PropertiesSplitter.Visibility = Visibility.Collapsed; + PropertiesColumn.Width = new GridLength(0); + + // Deselect node + if (_selectedNodeBorder != null) + { + _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; + _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; + _selectedNodeBorder = null; + } + _selectedNode = null; + } + + #endregion + + #region Banners + + private void ShowMissingIndexes(List indexes) + { + MissingIndexContent.Children.Clear(); + + if (indexes.Count > 0) + { + MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; + + 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 = FontWeights.SemiBold, + Foreground = TooltipFgBrush, + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $" \u2014 Impact: ", + Foreground = MutedBrush, + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $"{mi.Impact:F1}%", + Foreground = OrangeBrush, + FontSize = 12 + }); + itemPanel.Children.Add(headerRow); + + if (!string.IsNullOrEmpty(mi.CreateStatement)) + { + itemPanel.Children.Add(new TextBox + { + Text = mi.CreateStatement, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + Foreground = TooltipFgBrush, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(12, 2, 0, 0) + }); + } + + MissingIndexContent.Children.Add(itemPanel); + } + + MissingIndexEmpty.Visibility = Visibility.Collapsed; + } + else + { + MissingIndexHeader.Text = "Missing Index Suggestions"; + MissingIndexEmpty.Visibility = Visibility.Visible; + } + } + + private void ShowWaitStats(List waits, 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.Visibility = Visibility.Visible; + return; + } + + WaitStatsEmpty.Visibility = Visibility.Collapsed; + + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); + var maxWait = sorted[0].WaitTimeMs; + var totalWait = sorted.Sum(w => w.WaitTimeMs); + + WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; + + var longestName = sorted.Max(w => w.WaitType.Length); + var nameColWidth = longestName * 6.5 + 10; + + var maxBarWidth = 300; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(nameColWidth) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(maxBarWidth + 16) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + for (int i = 0; i < sorted.Count; i++) + grid.RowDefinitions.Add(new RowDefinition { Height = 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)); + + var nameText = new TextBlock + { + Text = w.WaitType, + FontSize = 12, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 10, 2) + }; + Grid.SetRow(nameText, i); + Grid.SetColumn(nameText, 0); + grid.Children.Add(nameText); + + var colorBar = new Border + { + Width = Math.Max(4, barFraction * maxBarWidth), + Height = 14, + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)), + 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); + + var durationText = new TextBlock + { + Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", + FontSize = 12, + Foreground = TooltipFgBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(durationText, i); + Grid.SetColumn(durationText, 2); + grid.Children.Add(durationText); + } + + WaitStatsContent.Children.Add(grid); + } + + private static string GetWaitCategory(string waitType) + { + if (waitType.StartsWith("SOS_SCHEDULER_YIELD", StringComparison.Ordinal) || + waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || + waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) || + waitType.StartsWith("CXSYNC_PORT", StringComparison.Ordinal) || + waitType.StartsWith("CXSYNC_CONSUMER", StringComparison.Ordinal)) + return "CPU"; + + if (waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || + waitType.StartsWith("WRITELOG", StringComparison.Ordinal) || + waitType.StartsWith("IO_COMPLETION", StringComparison.Ordinal) || + waitType.StartsWith("ASYNC_IO_COMPLETION", StringComparison.Ordinal)) + return "I/O"; + + if (waitType.StartsWith("LCK_M_", StringComparison.Ordinal)) + 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" + }; + } + + private void ShowRuntimeSummary(PlanStatement statement) + { + RuntimeSummaryContent.Children.Clear(); + + var labelBrush = MutedBrush; + var valueBrush = TooltipFgBrush; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + int rowIndex = 0; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var labelText = new TextBlock + { + Text = label, + FontSize = 11, + Foreground = labelBrush, + 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 = valueBrush, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(valueText, rowIndex); + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + + rowIndex++; + } + + 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"); + } + + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used"); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms"); + } + + if (statement.DegreeOfParallelism > 0) + AddRow("DOP", statement.DegreeOfParallelism.ToString()); + else if (statement.NonParallelPlanReason != null) + AddRow("Serial", statement.NonParallelPlanReason); + + 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 threadText = ts.UsedThreads == totalReserved + ? $"{ts.UsedThreads} used ({totalReserved} reserved)" + : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; + AddRow("Threads", threadText); + } + else + { + AddRow("Threads", $"{ts.UsedThreads} used"); + } + } + + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + if (statement.CompileTimeMs > 0) + AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + + RuntimeSummaryContent.Children.Add(grid); + } + + /// + /// Formats a memory value given in KB to a human-readable string. + /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals). + /// + private static string FormatMemoryGrantKB(long kb) + { + if (kb < 1024) + return $"{kb:N0} KB"; + if (kb < 1024 * 1024) + return $"{kb / 1024.0:N1} MB"; + return $"{kb / (1024.0 * 1024.0):N2} GB"; + } + + private void UpdateInsightsHeader() + { + InsightsPanel.Visibility = Visibility.Visible; + InsightsHeader.Text = " Plan Insights"; + } + + #endregion +} diff --git a/Dashboard/Controls/PlanViewerControl.Rendering.cs b/Dashboard/Controls/PlanViewerControl.Rendering.cs new file mode 100644 index 0000000..24b6344 --- /dev/null +++ b/Dashboard/Controls/PlanViewerControl.Rendering.cs @@ -0,0 +1,451 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +using WpfPath = System.Windows.Shapes.Path; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class PlanViewerControl +{ + private void RenderStatement(PlanStatement statement) + { + _currentStatement = statement; + PlanCanvas.Children.Clear(); + _selectedNodeBorder = null; + PlanScrollViewer.ScrollToHome(); + + 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 + var allWarnings = new List(); + CollectWarnings(statement.RootNode, allWarnings); + RenderNodes(statement.RootNode, allWarnings.Count); + + // Update banners + ShowMissingIndexes(statement.MissingIndexes); + ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); + ShowRuntimeSummary(statement); + UpdateInsightsHeader(); + + // Update cost text + CostText.Text = $"Statement Cost: {statement.StatementSubTreeCost:F4}"; + } + + #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 border = new Border + { + Width = PlanLayoutEngine.NodeWidth, + MinHeight = PlanLayoutEngine.NodeHeightMin, + Background = isExpensive + ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) + : (Brush)FindResource("BackgroundLightBrush"), + BorderBrush = isExpensive + ? Brushes.OrangeRed + : (Brush)FindResource("BorderBrush"), + BorderThickness = new Thickness(isExpensive ? 2 : 1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + SnapsToDevicePixels = true, + Tag = node + }; + + // Tooltip — root node includes statement-level PlanWarnings + if (totalWarningCount > 0 && _currentStatement != null) + { + var allWarnings = new List(); + allWarnings.AddRange(_currentStatement.PlanWarnings); + CollectWarnings(node, allWarnings); + border.ToolTip = BuildNodeTooltip(node, allWarnings); + } + else + { + border.ToolTip = BuildNodeTooltip(node); + } + + // Click to select + show properties + border.MouseLeftButtonUp += 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 icon = PlanIconMapper.GetIcon(node.IconName); + if (icon != null) + { + iconRow.Children.Add(new Image + { + Source = icon, + 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 Polygon + { + Points = new PointCollection + { + new Point(10, 0), new Point(20, 18), new Point(0, 18) + }, + Fill = Brushes.Orange + }); + warnBadge.Children.Add(new TextBlock + { + Text = "!", + FontSize = 12, + FontWeight = FontWeights.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 = FontWeights.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, 4, 1), + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"+{node.NonClusteredIndexCount} NC", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + } + }; + iconRow.Children.Add(ncBadge); + } + + stack.Children.Add(iconRow); + + // Operator name — use full name, let TextTrimming handle overflow + stack.Children.Add(new TextBlock + { + Text = node.PhysicalOp, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("ForegroundBrush"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // Cost percentage + var costColor = node.CostPercent >= 50 ? Brushes.OrangeRed + : node.CostPercent >= 25 ? Brushes.Orange + : (Brush)FindResource("ForegroundBrush"); + + 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) + { + var fgBrush = (Brush)FindResource("ForegroundBrush"); + + // Elapsed time — red if >= 1 second + var elapsedSec = node.ActualElapsedMs / 1000.0; + var elapsedBrush = elapsedSec >= 1.0 ? Brushes.OrangeRed : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"{elapsedSec:F3}s", + FontSize = 10, + Foreground = elapsedBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }); + + // CPU time — red if >= 1 second + var cpuSec = node.ActualCPUMs / 1000.0; + var cpuBrush = cpuSec >= 1.0 ? Brushes.OrangeRed : fgBrush; + stack.Children.Add(new TextBlock + { + Text = $"CPU: {cpuSec:F3}s", + FontSize = 9, + 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); + var rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? Brushes.OrangeRed : fgBrush; + var accuracy = estRows > 0 + ? $" ({accuracyRatio * 100:F0}%)" + : ""; + stack.Children.Add(new TextBlock + { + Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", + FontSize = 9, + Foreground = rowBrush, + TextAlignment = TextAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16 + }); + } + + // Object name — show full object name, use ellipsis for overflow + if (!string.IsNullOrEmpty(node.ObjectName)) + { + stack.Children.Add(new TextBlock + { + Text = node.FullObjectName ?? node.ObjectName, + FontSize = 9, + Foreground = (Brush)FindResource("ForegroundBrush"), + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = PlanLayoutEngine.NodeWidth - 16, + HorizontalAlignment = HorizontalAlignment.Center, + ToolTip = node.FullObjectName ?? node.ObjectName + }); + } + + // 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 = FontWeights.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 WpfPath 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(new Point(midX, parentCenterY), true)); + figure.Segments.Add(new LineSegment(new Point(midX, childCenterY), true)); + figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true)); + geometry.Figures.Add(figure); + + return new WpfPath + { + Data = geometry, + Stroke = EdgeBrush, + StrokeThickness = thickness, + StrokeLineJoin = PenLineJoin.Round, + ToolTip = BuildEdgeTooltipContent(child), + SnapsToDevicePixels = true + }; + } + + private Border BuildEdgeTooltipContent(PlanNode child) + { + var grid = new Grid { MinWidth = 240 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + int row = 0; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var lbl = new TextBlock + { + Text = label, + Foreground = MutedBrush, + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = TooltipFgBrush, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(lbl, row); + Grid.SetColumn(lbl, 0); + Grid.SetRow(val, row); + Grid.SetColumn(val, 1); + grid.Children.Add(lbl); + grid.Children.Add(val); + 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 = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6, 10, 6), + CornerRadius = new CornerRadius(4), + Child = grid + }; + } + + 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"; + } + + #endregion +} diff --git a/Dashboard/Controls/PlanViewerControl.Tooltips.cs b/Dashboard/Controls/PlanViewerControl.Tooltips.cs new file mode 100644 index 0000000..7c5c9e4 --- /dev/null +++ b/Dashboard/Controls/PlanViewerControl.Tooltips.cs @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class PlanViewerControl +{ + #region Tooltips + + private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null) + { + var tip = new ToolTip + { + Background = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, + Foreground = TooltipFgBrush, + 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 = FontWeights.Bold, + FontSize = 13, + 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}"); + } + + // 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 — show full qualified name + 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 (includes statement-level) 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 + var distinct = warnings + .GroupBy(w => w.WarningType) + .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count())) + .OrderByDescending(g => g.MaxSeverity) + .ThenBy(g => g.Type); + + foreach (var (type, severity, count) in distinct) + { + var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" + : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; + var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}"; + stack.Children.Add(new TextBlock + { + Text = label, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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)ColorConverter.ConvertFromString(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 = FontStyles.Italic, + Foreground = MutedBrush, + Margin = new Thickness(0, 8, 0, 0) + }); + + tip.Content = stack; + return tip; + } + + private void AddTooltipSection(StackPanel parent, string title) + { + parent.Children.Add(new TextBlock + { + Text = title, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = SectionHeaderBrush, + Margin = new Thickness(0, 6, 0, 2) + }); + } + + private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) + { + var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; + row.Children.Add(new TextBlock + { + Text = $"{label}: ", + Foreground = MutedBrush, + FontSize = 11, + MinWidth = 120 + }); + var valueBlock = new TextBlock + { + Text = value, + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 350 + }; + if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); + row.Children.Add(valueBlock); + parent.Children.Add(row); + } + + #endregion +} diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs index c7bcac4..69686e0 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml.cs +++ b/Dashboard/Controls/PlanViewerControl.xaml.cs @@ -1,2539 +1,368 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Shapes; -using Microsoft.Win32; -using PerformanceMonitorDashboard.Models; -using PerformanceMonitorDashboard.Services; - -using WpfPath = System.Windows.Shapes.Path; - -namespace PerformanceMonitorDashboard.Controls; - -public partial class PlanViewerControl : UserControl -{ - private ParsedPlan? _currentPlan; - private PlanStatement? _currentStatement; - 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 = ""; - - // Node selection - private Border? _selectedNodeBorder; - private Brush? _selectedNodeOriginalBorder; - private Thickness _selectedNodeOriginalThickness; - private PlanNode? _selectedNode; - - // Brushes — accent/neutral tones that suit every theme - private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); - private static readonly SolidColorBrush OrangeBrush = new(Color.FromRgb(0xFF, 0xB3, 0x47)); - - // Theme-aware brushes resolved at call time from Application.Resources - private SolidColorBrush TooltipBgBrush => - (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23)); - private SolidColorBrush TooltipBorderBrush => - (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)); - private SolidColorBrush TooltipFgBrush => - (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private SolidColorBrush MutedBrush => - (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); - private SolidColorBrush SectionHeaderBrush => - (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF)); - private SolidColorBrush PropSeparatorBrush => - (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35)); - - // Current property section for collapsible groups - private StackPanel? _currentPropertySection; - - // Canvas panning - private bool _isPanning; - private Point _panStart; - private double _panStartOffsetX; - private double _panStartOffsetY; - - public PlanViewerControl() - { - InitializeComponent(); - Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - } - - private void OnThemeChanged(string _) - { - if (_currentStatement == null) return; - - var nodeToRestore = _selectedNode; - RenderStatement(_currentStatement); - - if (nodeToRestore == null) return; - - // Find the re-created border for the previously selected node and reopen properties - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && b.Tag == nodeToRestore) - { - SelectNode(b, nodeToRestore); - break; - } - } - } - - public void LoadPlan(string planXml, string label, string? queryText = null) - { - _label = label; - - if (!string.IsNullOrEmpty(queryText)) - { - QueryTextBox.Text = queryText; - QueryTextExpander.Visibility = Visibility.Visible; - } - else - { - QueryTextExpander.Visibility = Visibility.Collapsed; - } - _currentPlan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(_currentPlan); - - var allStatements = _currentPlan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .ToList(); - - if (allStatements.Count == 0) - { - EmptyState.Visibility = Visibility.Visible; - PlanScrollViewer.Visibility = Visibility.Collapsed; - return; - } - - EmptyState.Visibility = Visibility.Collapsed; - PlanScrollViewer.Visibility = Visibility.Visible; - - // Populate statement grid for multi-statement plans - if (allStatements.Count > 1) - { - PopulateStatementsGrid(allStatements); - ShowStatementsPanel(); - CostText.Visibility = Visibility.Visible; - // Auto-select first statement to render it - if (StatementsGrid.Items.Count > 0) - StatementsGrid.SelectedIndex = 0; - } - else - { - CostText.Visibility = Visibility.Collapsed; - RenderStatement(allStatements[0]); - } - } - - public void Clear() - { - PlanCanvas.Children.Clear(); - _currentPlan = null; - _currentStatement = null; - _selectedNodeBorder = null; - EmptyState.Visibility = Visibility.Visible; - PlanScrollViewer.Visibility = Visibility.Collapsed; - InsightsPanel.Visibility = Visibility.Collapsed; - CloseStatementsPanel(); - CostText.Text = ""; - CostText.Visibility = Visibility.Collapsed; - ClosePropertiesPanel(); - } - - private void RenderStatement(PlanStatement statement) - { - _currentStatement = statement; - PlanCanvas.Children.Clear(); - _selectedNodeBorder = null; - PlanScrollViewer.ScrollToHome(); - - 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 - var allWarnings = new List(); - CollectWarnings(statement.RootNode, allWarnings); - RenderNodes(statement.RootNode, allWarnings.Count); - - // Update banners - ShowMissingIndexes(statement.MissingIndexes); - ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); - ShowRuntimeSummary(statement); - UpdateInsightsHeader(); - - // Update cost text - CostText.Text = $"Statement Cost: {statement.StatementSubTreeCost:F4}"; - } - - #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 border = new Border - { - Width = PlanLayoutEngine.NodeWidth, - MinHeight = PlanLayoutEngine.NodeHeightMin, - Background = isExpensive - ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73)) - : (Brush)FindResource("BackgroundLightBrush"), - BorderBrush = isExpensive - ? Brushes.OrangeRed - : (Brush)FindResource("BorderBrush"), - BorderThickness = new Thickness(isExpensive ? 2 : 1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 4, 6, 4), - Cursor = Cursors.Hand, - SnapsToDevicePixels = true, - Tag = node - }; - - // Tooltip — root node includes statement-level PlanWarnings - if (totalWarningCount > 0 && _currentStatement != null) - { - var allWarnings = new List(); - allWarnings.AddRange(_currentStatement.PlanWarnings); - CollectWarnings(node, allWarnings); - border.ToolTip = BuildNodeTooltip(node, allWarnings); - } - else - { - border.ToolTip = BuildNodeTooltip(node); - } - - // Click to select + show properties - border.MouseLeftButtonUp += 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 icon = PlanIconMapper.GetIcon(node.IconName); - if (icon != null) - { - iconRow.Children.Add(new Image - { - Source = icon, - 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 Polygon - { - Points = new PointCollection - { - new Point(10, 0), new Point(20, 18), new Point(0, 18) - }, - Fill = Brushes.Orange - }); - warnBadge.Children.Add(new TextBlock - { - Text = "!", - FontSize = 12, - FontWeight = FontWeights.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 = FontWeights.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, 4, 1), - Margin = new Thickness(4, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = $"+{node.NonClusteredIndexCount} NC", - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.White - } - }; - iconRow.Children.Add(ncBadge); - } - - stack.Children.Add(iconRow); - - // Operator name — use full name, let TextTrimming handle overflow - stack.Children.Add(new TextBlock - { - Text = node.PhysicalOp, - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = (Brush)FindResource("ForegroundBrush"), - TextAlignment = TextAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // Cost percentage - var costColor = node.CostPercent >= 50 ? Brushes.OrangeRed - : node.CostPercent >= 25 ? Brushes.Orange - : (Brush)FindResource("ForegroundBrush"); - - 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) - { - var fgBrush = (Brush)FindResource("ForegroundBrush"); - - // Elapsed time — red if >= 1 second - var elapsedSec = node.ActualElapsedMs / 1000.0; - var elapsedBrush = elapsedSec >= 1.0 ? Brushes.OrangeRed : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"{elapsedSec:F3}s", - FontSize = 10, - Foreground = elapsedBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }); - - // CPU time — red if >= 1 second - var cpuSec = node.ActualCPUMs / 1000.0; - var cpuBrush = cpuSec >= 1.0 ? Brushes.OrangeRed : fgBrush; - stack.Children.Add(new TextBlock - { - Text = $"CPU: {cpuSec:F3}s", - FontSize = 9, - 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); - var rowBrush = (accuracyRatio < 0.1 || accuracyRatio > 10.0) ? Brushes.OrangeRed : fgBrush; - var accuracy = estRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; - stack.Children.Add(new TextBlock - { - Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", - FontSize = 9, - Foreground = rowBrush, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16 - }); - } - - // Object name — show full object name, use ellipsis for overflow - if (!string.IsNullOrEmpty(node.ObjectName)) - { - stack.Children.Add(new TextBlock - { - Text = node.FullObjectName ?? node.ObjectName, - FontSize = 9, - Foreground = (Brush)FindResource("ForegroundBrush"), - TextAlignment = TextAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = PlanLayoutEngine.NodeWidth - 16, - HorizontalAlignment = HorizontalAlignment.Center, - ToolTip = node.FullObjectName ?? node.ObjectName - }); - } - - // 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 = FontWeights.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 WpfPath 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(new Point(midX, parentCenterY), true)); - figure.Segments.Add(new LineSegment(new Point(midX, childCenterY), true)); - figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true)); - geometry.Figures.Add(figure); - - return new WpfPath - { - Data = geometry, - Stroke = EdgeBrush, - StrokeThickness = thickness, - StrokeLineJoin = PenLineJoin.Round, - ToolTip = BuildEdgeTooltipContent(child), - SnapsToDevicePixels = true - }; - } - - private Border BuildEdgeTooltipContent(PlanNode child) - { - var grid = new Grid { MinWidth = 240 }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - int row = 0; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - var lbl = new TextBlock - { - Text = label, - Foreground = MutedBrush, - FontSize = 12, - Margin = new Thickness(0, 1, 12, 1) - }; - var val = new TextBlock - { - Text = value, - Foreground = TooltipFgBrush, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(lbl, row); - Grid.SetColumn(lbl, 0); - Grid.SetRow(val, row); - Grid.SetColumn(val, 1); - grid.Children.Add(lbl); - grid.Children.Add(val); - 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 = TooltipBgBrush, - BorderBrush = TooltipBorderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 6, 10, 6), - CornerRadius = new CornerRadius(4), - Child = grid - }; - } - - 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"; - } - - #endregion - - #region Node Selection & Properties Panel - - private void Node_Click(object sender, MouseButtonEventArgs e) - { - if (sender is Border border && border.Tag is PlanNode 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; - _selectedNode = node; - 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 += (_, _) => - { - // Find the border for this node by checking Tags - foreach (var child in PlanCanvas.Children) - { - if (child is Border b && b.Tag == node) - { - SelectNode(b, node); - break; - } - } - }; - menu.Items.Add(propsItem); - - menu.Items.Add(new Separator()); - - var copyOpItem = new MenuItem { Header = "Copy Operator Name" }; - copyOpItem.Click += (_, _) => Clipboard.SetDataObject(node.PhysicalOp, false); - menu.Items.Add(copyOpItem); - - if (!string.IsNullOrEmpty(node.FullObjectName)) - { - var copyObjItem = new MenuItem { Header = "Copy Object Name" }; - copyObjItem.Click += (_, _) => Clipboard.SetDataObject(node.FullObjectName, false); - menu.Items.Add(copyObjItem); - } - - if (!string.IsNullOrEmpty(node.Predicate)) - { - var copyPredItem = new MenuItem { Header = "Copy Predicate" }; - copyPredItem.Click += (_, _) => Clipboard.SetDataObject(node.Predicate, false); - menu.Items.Add(copyPredItem); - } - - if (!string.IsNullOrEmpty(node.SeekPredicates)) - { - var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" }; - copySeekItem.Click += (_, _) => Clipboard.SetDataObject(node.SeekPredicates, false); - menu.Items.Add(copySeekItem); - } - - return menu; - } - - private void ShowPropertiesPanel(PlanNode node) - { - PropertiesContent.Children.Clear(); - _currentPropertySection = null; - - // 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 — always show for operators that have object references - 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 Matching 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) - { - AddPropertySection("Plan Warnings"); - foreach (var w in s.PlanWarnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - warnPanel.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}", - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel); - } - } - - // === 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) - { - AddPropertySection("Warnings"); - foreach (var w in node.Warnings) - { - var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373" - : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) }; - warnPanel.Children.Add(new TextBlock - { - Text = $"\u26A0 {w.WarningType}", - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)) - }); - warnPanel.Children.Add(new TextBlock - { - Text = w.Message, - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(16, 0, 0, 0) - }); - PropertiesContent.Children.Add(warnPanel); - } - } - - // Show the panel - PropertiesColumn.Width = new GridLength(320); - PropertiesSplitter.Visibility = Visibility.Visible; - PropertiesPanel.Visibility = Visibility.Visible; - } - - private void AddPropertySection(string title) - { - var contentPanel = new StackPanel(); - var expander = new Expander - { - IsExpanded = true, - Header = new TextBlock - { - Text = title, - FontWeight = FontWeights.SemiBold, - FontSize = 11, - Foreground = SectionHeaderBrush - }, - Content = contentPanel, - Margin = new Thickness(0, 2, 0, 0), - Padding = new Thickness(0), - Foreground = SectionHeaderBrush, - Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), - BorderBrush = PropSeparatorBrush, - BorderThickness = new Thickness(0, 0, 0, 1) - }; - PropertiesContent.Children.Add(expander); - _currentPropertySection = contentPanel; - } - - private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) - { - var grid = new Grid { Margin = new Thickness(10, 3, 10, 3) }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var labelBlock = new TextBlock - { - Text = label, - FontSize = indent ? 10 : 11, - Foreground = MutedBrush, - VerticalAlignment = VerticalAlignment.Top, - TextWrapping = TextWrapping.Wrap, - Margin = indent ? new Thickness(16, 0, 0, 0) : new Thickness(0) - }; - Grid.SetColumn(labelBlock, 0); - grid.Children.Add(labelBlock); - - 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), - VerticalAlignment = VerticalAlignment.Top - }; - if (isCode) valueBox.FontFamily = new FontFamily("Consolas"); - Grid.SetColumn(valueBox, 1); - grid.Children.Add(valueBox); - - var target = _currentPropertySection ?? PropertiesContent; - target.Children.Add(grid); - } - - private void CloseProperties_Click(object sender, RoutedEventArgs e) - { - ClosePropertiesPanel(); - } - - private void ClosePropertiesPanel() - { - PropertiesPanel.Visibility = Visibility.Collapsed; - PropertiesSplitter.Visibility = Visibility.Collapsed; - PropertiesColumn.Width = new GridLength(0); - - // Deselect node - if (_selectedNodeBorder != null) - { - _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder; - _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; - _selectedNodeBorder = null; - } - _selectedNode = null; - } - - #endregion - - #region Tooltips - - private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null) - { - var tip = new ToolTip - { - Background = TooltipBgBrush, - BorderBrush = TooltipBorderBrush, - Foreground = TooltipFgBrush, - 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 = FontWeights.Bold, - FontSize = 13, - 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}"); - } - - // 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 — show full qualified name - 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 (includes statement-level) 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 - var distinct = warnings - .GroupBy(w => w.WarningType) - .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count())) - .OrderByDescending(g => g.MaxSeverity) - .ThenBy(g => g.Type); - - foreach (var (type, severity, count) in distinct) - { - var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373" - : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF"; - var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}"; - stack.Children.Add(new TextBlock - { - Text = label, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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)ColorConverter.ConvertFromString(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 = FontStyles.Italic, - Foreground = MutedBrush, - Margin = new Thickness(0, 8, 0, 0) - }); - - tip.Content = stack; - return tip; - } - - private void AddTooltipSection(StackPanel parent, string title) - { - parent.Children.Add(new TextBlock - { - Text = title, - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = SectionHeaderBrush, - Margin = new Thickness(0, 6, 0, 2) - }); - } - - private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) - { - var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; - row.Children.Add(new TextBlock - { - Text = $"{label}: ", - Foreground = MutedBrush, - FontSize = 11, - MinWidth = 120 - }); - var valueBlock = new TextBlock - { - Text = value, - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 350 - }; - if (isCode) valueBlock.FontFamily = new FontFamily("Consolas"); - row.Children.Add(valueBlock); - parent.Children.Add(row); - } - - #endregion - - #region Banners - - private void ShowMissingIndexes(List indexes) - { - MissingIndexContent.Children.Clear(); - - if (indexes.Count > 0) - { - MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; - - 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 = FontWeights.SemiBold, - Foreground = TooltipFgBrush, - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $" \u2014 Impact: ", - Foreground = MutedBrush, - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $"{mi.Impact:F1}%", - Foreground = OrangeBrush, - FontSize = 12 - }); - itemPanel.Children.Add(headerRow); - - if (!string.IsNullOrEmpty(mi.CreateStatement)) - { - itemPanel.Children.Add(new TextBox - { - Text = mi.CreateStatement, - FontFamily = new FontFamily("Consolas"), - FontSize = 11, - Foreground = TooltipFgBrush, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - IsReadOnly = true, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(12, 2, 0, 0) - }); - } - - MissingIndexContent.Children.Add(itemPanel); - } - - MissingIndexEmpty.Visibility = Visibility.Collapsed; - } - else - { - MissingIndexHeader.Text = "Missing Index Suggestions"; - MissingIndexEmpty.Visibility = Visibility.Visible; - } - } - - private static void CollectWarnings(PlanNode node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectWarnings(child, warnings); - } - - private void ShowWaitStats(List waits, 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.Visibility = Visibility.Visible; - return; - } - - WaitStatsEmpty.Visibility = Visibility.Collapsed; - - var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); - var maxWait = sorted[0].WaitTimeMs; - var totalWait = sorted.Sum(w => w.WaitTimeMs); - - WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; - - var longestName = sorted.Max(w => w.WaitType.Length); - var nameColWidth = longestName * 6.5 + 10; - - var maxBarWidth = 300; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(nameColWidth) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(maxBarWidth + 16) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - for (int i = 0; i < sorted.Count; i++) - grid.RowDefinitions.Add(new RowDefinition { Height = 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)); - - var nameText = new TextBlock - { - Text = w.WaitType, - FontSize = 12, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 10, 2) - }; - Grid.SetRow(nameText, i); - Grid.SetColumn(nameText, 0); - grid.Children.Add(nameText); - - var colorBar = new Border - { - Width = Math.Max(4, barFraction * maxBarWidth), - Height = 14, - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)), - 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); - - var durationText = new TextBlock - { - Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", - FontSize = 12, - Foreground = TooltipFgBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) - }; - Grid.SetRow(durationText, i); - Grid.SetColumn(durationText, 2); - grid.Children.Add(durationText); - } - - WaitStatsContent.Children.Add(grid); - } - - private static string GetWaitCategory(string waitType) - { - if (waitType.StartsWith("SOS_SCHEDULER_YIELD", StringComparison.Ordinal) || - waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || - waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) || - waitType.StartsWith("CXSYNC_PORT", StringComparison.Ordinal) || - waitType.StartsWith("CXSYNC_CONSUMER", StringComparison.Ordinal)) - return "CPU"; - - if (waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || - waitType.StartsWith("WRITELOG", StringComparison.Ordinal) || - waitType.StartsWith("IO_COMPLETION", StringComparison.Ordinal) || - waitType.StartsWith("ASYNC_IO_COMPLETION", StringComparison.Ordinal)) - return "I/O"; - - if (waitType.StartsWith("LCK_M_", StringComparison.Ordinal)) - 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" - }; - } - - private void ShowRuntimeSummary(PlanStatement statement) - { - RuntimeSummaryContent.Children.Clear(); - - var labelBrush = MutedBrush; - var valueBrush = TooltipFgBrush; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - int rowIndex = 0; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - var labelText = new TextBlock - { - Text = label, - FontSize = 11, - Foreground = labelBrush, - 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 = valueBrush, - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(valueText, rowIndex); - Grid.SetColumn(valueText, 1); - grid.Children.Add(valueText); - - rowIndex++; - } - - 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"); - } - - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used"); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms"); - } - - if (statement.DegreeOfParallelism > 0) - AddRow("DOP", statement.DegreeOfParallelism.ToString()); - else if (statement.NonParallelPlanReason != null) - AddRow("Serial", statement.NonParallelPlanReason); - - 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 threadText = ts.UsedThreads == totalReserved - ? $"{ts.UsedThreads} used ({totalReserved} reserved)" - : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; - AddRow("Threads", threadText); - } - else - { - AddRow("Threads", $"{ts.UsedThreads} used"); - } - } - - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - - RuntimeSummaryContent.Children.Add(grid); - } - - /// - /// Formats a memory value given in KB to a human-readable string. - /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals). - /// - private static string FormatMemoryGrantKB(long kb) - { - if (kb < 1024) - return $"{kb:N0} KB"; - if (kb < 1024 * 1024) - return $"{kb / 1024.0:N1} MB"; - return $"{kb / (1024.0 * 1024.0):N2} GB"; - } - - private void UpdateInsightsHeader() - { - InsightsPanel.Visibility = Visibility.Visible; - InsightsHeader.Text = " Plan Insights"; - } - - #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.ActualWidth; - var viewHeight = PlanScrollViewer.ActualHeight; - if (viewWidth <= 0 || viewHeight <= 0) return; - - var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); - SetZoom(Math.Min(fitZoom, 1.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)}%"; - } - - private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) - { - if (Keyboard.Modifiers == ModifierKeys.Control) - { - e.Handled = true; - SetZoom(_zoomLevel + (e.Delta > 0 ? ZoomStep : -ZoomStep)); - } - } - - private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) - { - // Don't steal focus from interactive controls (ComboBox, DataGrid, TextBox, etc.) - // ComboBox dropdown items live in a separate visual tree (Popup), so also check - // for ComboBoxItem to avoid stealing focus when selecting dropdown items. - if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase - || e.OriginalSource is ComboBox - || e.OriginalSource is ComboBoxItem - || FindVisualParent(e.OriginalSource as DependencyObject) != null - || FindVisualParent(e.OriginalSource as DependencyObject) != null - || FindVisualParent(e.OriginalSource as DependencyObject) != null) - return; - - Focus(); - } - - private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject - { - while (child != null) - { - if (child is T parent) return parent; - child = VisualTreeHelper.GetParent(child); - } - return null; - } - - private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control - && e.OriginalSource is not TextBox) - { - var text = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(text)) - { - e.Handled = true; - try - { - System.Xml.Linq.XDocument.Parse(text); - } - catch (System.Xml.XmlException ex) - { - MessageBox.Show( - $"The plan XML is not valid:\n\n{ex.Message}", - "Invalid Plan XML", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - LoadPlan(text, "Pasted Plan"); - } - } - } - - #endregion - - #region Save & Statement Selection - - private void SavePlan_Click(object sender, RoutedEventArgs e) - { - if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", - DefaultExt = ".sqlplan", - FileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" - }; - - if (dialog.ShowDialog() == true) - { - File.WriteAllText(dialog.FileName, _currentPlan.RawXml); - } - } - - 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 System.Windows.Data.Binding("Index"), - Width = new DataGridLength(40), - IsReadOnly = true - }); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Query", - Binding = new System.Windows.Data.Binding("QueryText"), - Width = new DataGridLength(1, DataGridLengthUnitType.Star), - IsReadOnly = true - }); - - if (hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "CPU", - Binding = new System.Windows.Data.Binding("CpuDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - SortMemberPath = "CpuMs" - }); - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Elapsed", - Binding = new System.Windows.Data.Binding("ElapsedDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - SortMemberPath = "ElapsedMs" - }); - } - - if (hasUdf) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "UDF", - Binding = new System.Windows.Data.Binding("UdfDisplay"), - Width = new DataGridLength(70), - IsReadOnly = true, - SortMemberPath = "UdfMs" - }); - } - - if (!hasActualTimes) - { - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Est. Cost", - Binding = new System.Windows.Data.Binding("CostDisplay"), - Width = new DataGridLength(80), - IsReadOnly = true, - SortMemberPath = "EstCost" - }); - } - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Critical", - Binding = new System.Windows.Data.Binding("Critical"), - Width = new DataGridLength(60), - IsReadOnly = true - }); - - StatementsGrid.Columns.Add(new DataGridTextColumn - { - Header = "Warnings", - Binding = new System.Windows.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) - CollectWarnings(stmt.RootNode, allWarnings); - - var text = stmt.StatementText; - if (string.IsNullOrWhiteSpace(text)) - text = $"Statement {i + 1}"; - if (text.Length > 120) - text = text[..120] + "..."; - - rows.Add(new StatementRow - { - Index = i + 1, - QueryText = text, - 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 void CopyStatementText_Click(object sender, RoutedEventArgs e) - { - if (StatementsGrid.SelectedItem is StatementRow row) - { - var text = row.Statement.StatementText; - if (!string.IsNullOrEmpty(text)) - Clipboard.SetText(text); - } - } - - private void ToggleStatements_Click(object sender, RoutedEventArgs e) - { - if (StatementsPanel.Visibility == Visibility.Visible) - 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.Visibility = Visibility.Visible; - StatementsPanel.Visibility = Visibility.Visible; - StatementsButton.Visibility = Visibility.Visible; - StatementsButtonSeparator.Visibility = Visibility.Visible; - } - - private void CloseStatementsPanel() - { - StatementsPanel.Visibility = Visibility.Collapsed; - StatementsSplitter.Visibility = Visibility.Collapsed; - StatementsColumn.Width = new GridLength(0); - StatementsSplitterColumn.Width = new GridLength(0); - } - - #endregion - - #region Canvas Panning - - private void PlanScrollViewer_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - // Don't intercept scrollbar interactions - if (IsScrollBarAtPoint(e)) - return; - - // Don't pan if clicking on a node - if (IsNodeAtPoint(e)) - return; - - _isPanning = true; - _panStart = e.GetPosition(PlanScrollViewer); - _panStartOffsetX = PlanScrollViewer.HorizontalOffset; - _panStartOffsetY = PlanScrollViewer.VerticalOffset; - PlanScrollViewer.Cursor = Cursors.SizeAll; - PlanScrollViewer.CaptureMouse(); - e.Handled = true; - } - - private void PlanScrollViewer_PreviewMouseMove(object sender, MouseEventArgs e) - { - if (!_isPanning) return; - - var current = e.GetPosition(PlanScrollViewer); - var dx = current.X - _panStart.X; - var dy = current.Y - _panStart.Y; - - PlanScrollViewer.ScrollToHorizontalOffset(Math.Max(0, _panStartOffsetX - dx)); - PlanScrollViewer.ScrollToVerticalOffset(Math.Max(0, _panStartOffsetY - dy)); - e.Handled = true; - } - - private void PlanScrollViewer_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - if (!_isPanning) return; - _isPanning = false; - PlanScrollViewer.Cursor = Cursors.Arrow; - PlanScrollViewer.ReleaseMouseCapture(); - e.Handled = true; - } - - /// Check if the mouse event originated from a ScrollBar. - private static bool IsScrollBarAtPoint(MouseButtonEventArgs e) - { - var source = e.OriginalSource as DependencyObject; - while (source != null) - { - if (source is System.Windows.Controls.Primitives.ScrollBar) - return true; - source = VisualTreeHelper.GetParent(source); - } - return false; - } - - /// Check if the mouse event originated from a node Border (has PlanNode in Tag). - private static bool IsNodeAtPoint(MouseButtonEventArgs e) - { - var source = e.OriginalSource as DependencyObject; - while (source != null) - { - if (source is Border b && b.Tag is PlanNode) - return true; - source = VisualTreeHelper.GetParent(source); - } - return false; - } - - #endregion -} - -/// Data model for the statement DataGrid rows. -public class StatementRow -{ - public int Index { get; set; } - public string QueryText { 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 — grid binds to these, sorting uses the raw properties via SortMemberPath - 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"; - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class PlanViewerControl : UserControl +{ + private ParsedPlan? _currentPlan; + private PlanStatement? _currentStatement; + 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 = ""; + + // Node selection + private Border? _selectedNodeBorder; + private Brush? _selectedNodeOriginalBorder; + private Thickness _selectedNodeOriginalThickness; + private PlanNode? _selectedNode; + + // Brushes — accent/neutral tones that suit every theme + private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); + private static readonly SolidColorBrush OrangeBrush = new(Color.FromRgb(0xFF, 0xB3, 0x47)); + + // Theme-aware brushes resolved at call time from Application.Resources + private SolidColorBrush TooltipBgBrush => + (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23)); + private SolidColorBrush TooltipBorderBrush => + (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)); + private SolidColorBrush TooltipFgBrush => + (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush MutedBrush => + (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush SectionHeaderBrush => + (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private SolidColorBrush PropSeparatorBrush => + (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35)); + + // Current property section for collapsible groups + private StackPanel? _currentPropertySection; + + // Canvas panning + private bool _isPanning; + private Point _panStart; + private double _panStartOffsetX; + private double _panStartOffsetY; + + public PlanViewerControl() + { + InitializeComponent(); + Helpers.ThemeManager.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(string _) + { + if (_currentStatement == null) return; + + var nodeToRestore = _selectedNode; + RenderStatement(_currentStatement); + + if (nodeToRestore == null) return; + + // Find the re-created border for the previously selected node and reopen properties + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && b.Tag == nodeToRestore) + { + SelectNode(b, nodeToRestore); + break; + } + } + } + + public void LoadPlan(string planXml, string label, string? queryText = null) + { + _label = label; + + if (!string.IsNullOrEmpty(queryText)) + { + QueryTextBox.Text = queryText; + QueryTextExpander.Visibility = Visibility.Visible; + } + else + { + QueryTextExpander.Visibility = Visibility.Collapsed; + } + _currentPlan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(_currentPlan); + + var allStatements = _currentPlan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .ToList(); + + if (allStatements.Count == 0) + { + EmptyState.Visibility = Visibility.Visible; + PlanScrollViewer.Visibility = Visibility.Collapsed; + return; + } + + EmptyState.Visibility = Visibility.Collapsed; + PlanScrollViewer.Visibility = Visibility.Visible; + + // Populate statement grid for multi-statement plans + if (allStatements.Count > 1) + { + PopulateStatementsGrid(allStatements); + ShowStatementsPanel(); + CostText.Visibility = Visibility.Visible; + // Auto-select first statement to render it + if (StatementsGrid.Items.Count > 0) + StatementsGrid.SelectedIndex = 0; + } + else + { + CostText.Visibility = Visibility.Collapsed; + RenderStatement(allStatements[0]); + } + } + + public void Clear() + { + PlanCanvas.Children.Clear(); + _currentPlan = null; + _currentStatement = null; + _selectedNodeBorder = null; + EmptyState.Visibility = Visibility.Visible; + PlanScrollViewer.Visibility = Visibility.Collapsed; + InsightsPanel.Visibility = Visibility.Collapsed; + CloseStatementsPanel(); + CostText.Text = ""; + CostText.Visibility = Visibility.Collapsed; + ClosePropertiesPanel(); + } + + private static void CollectWarnings(PlanNode node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectWarnings(child, warnings); + } + + #region Save & Statement Selection + + private void SavePlan_Click(object sender, RoutedEventArgs e) + { + if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return; + + var dialog = new SaveFileDialog + { + Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() == true) + { + File.WriteAllText(dialog.FileName, _currentPlan.RawXml); + } + } + + 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 System.Windows.Data.Binding("Index"), + Width = new DataGridLength(40), + IsReadOnly = true + }); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Query", + Binding = new System.Windows.Data.Binding("QueryText"), + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + IsReadOnly = true + }); + + if (hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "CPU", + Binding = new System.Windows.Data.Binding("CpuDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + SortMemberPath = "CpuMs" + }); + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Elapsed", + Binding = new System.Windows.Data.Binding("ElapsedDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + SortMemberPath = "ElapsedMs" + }); + } + + if (hasUdf) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "UDF", + Binding = new System.Windows.Data.Binding("UdfDisplay"), + Width = new DataGridLength(70), + IsReadOnly = true, + SortMemberPath = "UdfMs" + }); + } + + if (!hasActualTimes) + { + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Est. Cost", + Binding = new System.Windows.Data.Binding("CostDisplay"), + Width = new DataGridLength(80), + IsReadOnly = true, + SortMemberPath = "EstCost" + }); + } + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Critical", + Binding = new System.Windows.Data.Binding("Critical"), + Width = new DataGridLength(60), + IsReadOnly = true + }); + + StatementsGrid.Columns.Add(new DataGridTextColumn + { + Header = "Warnings", + Binding = new System.Windows.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) + CollectWarnings(stmt.RootNode, allWarnings); + + var text = stmt.StatementText; + if (string.IsNullOrWhiteSpace(text)) + text = $"Statement {i + 1}"; + if (text.Length > 120) + text = text[..120] + "..."; + + rows.Add(new StatementRow + { + Index = i + 1, + QueryText = text, + 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 void CopyStatementText_Click(object sender, RoutedEventArgs e) + { + if (StatementsGrid.SelectedItem is StatementRow row) + { + var text = row.Statement.StatementText; + if (!string.IsNullOrEmpty(text)) + Clipboard.SetText(text); + } + } + + private void ToggleStatements_Click(object sender, RoutedEventArgs e) + { + if (StatementsPanel.Visibility == Visibility.Visible) + 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.Visibility = Visibility.Visible; + StatementsPanel.Visibility = Visibility.Visible; + StatementsButton.Visibility = Visibility.Visible; + StatementsButtonSeparator.Visibility = Visibility.Visible; + } + + private void CloseStatementsPanel() + { + StatementsPanel.Visibility = Visibility.Collapsed; + StatementsSplitter.Visibility = Visibility.Collapsed; + StatementsColumn.Width = new GridLength(0); + StatementsSplitterColumn.Width = new GridLength(0); + } + + #endregion +} + +/// Data model for the statement DataGrid rows. +public class StatementRow +{ + public int Index { get; set; } + public string QueryText { 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 — grid binds to these, sorting uses the raw properties via SortMemberPath + 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"; + } +} diff --git a/Dashboard/Controls/QueryPerformanceContent.Comparison.cs b/Dashboard/Controls/QueryPerformanceContent.Comparison.cs new file mode 100644 index 0000000..084b36d --- /dev/null +++ b/Dashboard/Controls/QueryPerformanceContent.Comparison.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class QueryPerformanceContent : UserControl + { + private (DateTime From, DateTime To)? _comparisonRange; + + public void SetComparisonRange((DateTime From, DateTime To)? range) + { + _comparisonRange = range; + } + + public async Task RefreshComparisonAsync() + { + if (_databaseService == null) return; + + try + { + var currentEnd = _queryStatsToDate ?? DateTime.UtcNow; + var currentStart = _queryStatsFromDate ?? currentEnd.AddHours(-_queryStatsHoursBack); + + await RefreshQueryStatsComparisonAsync(currentStart, currentEnd); + await RefreshProcStatsComparisonAsync(currentStart, currentEnd); + await RefreshQueryStoreComparisonAsync(currentStart, currentEnd); + } + catch (Exception ex) + { + _statusCallback?.Invoke($"Comparison failed: {ex.Message}"); + } + } + + private void SetQueryStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + QueryStatsDataGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + QueryStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + QueryStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = baselineRange.Value.From.ToString("yyyy-MM-dd HH:mm"); + var to = baselineRange.Value.To.ToString("yyyy-MM-dd HH:mm"); + QueryStatsComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; + } + } + + private async Task RefreshQueryStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + if (_comparisonRange == null) + { + SetQueryStatsComparisonMode(false); + return; + } + + SetQueryStatsComparisonMode(true, _comparisonRange); + + var items = await _databaseService!.GetQueryStatsComparisonAsync( + currentStart, currentEnd, + _comparisonRange.Value.From, _comparisonRange.Value.To); + + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + QueryStatsComparisonGrid.ItemsSource = sorted; + } + + private void SetProcStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + ProcStatsDataGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + ProcStatsComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + ProcStatsComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = baselineRange.Value.From.ToString("yyyy-MM-dd HH:mm"); + var to = baselineRange.Value.To.ToString("yyyy-MM-dd HH:mm"); + ProcStatsComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; + } + } + + private async Task RefreshProcStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + if (_comparisonRange == null) + { + SetProcStatsComparisonMode(false); + return; + } + + SetProcStatsComparisonMode(true, _comparisonRange); + + var items = await _databaseService!.GetProcedureStatsComparisonAsync( + currentStart, currentEnd, + _comparisonRange.Value.From, _comparisonRange.Value.To); + + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + ProcStatsComparisonGrid.ItemsSource = sorted; + } + + private void SetQueryStoreComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null) + { + QueryStoreDataGrid.Visibility = active ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + QueryStoreComparisonGrid.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + QueryStoreComparisonBanner.Visibility = active ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = baselineRange.Value.From.ToString("yyyy-MM-dd HH:mm"); + var to = baselineRange.Value.To.ToString("yyyy-MM-dd HH:mm"); + QueryStoreComparisonBanner.Text = $"Comparing against baseline: {from} \u2192 {to}"; + } + } + + private async Task RefreshQueryStoreComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + if (_comparisonRange == null) + { + SetQueryStoreComparisonMode(false); + return; + } + + SetQueryStoreComparisonMode(true, _comparisonRange); + + var items = await _databaseService!.GetQueryStoreComparisonAsync( + currentStart, currentEnd, + _comparisonRange.Value.From, _comparisonRange.Value.To); + + var sorted = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + + QueryStoreComparisonGrid.ItemsSource = sorted; + } + } +} diff --git a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs new file mode 100644 index 0000000..ef7dbe8 --- /dev/null +++ b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class QueryPerformanceContent : UserControl + { + private void CopyCell_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.CurrentCell.Item != null) + { + var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); + if (!string.IsNullOrEmpty(cellContent)) + { + Clipboard.SetDataObject(cellContent, false); + } + } + } + } + + private void CopyRow_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.SelectedItem != null) + { + var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); + Clipboard.SetDataObject(rowText, false); + } + } + } + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.Items.Count > 0) + { + var sb = new StringBuilder(); + + var headers = new List(); + foreach (var column in dataGrid.Columns) + { + if (column is DataGridBoundColumn) + { + headers.Add(Helpers.DataGridClipboardBehavior.GetHeaderText(column)); + } + } + sb.AppendLine(string.Join("\t", headers)); + + foreach (var item in dataGrid.Items) + { + sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); + } + + Clipboard.SetDataObject(sb.ToString(), false); + } + } + } + + private void CopyReproScript_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem || menuItem.Parent is not ContextMenu contextMenu) return; + + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid?.SelectedItem == null) return; + + var item = dataGrid.SelectedItem; + string? queryText = null; + string? databaseName = null; + string? planXml = null; + string source = "Query"; + + /* Extract data based on item type */ + switch (item) + { + case QuerySnapshotItem qs: + queryText = qs.QueryText; + databaseName = qs.DatabaseName; + planXml = qs.QueryPlan; + source = "Active Queries"; + break; + case QueryStatsItem qst: + queryText = qst.QueryText; + databaseName = qst.DatabaseName; + planXml = qst.QueryPlanXml; + source = "Query Stats"; + break; + case QueryStoreItem qsi: + queryText = qsi.QueryText; + databaseName = qsi.DatabaseName; + planXml = qsi.QueryPlanXml; + source = "Query Store"; + break; + case ProcedureStatsItem ps: + queryText = ps.ObjectName; + databaseName = ps.DatabaseName; + planXml = null; /* Procedures don't have plan XML in the model */ + source = "Procedure Stats"; + break; + default: + MessageBox.Show("Copy Repro Script is not available for this data type.", "Not Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (string.IsNullOrWhiteSpace(queryText)) + { + MessageBox.Show("No query text available for this row.", "No Query Text", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var script = ReproScriptBuilder.BuildReproScript(queryText, databaseName, planXml, isolationLevel: null, source); + + try + { + Clipboard.SetDataObject(script, false); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to copy to clipboard: {ex.Message}", "Clipboard Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.Items.Count > 0) + { + var saveFileDialog = new SaveFileDialog + { + FileName = $"query_performance_{DateTime.Now:yyyyMMdd_HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" + }; + + if (saveFileDialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + + var headers = new List(); + foreach (var column in dataGrid.Columns) + { + if (column is DataGridBoundColumn) + { + headers.Add(TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); + } + } + sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); + + foreach (var item in dataGrid.Items) + { + var values = TabHelpers.GetRowValues(dataGrid, item); + sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); + } + + File.WriteAllText(saveFileDialog.FileName, sb.ToString()); + MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + } + } + } +} diff --git a/Dashboard/Controls/QueryPerformanceContent.Filters.cs b/Dashboard/Controls/QueryPerformanceContent.Filters.cs new file mode 100644 index 0000000..607892d --- /dev/null +++ b/Dashboard/Controls/QueryPerformanceContent.Filters.cs @@ -0,0 +1,655 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class QueryPerformanceContent : UserControl + { + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + + // Active Queries filter state + private Dictionary _activeQueriesFilters = new(); + private List? _activeQueriesUnfilteredData; + + // Current Active Queries filter state + private Dictionary _currentActiveFilters = new(); + private List? _currentActiveUnfilteredData; + + // Query Stats filter state + private Dictionary _queryStatsFilters = new(); + private List? _queryStatsUnfilteredData; + + // Procedure Stats filter state + private Dictionary _procStatsFilters = new(); + private List? _procStatsUnfilteredData; + + // Query Store filter state + private Dictionary _queryStoreFilters = new(); + private List? _queryStoreUnfilteredData; + + // Query Store Regressions filter state + private Dictionary _qsRegressionsFilters = new(); + private List? _qsRegressionsUnfilteredData; + + // Query Trace Patterns filter state + private Dictionary _lrqPatternsFilters = new(); + private List? _lrqPatternsUnfilteredData; + + /// + /// Generic method to update filter button styles for any DataGrid by traversing column headers + /// + private void UpdateDataGridFilterButtonStyles(DataGrid dataGrid, Dictionary filters) + { + foreach (var column in dataGrid.Columns) + { + // Get the header content - it's either a StackPanel containing a Button, or a direct element + if (column.Header is StackPanel headerPanel) + { + // Find the filter button in the header + var filterButton = headerPanel.Children.OfType