diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2e88c68..3c5b35c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build on: push: - branches: [main] + branches: [main, dev] pull_request: branches: [main, dev] release: @@ -20,16 +20,62 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Detect changed paths + id: filter + if: github.event_name != 'release' + uses: dorny/paths-filter@v3 + with: + # On push events, compare against the previous commit on this branch + # (github.event.before). Without this, the action defaults to comparing + # against the default branch on non-default branch pushes, which would + # match every accumulated change and defeat the filter. + base: ${{ github.event_name == 'push' && github.event.before || '' }} + filters: | + lite_analysis: + - 'Lite/Analysis/**' + - 'Lite/Services/**' + - 'Lite/DuckDb/**' + - 'Lite/Models/**' + - 'Lite.Tests/**' + - 'Lite/PerformanceMonitorLite.csproj' + installer: + - 'Installer/**' + - 'Installer.Core/**' + - 'Installer.Tests/**' + - 'install/**' + - 'upgrades/**' + - name: Setup .NET 8.0 uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore dependencies run: | - dotnet restore Dashboard/Dashboard.csproj - dotnet restore Lite/PerformanceMonitorLite.csproj - dotnet restore Installer/PerformanceMonitorInstaller.csproj + dotnet restore Dashboard/Dashboard.csproj --locked-mode + dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode + dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode + dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + dotnet restore Installer.Tests/Installer.Tests.csproj --locked-mode + + - name: Build Lite.Tests + run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore + + - name: Build Installer.Tests + run: dotnet build Installer.Tests/Installer.Tests.csproj -c Release --no-restore + + - name: Run Lite fast tests + run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~AnomalyDetectorTests&FullyQualifiedName!~FactCollectorTests&FullyQualifiedName!~FactCollectorMiseryTests&FullyQualifiedName!~BaselineProviderTests&FullyQualifiedName!~InferenceEngineTests&FullyQualifiedName!~ScenarioTests&FullyQualifiedName!~AnalysisServiceTests" + + - name: Run Lite analysis-heavy tests + if: steps.filter.outputs.lite_analysis == 'true' || github.event_name == 'release' + run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~AnomalyDetectorTests|FullyQualifiedName~FactCollectorTests|FullyQualifiedName~FactCollectorMiseryTests|FullyQualifiedName~BaselineProviderTests|FullyQualifiedName~InferenceEngineTests|FullyQualifiedName~ScenarioTests|FullyQualifiedName~AnalysisServiceTests" + + - name: Run Installer tests + if: steps.filter.outputs.installer == 'true' || github.event_name == 'release' + run: dotnet test Installer.Tests/Installer.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~VersionDetectionTests&FullyQualifiedName!~IdempotencyTests&FullyQualifiedName!~AdversarialTests" - name: Get version id: version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index cc844581..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: CI - -on: - push: - branches: [dev] - pull_request: - branches: [dev] - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore dependencies - run: | - dotnet restore Dashboard/Dashboard.csproj - dotnet restore Lite/PerformanceMonitorLite.csproj - dotnet restore Installer/PerformanceMonitorInstaller.csproj - dotnet restore Lite.Tests/Lite.Tests.csproj - - - name: Build all projects - run: | - dotnet build Dashboard/Dashboard.csproj -c Release --no-restore - dotnet build Lite/PerformanceMonitorLite.csproj -c Release --no-restore - dotnet build Installer/PerformanceMonitorInstaller.csproj -c Release --no-restore - dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - - name: Run tests - run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9ee75bc1..ef0ef391 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -46,6 +46,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Set nightly version id: version @@ -59,10 +61,10 @@ jobs: - name: Restore dependencies run: | - dotnet restore Dashboard/Dashboard.csproj - dotnet restore Lite/PerformanceMonitorLite.csproj - dotnet restore Installer/PerformanceMonitorInstaller.csproj - dotnet restore Lite.Tests/Lite.Tests.csproj + dotnet restore Dashboard/Dashboard.csproj --locked-mode + dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode + dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode + dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - name: Run tests run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --verbosity normal diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a156bc..7def0f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.9.0] - TBD +## [2.10.0] - TBD + +### Fixed + +- **Memory tab tooltip** stops working after switching away and returning to the tab. Both Dashboard and Lite Memory tab crosshair tooltip handlers now reattach correctly on tab re-entry; the same popup-wedge fix is also applied to `CorrelatedCrosshairManager` ([#916]) +- **FinOps memory recommendation** now bases sizing on a 7-day P95 of memory samples instead of a single snapshot, so recommendations no longer swing based on instantaneous workload state. Applied in both Dashboard and Lite ([#917]) + +### Changed + +- **Per-database grants for FinOps Index Analysis** documented in the README — sp_IndexCleanup-backed Index Analysis requires per-database `EXECUTE` grants on each user database you want to analyze ([#915]) + +[#915]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/915 +[#916]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/916 +[#917]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/917 + +## [2.9.0] - 2026-04-29 ### Important diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index 738cd06c..297fa5f9 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -89,6 +89,16 @@ protected override void OnExit(ExitEventArgs e) private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { var exception = e.ExceptionObject as Exception; + + /* Silently swallow Hardcodet TrayToolTip race condition (issue #422) when it + escapes the Dispatcher path — happens during tray-Exit shutdown when the + Dispatcher's exception hooks are torn down before the tray library finishes. */ + if (exception != null && IsTrayToolTipCrash(exception)) + { + Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422) in AppDomain handler"); + return; + } + Logger.Fatal("Unhandled AppDomain Exception", exception ?? new Exception("Unknown exception")); if (e.IsTerminating) diff --git a/Dashboard/Controls/MemoryContent.CopyExport.cs b/Dashboard/Controls/MemoryContent.CopyExport.cs new file mode 100644 index 00000000..75b0d92a --- /dev/null +++ b/Dashboard/Controls/MemoryContent.CopyExport.cs @@ -0,0 +1,149 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Context Menu Handlers + + 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)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + 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); + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + 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(); + + // Add headers + 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)); + + // Add all rows + foreach (var item in dataGrid.Items) + { + sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); + } + + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(sb.ToString(), false); + } + } + } + + 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) + { + string prefix = "memory"; + + + var saveFileDialog = new SaveFileDialog + { + FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" + }; + + if (saveFileDialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + + // Add headers + 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)); + + // Add all rows + 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); + } + } + } + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryClerks.cs b/Dashboard/Controls/MemoryContent.MemoryClerks.cs new file mode 100644 index 00000000..2703cb52 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryClerks.cs @@ -0,0 +1,248 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Clerks + + private async System.Threading.Tasks.Task RefreshMemoryClerksAsync() + { + if (_databaseService == null) return; + + try + { + if (!MemoryClerksChart.Plot.GetPlottables().Any()) + { + MemoryClerksLoading.IsLoading = true; + MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; + } + + var clerkTypes = await _databaseService.GetDistinctMemoryClerkTypesAsync(_memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); + PopulateMemoryClerkPicker(clerkTypes); + await UpdateMemoryClerksChartFromPickerAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory clerks: {ex.Message}"); + } + finally + { + MemoryClerksLoading.IsLoading = false; + } + } + + 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() + { + if (_databaseService == null) return; + + try + { + var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); + + if (_legendPanels.TryGetValue(MemoryClerksChart, out var existingPanel) && existingPanel != null) + { + MemoryClerksChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryClerksChart] = null; + } + MemoryClerksChart.Plot.Clear(); + _memoryClerksHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryClerksChart); + + DateTime rangeEnd = _memoryClerksToDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = _memoryClerksFromDate ?? rangeEnd.AddHours(-_memoryClerksHoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (selected.Count > 0) + { + var selectedTypes = selected.Select(s => s.DisplayName).ToList(); + var data = await _databaseService.GetMemoryClerksByTypesAsync(selectedTypes, _memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); + var dataList = data.ToList(); + + MemoryClerksNoDataMessage.Visibility = dataList.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + + if (dataList.Count > 0) + { + var colors = TabHelpers.ChartColors; + int colorIndex = 0; + + foreach (var clerkType in selectedTypes) + { + var clerkData = dataList.Where(d => d.ClerkType == clerkType) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (clerkData.Count >= 1) + { + var timePoints = clerkData.Select(d => d.CollectionTime); + var values = clerkData.Select(d => (double)d.PagesMb); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = MemoryClerksChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = clerkType.Length > 20 ? clerkType.Substring(0, 20) + "..." : clerkType; + scatter.LegendText = label; + _memoryClerksHover?.Add(scatter, label); + colorIndex++; + } + } + + _legendPanels[MemoryClerksChart] = MemoryClerksChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryClerksChart.Plot.Legend.FontSize = 12; + } + + UpdateMemoryClerksSummaryPanel(dataList); + } + else + { + MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; + MemoryClerksTotalText.Text = "N/A"; + MemoryClerksTopText.Text = "N/A"; + } + + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryClerksChart.Plot.YLabel("MB"); + MemoryClerksChart.Plot.Axes.AutoScaleY(); + var clerksLimits = MemoryClerksChart.Plot.Axes.GetLimits(); + MemoryClerksChart.Plot.Axes.SetLimitsY(0, clerksLimits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryClerksChart); + MemoryClerksChart.Refresh(); + } + catch (Exception ex) + { + Logger.Error($"Error updating memory clerks chart: {ex.Message}"); + } + } + + private void UpdateMemoryClerksSummaryPanel(List dataList) + { + if (dataList == null || dataList.Count == 0) + { + MemoryClerksTotalText.Text = "N/A"; + MemoryClerksTopText.Text = "N/A"; + return; + } + + var latestTime = dataList.Max(d => d.CollectionTime); + var latestData = dataList + .Where(d => d.CollectionTime == latestTime) + .Where(d => d.ClerkType == null || !d.ClerkType.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var totalMb = latestData.Sum(d => d.PagesMb); + MemoryClerksTotalText.Text = string.Format(CultureInfo.CurrentCulture, "{0:N0} MB", totalMb); + + var topClerk = latestData.OrderByDescending(d => d.PagesMb).FirstOrDefault(); + if (topClerk != null) + { + var name = topClerk.ClerkType ?? "Unknown"; + if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(12); + if (name.Length > 20) name = name.Substring(0, 20) + "..."; + MemoryClerksTopText.Text = string.Format(CultureInfo.CurrentCulture, "{0} ({1:N0} MB)", name, topClerk.PagesMb); + } + else + { + MemoryClerksTopText.Text = "N/A"; + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryGrants.cs b/Dashboard/Controls/MemoryContent.MemoryGrants.cs new file mode 100644 index 00000000..3885cc7f --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryGrants.cs @@ -0,0 +1,239 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Grants + + private sealed class PoolGrantPoint + { + public DateTime CollectionTime { get; set; } + public int PoolId { get; set; } + public double AvailableMemoryMb { get; set; } + public double GrantedMemoryMb { get; set; } + public double UsedMemoryMb { get; set; } + public double GranteeCount { get; set; } + public double WaiterCount { get; set; } + public double TimeoutErrorCountDelta { get; set; } + public double ForcedGrantCountDelta { get; set; } + } + + private async System.Threading.Tasks.Task RefreshMemoryGrantsAsync() + { + if (_databaseService == null) return; + + try + { + if (!MemoryGrantSizingChart.Plot.GetPlottables().Any()) + { + MemoryGrantSizingLoading.IsLoading = true; + MemoryGrantSizingNoData.Visibility = Visibility.Collapsed; + MemoryGrantActivityNoData.Visibility = Visibility.Collapsed; + } + + var data = await _databaseService.GetMemoryGrantStatsAsync(_memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + var dataList = data.ToList(); + + bool hasData = dataList.Count > 0; + MemoryGrantSizingNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; + MemoryGrantActivityNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; + + // Aggregate across resource_semaphore_id within each pool + var aggregated = dataList + .GroupBy(d => new { d.CollectionTime, d.PoolId }) + .Select(g => new PoolGrantPoint + { + CollectionTime = g.Key.CollectionTime, + PoolId = g.Key.PoolId, + AvailableMemoryMb = g.Sum(x => (double)(x.AvailableMemoryMb ?? 0)), + GrantedMemoryMb = g.Sum(x => (double)(x.GrantedMemoryMb ?? 0)), + UsedMemoryMb = g.Sum(x => (double)(x.UsedMemoryMb ?? 0)), + GranteeCount = g.Sum(x => (double)(x.GranteeCount ?? 0)), + WaiterCount = g.Sum(x => (double)(x.WaiterCount ?? 0)), + TimeoutErrorCountDelta = g.Sum(x => (double)(x.TimeoutErrorCountDelta ?? 0)), + ForcedGrantCountDelta = g.Sum(x => (double)(x.ForcedGrantCountDelta ?? 0)) + }) + .OrderBy(d => d.CollectionTime) + .ToList(); + + LoadMemoryGrantSizingChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + LoadMemoryGrantActivityChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory grants: {ex.Message}"); + MemoryGrantSizingNoData.Visibility = Visibility.Visible; + MemoryGrantActivityNoData.Visibility = Visibility.Visible; + } + finally + { + MemoryGrantSizingLoading.IsLoading = false; + MemoryGrantActivityLoading.IsLoading = false; + } + } + + private void LoadMemoryGrantSizingChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryGrantSizingChart, out var existingPanel) && existingPanel != null) + { + MemoryGrantSizingChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryGrantSizingChart] = null; + } + MemoryGrantSizingChart.Plot.Clear(); + _memoryGrantSizingHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); + + var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); + int colorIndex = 0; + var colors = TabHelpers.ChartColors; + bool hasData = false; + + foreach (var poolId in poolIds) + { + var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + if (poolData.Count == 0) continue; + hasData = true; + + var metrics = new (string Name, Func Selector)[] { + ("Available MB", d => d.AvailableMemoryMb), + ("Granted MB", d => d.GrantedMemoryMb), + ("Used MB", d => d.UsedMemoryMb) + }; + + foreach (var (metricName, selector) in metrics) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + poolData.Select(d => d.CollectionTime), + poolData.Select(selector)); + + if (xs.Length > 0) + { + var scatter = MemoryGrantSizingChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = $"Pool {poolId}: {metricName}"; + scatter.LegendText = label; + _memoryGrantSizingHover?.Add(scatter, label); + colorIndex++; + } + } + } + + if (hasData) + { + _legendPanels[MemoryGrantSizingChart] = MemoryGrantSizingChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryGrantSizingChart.Plot.Legend.FontSize = 12; + } + + MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryGrantSizingChart.Plot.YLabel("MB"); + MemoryGrantSizingChart.Plot.Axes.AutoScaleY(); + var limits = MemoryGrantSizingChart.Plot.Axes.GetLimits(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryGrantSizingChart); + MemoryGrantSizingChart.Refresh(); + } + + private void LoadMemoryGrantActivityChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryGrantActivityChart, out var existingPanel) && existingPanel != null) + { + MemoryGrantActivityChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryGrantActivityChart] = null; + } + MemoryGrantActivityChart.Plot.Clear(); + _memoryGrantActivityHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); + + var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); + int colorIndex = 0; + var colors = TabHelpers.ChartColors; + bool hasData = false; + + foreach (var poolId in poolIds) + { + var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + if (poolData.Count == 0) continue; + hasData = true; + + var metrics = new (string Name, Func Selector)[] { + ("Grantees", d => d.GranteeCount), + ("Waiters", d => d.WaiterCount), + ("Timeouts", d => d.TimeoutErrorCountDelta), + ("Forced Grants", d => d.ForcedGrantCountDelta) + }; + + foreach (var (metricName, selector) in metrics) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + poolData.Select(d => d.CollectionTime), + poolData.Select(selector)); + + if (xs.Length > 0) + { + var scatter = MemoryGrantActivityChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = $"Pool {poolId}: {metricName}"; + scatter.LegendText = label; + _memoryGrantActivityHover?.Add(scatter, label); + colorIndex++; + } + } + } + + if (hasData) + { + _legendPanels[MemoryGrantActivityChart] = MemoryGrantActivityChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryGrantActivityChart.Plot.Legend.FontSize = 12; + } + + MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryGrantActivityChart.Plot.YLabel("Count"); + MemoryGrantActivityChart.Plot.Axes.AutoScaleY(); + var limits = MemoryGrantActivityChart.Plot.Axes.GetLimits(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryGrantActivityChart); + MemoryGrantActivityChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryPressure.cs b/Dashboard/Controls/MemoryContent.MemoryPressure.cs new file mode 100644 index 00000000..f401d620 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryPressure.cs @@ -0,0 +1,214 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Pressure Events + + private async System.Threading.Tasks.Task RefreshMemoryPressureEventsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetMemoryPressureEventsAsync(_memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); + LoadMemoryPressureEventsChart(data.ToList(), _memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory pressure events: {ex.Message}"); + } + } + + private void LoadMemoryPressureEventsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryPressureEventsChart, out var existingPanel) && existingPanel != null) + { + MemoryPressureEventsChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryPressureEventsChart] = null; + } + MemoryPressureEventsChart.Plot.Clear(); + _memoryPressureEventsHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); + + // Count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). + var dataList = data? + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList() ?? new List(); + + bool hasData = false; + int maxBarCount = 0; + + if (dataList.Count > 0) + { + var grouped = dataList + .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; + + // Four series: SQL Server medium, SQL Server severe (stacked on top of medium), + // OS medium, OS severe. Stacking uses ValueBase so severe bars sit on top of medium. + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + 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 + + 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.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; + } + + bool anyBars = sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 + || osMediumBars.Count > 0 || osSevereBars.Count > 0; + + if (anyBars) + { + 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)"); + } + + _legendPanels[MemoryPressureEventsChart] = MemoryPressureEventsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryPressureEventsChart.Plot.Legend.FontSize = 12; + } + } + + if (!hasData) + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No memory pressure events in selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); + + TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); + MemoryPressureEventsChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryStats.cs b/Dashboard/Controls/MemoryContent.MemoryStats.cs new file mode 100644 index 00000000..56c08fa8 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryStats.cs @@ -0,0 +1,253 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Stats + + private async System.Threading.Tasks.Task RefreshMemoryStatsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetMemoryStatsAsync(_memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); + var dataList = data.ToList(); + LoadMemoryStatsOverviewChart(dataList, _memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory stats: {ex.Message}"); + } + } + + private void LoadMemoryStatsOverviewChart(List memoryData, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryStatsOverviewChart, out var existingPanel) && existingPanel != null) + { + MemoryStatsOverviewChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryStatsOverviewChart] = null; + } + MemoryStatsOverviewChart.Plot.Clear(); + _memoryStatsOverviewHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); + + var dataList = memoryData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); + // Total Memory series with gap filling + var (totalXs, totalYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.TotalMemoryMb)); + + // Buffer Pool series with gap filling + var (bufferXs, bufferYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.BufferPoolMb)); + + // Plan Cache series with gap filling + var (cacheXs, cacheYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.PlanCacheMb)); + + // Available Physical Memory series with gap filling + var (availXs, availYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.AvailablePhysicalMemoryMb)); + + if (totalXs.Length > 0) + { + // Add pressure warning spans first (so they appear behind the lines) + AddPressureWarningSpans(dataList); + + var totalScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(totalXs, totalYs); + totalScatter.LineWidth = 2; + totalScatter.MarkerSize = 5; + totalScatter.Color = TabHelpers.ChartColors[9]; + totalScatter.LegendText = "Total Memory"; + _memoryStatsOverviewHover?.Add(totalScatter, "Total Memory"); + + var bufferScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(bufferXs, bufferYs); + bufferScatter.LineWidth = 2; + bufferScatter.MarkerSize = 5; + bufferScatter.Color = TabHelpers.ChartColors[0]; + bufferScatter.LegendText = "Buffer Pool"; + _memoryStatsOverviewHover?.Add(bufferScatter, "Buffer Pool"); + + var cacheScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(cacheXs, cacheYs); + cacheScatter.LineWidth = 2; + cacheScatter.MarkerSize = 5; + cacheScatter.Color = TabHelpers.ChartColors[1]; + cacheScatter.LegendText = "Plan Cache"; + _memoryStatsOverviewHover?.Add(cacheScatter, "Plan Cache"); + + var availScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(availXs, availYs); + availScatter.LineWidth = 2; + availScatter.MarkerSize = 5; + availScatter.Color = TabHelpers.ChartColors[2]; + availScatter.LegendText = "Available Physical"; + _memoryStatsOverviewHover?.Add(availScatter, "Available Physical"); + + _legendPanels[MemoryStatsOverviewChart] = MemoryStatsOverviewChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryStatsOverviewChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = MemoryStatsOverviewChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryStatsOverviewChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryStatsOverviewChart.Plot.YLabel("MB"); + // Fixed negative space for legend + MemoryStatsOverviewChart.Plot.Axes.AutoScaleY(); + var memOverviewLimits = MemoryStatsOverviewChart.Plot.Axes.GetLimits(); + MemoryStatsOverviewChart.Plot.Axes.SetLimitsY(0, memOverviewLimits.Top * 1.05); + + TabHelpers.LockChartVerticalAxis(MemoryStatsOverviewChart); + MemoryStatsOverviewChart.Refresh(); + + // Update summary panel + UpdateMemoryStatsSummaryPanel(dataList); + } + + private void AddPressureWarningSpans(List dataList) + { + // Track whether we've added legend entries (only want one per type) + bool bpLegendAdded = false; + bool pcLegendAdded = false; + + // Find time ranges where pressure warnings are active + foreach (var item in dataList) + { + if (item.BufferPoolPressureWarning || item.PlanCachePressureWarning) + { + // Add a vertical line at this time point to indicate pressure + var x = item.CollectionTime.ToOADate(); + var vline = MemoryStatsOverviewChart.Plot.Add.VerticalLine(x); + vline.LineWidth = 1; + vline.LinePattern = ScottPlot.LinePattern.Dotted; + + if (item.BufferPoolPressureWarning && item.PlanCachePressureWarning) + { + vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.5); + // Add legend entry for BP pressure (covers "both" case too) + if (!bpLegendAdded) + { + vline.LegendText = "BP Pressure"; + bpLegendAdded = true; + } + } + else if (item.BufferPoolPressureWarning) + { + vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.3); + if (!bpLegendAdded) + { + vline.LegendText = "BP Pressure"; + bpLegendAdded = true; + } + } + else + { + vline.Color = TabHelpers.ChartColors[2].WithAlpha(0.3); + if (!pcLegendAdded) + { + vline.LegendText = "PC Pressure"; + pcLegendAdded = true; + } + } + } + } + } + + private void UpdateMemoryStatsSummaryPanel(List dataList) + { + if (dataList == null || dataList.Count == 0) + { + MemoryStatsPhysicalText.Text = "N/A"; + MemoryStatsSqlServerText.Text = "N/A"; + MemoryStatsTargetText.Text = "N/A"; + MemoryStatsBPPercentText.Text = "N/A"; + MemoryStatsPCPercentText.Text = "N/A"; + MemoryStatsUtilPercentText.Text = "N/A"; + MemoryStatsPressureText.Text = "None"; + MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + return; + } + + // Use the most recent data point + var latest = dataList.OrderByDescending(d => d.CollectionTime).First(); + + // Absolute GB values + MemoryStatsPhysicalText.Text = latest.TotalPhysicalMemoryMb.HasValue + ? $"{latest.TotalPhysicalMemoryMb.Value / 1024.0m:F1} GB" + : "N/A"; + + MemoryStatsSqlServerText.Text = $"{latest.PhysicalMemoryInUseMb / 1024.0m:F1} GB"; + + MemoryStatsTargetText.Text = latest.CommittedTargetMemoryMb.HasValue + ? $"{latest.CommittedTargetMemoryMb.Value / 1024.0m:F1} GB" + : "N/A"; + + // Buffer Pool and Plan Cache with GB and percentage + MemoryStatsBPPercentText.Text = latest.BufferPoolPercentage.HasValue + ? $"{latest.BufferPoolMb / 1024.0m:F1} GB ({latest.BufferPoolPercentage:F1}%)" + : $"{latest.BufferPoolMb / 1024.0m:F1} GB"; + + MemoryStatsPCPercentText.Text = latest.PlanCachePercentage.HasValue + ? $"{latest.PlanCacheMb / 1024.0m:F1} GB ({latest.PlanCachePercentage:F1}%)" + : $"{latest.PlanCacheMb / 1024.0m:F1} GB"; + + MemoryStatsUtilPercentText.Text = $"{latest.MemoryUtilizationPercentage}%"; + + // Build pressure status text + var pressures = new List(); + if (latest.BufferPoolPressureWarning) pressures.Add("BP"); + if (latest.PlanCachePressureWarning) pressures.Add("PC"); + + if (pressures.Count > 0) + { + MemoryStatsPressureText.Text = string.Join(", ", pressures); + MemoryStatsPressureText.Foreground = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(0xFF, 0x69, 0x69)); // Light red + } + else + { + MemoryStatsPressureText.Text = "None"; + MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.PlanCache.cs b/Dashboard/Controls/MemoryContent.PlanCache.cs new file mode 100644 index 00000000..6560b395 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.PlanCache.cs @@ -0,0 +1,170 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Plan Cache + + private async System.Threading.Tasks.Task RefreshPlanCacheAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetPlanCacheStatsAsync(_planCacheHoursBack, _planCacheFromDate, _planCacheToDate); + LoadPlanCacheChart(data.ToList(), _planCacheHoursBack, _planCacheFromDate, _planCacheToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading plan cache stats: {ex.Message}"); + } + } + + private void LoadPlanCacheChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(PlanCacheChart, out var existingPanel) && existingPanel != null) + { + PlanCacheChart.Plot.Axes.Remove(existingPanel); + _legendPanels[PlanCacheChart] = null; + } + PlanCacheChart.Plot.Clear(); + _planCacheHover?.Clear(); + TabHelpers.ApplyThemeToChart(PlanCacheChart); + + var dataList = data?.ToList() ?? new List(); + if (dataList.Count > 0) + { + // Group by collection time and get single-use vs multi-use sizes + var grouped = dataList.GroupBy(d => d.CollectionTime) + .Select(g => new { + Time = g.Key, + SingleUseSizeMb = g.Sum(x => x.SingleUseSizeMb), + MultiUseSizeMb = g.Sum(x => x.MultiUseSizeMb) + }) + .OrderBy(x => x.Time) + .ToList(); + + if (grouped.Count > 0) + { + // Single-Use series with gap filling + var (singleXs, singleYs) = TabHelpers.FillTimeSeriesGaps( + grouped.Select(d => d.Time), + grouped.Select(d => (double)d.SingleUseSizeMb)); + + var singleScatter = PlanCacheChart.Plot.Add.Scatter(singleXs, singleYs); + singleScatter.LineWidth = 2; + singleScatter.MarkerSize = 5; + singleScatter.Color = TabHelpers.ChartColors[3]; + singleScatter.LegendText = "Single-Use"; + _planCacheHover?.Add(singleScatter, "Single-Use"); + + // Multi-Use series with gap filling + var (multiXs, multiYs) = TabHelpers.FillTimeSeriesGaps( + grouped.Select(d => d.Time), + grouped.Select(d => (double)d.MultiUseSizeMb)); + + var multiScatter = PlanCacheChart.Plot.Add.Scatter(multiXs, multiYs); + multiScatter.LineWidth = 2; + multiScatter.MarkerSize = 5; + multiScatter.Color = TabHelpers.ChartColors[1]; + multiScatter.LegendText = "Multi-Use"; + _planCacheHover?.Add(multiScatter, "Multi-Use"); + + _legendPanels[PlanCacheChart] = PlanCacheChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + PlanCacheChart.Plot.Legend.FontSize = 12; + } + + // Update summary panel with latest data point + var latestOldestPlan = dataList + .Where(d => d.OldestPlanCreateTime.HasValue) + .OrderByDescending(d => d.CollectionTime) + .FirstOrDefault(); + var latestTime = dataList.Max(d => d.CollectionTime); + int totalPlans = dataList.Where(d => d.CollectionTime == latestTime).Sum(d => d.TotalPlans); + UpdatePlanCacheSummary(latestOldestPlan, totalPlans); + } + else + { + UpdatePlanCacheSummary(null, 0); + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = PlanCacheChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); + PlanCacheChart.Plot.Axes.SetLimitsX(xMin, xMax); + PlanCacheChart.Plot.YLabel("MB"); + // Fixed negative space for legend + PlanCacheChart.Plot.Axes.AutoScaleY(); + var planCacheLimits = PlanCacheChart.Plot.Axes.GetLimits(); + PlanCacheChart.Plot.Axes.SetLimitsY(0, planCacheLimits.Top * 1.05); + + TabHelpers.LockChartVerticalAxis(PlanCacheChart); + PlanCacheChart.Refresh(); + } + + private void UpdatePlanCacheSummary(PlanCacheStatsItem? oldestPlanData, int totalPlans) + { + if (oldestPlanData?.OldestPlanCreateTime != null) + { + var age = ServerTimeHelper.ServerNow - oldestPlanData.OldestPlanCreateTime.Value; + string ageText; + if (age.TotalDays >= 1) + ageText = $"{age.Days}d {age.Hours}h"; + else if (age.TotalHours >= 1) + ageText = $"{age.Hours}h {age.Minutes}m"; + else + ageText = $"{age.Minutes}m"; + + PlanCacheOldestPlanText.Text = ageText; + + // Color code based on age - older is better (more stable) + if (age.TotalHours < 1) + PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed); + else if (age.TotalHours < 24) + PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Orange); + else + PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + else + { + PlanCacheOldestPlanText.Text = "N/A"; + PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + + PlanCacheTotalPlansText.Text = totalPlans > 0 ? totalPlans.ToString("N0", CultureInfo.CurrentCulture) : "N/A"; + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index dbe405ea..1ce108ca 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -1,1356 +1,256 @@ -/* - * 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.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Data; -using Microsoft.Win32; -using PerformanceMonitorDashboard.Helpers; -using PerformanceMonitorDashboard.Models; -using PerformanceMonitorDashboard.Services; - -namespace PerformanceMonitorDashboard.Controls -{ - /// - /// UserControl for the Memory tab content. - /// Displays memory stats, grants, clerks, and plan cache analysis. - /// - public partial class MemoryContent : UserControl - { - public event Action? ChartDrillDownRequested; - - private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, - Func hoverGetter, string label, string chartType) - { - menu.Items.Insert(0, new Separator()); - var item = new MenuItem { Header = label }; - menu.Items.Insert(0, item); - - menu.Opened += (s, _) => - { - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = hoverGetter()?.GetNearestSeries(pos); - item.Tag = nearest?.Time; - item.IsEnabled = nearest.HasValue; - }; - - item.Click += (s, _) => - { - if (item.Tag is DateTime time) - ChartDrillDownRequested?.Invoke(chartType, time); - }; - } - - private DatabaseService? _databaseService; - - // Memory Stats state - private int _memoryStatsHoursBack = 24; - private DateTime? _memoryStatsFromDate; - private DateTime? _memoryStatsToDate; - - // Memory Grants state - private int _memoryGrantsHoursBack = 24; - private DateTime? _memoryGrantsFromDate; - private DateTime? _memoryGrantsToDate; - - // Memory Clerks state - private int _memoryClerksHoursBack = 24; - private DateTime? _memoryClerksFromDate; - private DateTime? _memoryClerksToDate; - - // Plan Cache state - private int _planCacheHoursBack = 24; - private DateTime? _planCacheFromDate; - private DateTime? _planCacheToDate; - - // Memory Pressure Events state - private int _memoryPressureEventsHoursBack = 24; - private DateTime? _memoryPressureEventsFromDate; - private DateTime? _memoryPressureEventsToDate; - - // Filter state dictionaries removed - no more grids with filters in this control - - // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) - private Dictionary _legendPanels = new(); - - // Memory Clerks picker state - private List _memoryClerkItems = new(); - private bool _isUpdatingMemoryClerkSelection; - - // Chart hover tooltips - private Helpers.ChartHoverHelper? _memoryStatsOverviewHover; - private Helpers.ChartHoverHelper? _memoryGrantSizingHover; - private Helpers.ChartHoverHelper? _memoryGrantActivityHover; - private Helpers.ChartHoverHelper? _memoryClerksHover; - private Helpers.ChartHoverHelper? _planCacheHover; - private Helpers.ChartHoverHelper? _memoryPressureEventsHover; - - // No DataGrids with filters - all tabs are chart-only - - public MemoryContent() - { - InitializeComponent(); - SetupChartContextMenus(); - Loaded += OnLoaded; - Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => - { - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - DisposeChartHelpers(); - }; - - // Apply dark theme immediately so charts don't flash white before data loads - TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); - TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); - TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); - TabHelpers.ApplyThemeToChart(MemoryClerksChart); - TabHelpers.ApplyThemeToChart(PlanCacheChart); - TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - - _memoryStatsOverviewHover = new Helpers.ChartHoverHelper(MemoryStatsOverviewChart, "MB"); - _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); - _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, "count"); - _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); - _planCacheHover = new Helpers.ChartHoverHelper(PlanCacheChart, "MB"); - _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); - } - - public void DisposeChartHelpers() - { - _memoryStatsOverviewHover?.Dispose(); - _memoryGrantSizingHover?.Dispose(); - _memoryGrantActivityHover?.Dispose(); - _memoryClerksHover?.Dispose(); - _planCacheHover?.Dispose(); - _memoryPressureEventsHover?.Dispose(); - } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - // No grids to configure - all tabs are chart-only now - } - - 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) - { - Helpers.TabHelpers.ApplyThemeToChart(chart); - chart.Refresh(); - } - } - } - - private void SetupChartContextMenus() - { - // Memory Stats Overview chart - var memOverviewMenu = TabHelpers.SetupChartContextMenu(MemoryStatsOverviewChart, "Memory_Stats_Overview", "collect.memory_stats"); - AddDrillDown(MemoryStatsOverviewChart, memOverviewMenu, () => _memoryStatsOverviewHover, "Show Active Queries at This Time", "Memory"); - - // Memory Grant charts - var grantSizingMenu = TabHelpers.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing", "collect.memory_grant_stats"); - AddDrillDown(MemoryGrantSizingChart, grantSizingMenu, () => _memoryGrantSizingHover, "Show Active Queries at This Time", "MemoryGrant"); - var grantActivityMenu = TabHelpers.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity", "collect.memory_grant_stats"); - AddDrillDown(MemoryGrantActivityChart, grantActivityMenu, () => _memoryGrantActivityHover, "Show Active Queries at This Time", "MemoryGrant"); - - // Memory Clerks chart - var clerksMenu = TabHelpers.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks", "collect.memory_clerks_stats"); - AddDrillDown(MemoryClerksChart, clerksMenu, () => _memoryClerksHover, "Show Active Queries at This Time", "MemoryClerks"); - - // Plan Cache chart - TabHelpers.SetupChartContextMenu(PlanCacheChart, "Plan_Cache", "collect.plan_cache_stats"); - - // Memory Pressure Events chart - var pressureMenu = TabHelpers.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events", "collect.memory_pressure_events"); - AddDrillDown(MemoryPressureEventsChart, pressureMenu, () => _memoryPressureEventsHover, "Show Active Queries at This Time", "MemoryPressure"); - } - - /// - /// Initializes the control with required dependencies. - /// - public void Initialize(DatabaseService databaseService) - { - _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); - } - - /// - /// Sets the time range for all memory sub-tabs. - /// - public void SetTimeRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) - { - _memoryStatsHoursBack = hoursBack; - _memoryStatsFromDate = fromDate; - _memoryStatsToDate = toDate; - - _memoryGrantsHoursBack = hoursBack; - _memoryGrantsFromDate = fromDate; - _memoryGrantsToDate = toDate; - - _memoryClerksHoursBack = hoursBack; - _memoryClerksFromDate = fromDate; - _memoryClerksToDate = toDate; - - _planCacheHoursBack = hoursBack; - _planCacheFromDate = fromDate; - _planCacheToDate = toDate; - - _memoryPressureEventsHoursBack = hoursBack; - _memoryPressureEventsFromDate = fromDate; - _memoryPressureEventsToDate = toDate; - } - - /// - /// Refreshes memory data. When fullRefresh is false, only the visible sub-tab is refreshed. - /// - public async Task RefreshAllDataAsync(bool fullRefresh = true) - { - try - { - using var _ = Helpers.MethodProfiler.StartTiming("Memory"); - - if (fullRefresh) - { - // Run all independent refreshes in parallel for initial load / manual refresh - await Task.WhenAll( - RefreshMemoryStatsAsync(), - RefreshMemoryGrantsAsync(), - RefreshMemoryClerksAsync(), - RefreshPlanCacheAsync(), - RefreshMemoryPressureEventsAsync() - ); - } - else - { - // Only refresh the visible sub-tab - switch (SubTabControl.SelectedIndex) - { - case 0: await RefreshMemoryStatsAsync(); break; - case 1: await RefreshMemoryGrantsAsync(); break; - case 2: await RefreshMemoryClerksAsync(); break; - case 3: await RefreshPlanCacheAsync(); break; - case 4: await RefreshMemoryPressureEventsAsync(); break; - } - } - } - catch (Exception ex) - { - Logger.Error($"Error refreshing Memory data: {ex.Message}", ex); - } - } - - #region Memory Stats - - private async System.Threading.Tasks.Task RefreshMemoryStatsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetMemoryStatsAsync(_memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); - var dataList = data.ToList(); - LoadMemoryStatsOverviewChart(dataList, _memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory stats: {ex.Message}"); - } - } - - private void LoadMemoryStatsOverviewChart(List memoryData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryStatsOverviewChart, out var existingPanel) && existingPanel != null) - { - MemoryStatsOverviewChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryStatsOverviewChart] = null; - } - MemoryStatsOverviewChart.Plot.Clear(); - _memoryStatsOverviewHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); - - var dataList = memoryData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - // Total Memory series with gap filling - var (totalXs, totalYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.TotalMemoryMb)); - - // Buffer Pool series with gap filling - var (bufferXs, bufferYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.BufferPoolMb)); - - // Plan Cache series with gap filling - var (cacheXs, cacheYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.PlanCacheMb)); - - // Available Physical Memory series with gap filling - var (availXs, availYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.AvailablePhysicalMemoryMb)); - - if (totalXs.Length > 0) - { - // Add pressure warning spans first (so they appear behind the lines) - AddPressureWarningSpans(dataList); - - var totalScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(totalXs, totalYs); - totalScatter.LineWidth = 2; - totalScatter.MarkerSize = 5; - totalScatter.Color = TabHelpers.ChartColors[9]; - totalScatter.LegendText = "Total Memory"; - _memoryStatsOverviewHover?.Add(totalScatter, "Total Memory"); - - var bufferScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(bufferXs, bufferYs); - bufferScatter.LineWidth = 2; - bufferScatter.MarkerSize = 5; - bufferScatter.Color = TabHelpers.ChartColors[0]; - bufferScatter.LegendText = "Buffer Pool"; - _memoryStatsOverviewHover?.Add(bufferScatter, "Buffer Pool"); - - var cacheScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(cacheXs, cacheYs); - cacheScatter.LineWidth = 2; - cacheScatter.MarkerSize = 5; - cacheScatter.Color = TabHelpers.ChartColors[1]; - cacheScatter.LegendText = "Plan Cache"; - _memoryStatsOverviewHover?.Add(cacheScatter, "Plan Cache"); - - var availScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(availXs, availYs); - availScatter.LineWidth = 2; - availScatter.MarkerSize = 5; - availScatter.Color = TabHelpers.ChartColors[2]; - availScatter.LegendText = "Available Physical"; - _memoryStatsOverviewHover?.Add(availScatter, "Available Physical"); - - _legendPanels[MemoryStatsOverviewChart] = MemoryStatsOverviewChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryStatsOverviewChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = MemoryStatsOverviewChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryStatsOverviewChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryStatsOverviewChart.Plot.YLabel("MB"); - // Fixed negative space for legend - MemoryStatsOverviewChart.Plot.Axes.AutoScaleY(); - var memOverviewLimits = MemoryStatsOverviewChart.Plot.Axes.GetLimits(); - MemoryStatsOverviewChart.Plot.Axes.SetLimitsY(0, memOverviewLimits.Top * 1.05); - - TabHelpers.LockChartVerticalAxis(MemoryStatsOverviewChart); - MemoryStatsOverviewChart.Refresh(); - - // Update summary panel - UpdateMemoryStatsSummaryPanel(dataList); - } - - private void AddPressureWarningSpans(List dataList) - { - // Track whether we've added legend entries (only want one per type) - bool bpLegendAdded = false; - bool pcLegendAdded = false; - - // Find time ranges where pressure warnings are active - foreach (var item in dataList) - { - if (item.BufferPoolPressureWarning || item.PlanCachePressureWarning) - { - // Add a vertical line at this time point to indicate pressure - var x = item.CollectionTime.ToOADate(); - var vline = MemoryStatsOverviewChart.Plot.Add.VerticalLine(x); - vline.LineWidth = 1; - vline.LinePattern = ScottPlot.LinePattern.Dotted; - - if (item.BufferPoolPressureWarning && item.PlanCachePressureWarning) - { - vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.5); - // Add legend entry for BP pressure (covers "both" case too) - if (!bpLegendAdded) - { - vline.LegendText = "BP Pressure"; - bpLegendAdded = true; - } - } - else if (item.BufferPoolPressureWarning) - { - vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.3); - if (!bpLegendAdded) - { - vline.LegendText = "BP Pressure"; - bpLegendAdded = true; - } - } - else - { - vline.Color = TabHelpers.ChartColors[2].WithAlpha(0.3); - if (!pcLegendAdded) - { - vline.LegendText = "PC Pressure"; - pcLegendAdded = true; - } - } - } - } - } - - private void UpdateMemoryStatsSummaryPanel(List dataList) - { - if (dataList == null || dataList.Count == 0) - { - MemoryStatsPhysicalText.Text = "N/A"; - MemoryStatsSqlServerText.Text = "N/A"; - MemoryStatsTargetText.Text = "N/A"; - MemoryStatsBPPercentText.Text = "N/A"; - MemoryStatsPCPercentText.Text = "N/A"; - MemoryStatsUtilPercentText.Text = "N/A"; - MemoryStatsPressureText.Text = "None"; - MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - return; - } - - // Use the most recent data point - var latest = dataList.OrderByDescending(d => d.CollectionTime).First(); - - // Absolute GB values - MemoryStatsPhysicalText.Text = latest.TotalPhysicalMemoryMb.HasValue - ? $"{latest.TotalPhysicalMemoryMb.Value / 1024.0m:F1} GB" - : "N/A"; - - MemoryStatsSqlServerText.Text = $"{latest.PhysicalMemoryInUseMb / 1024.0m:F1} GB"; - - MemoryStatsTargetText.Text = latest.CommittedTargetMemoryMb.HasValue - ? $"{latest.CommittedTargetMemoryMb.Value / 1024.0m:F1} GB" - : "N/A"; - - // Buffer Pool and Plan Cache with GB and percentage - MemoryStatsBPPercentText.Text = latest.BufferPoolPercentage.HasValue - ? $"{latest.BufferPoolMb / 1024.0m:F1} GB ({latest.BufferPoolPercentage:F1}%)" - : $"{latest.BufferPoolMb / 1024.0m:F1} GB"; - - MemoryStatsPCPercentText.Text = latest.PlanCachePercentage.HasValue - ? $"{latest.PlanCacheMb / 1024.0m:F1} GB ({latest.PlanCachePercentage:F1}%)" - : $"{latest.PlanCacheMb / 1024.0m:F1} GB"; - - MemoryStatsUtilPercentText.Text = $"{latest.MemoryUtilizationPercentage}%"; - - // Build pressure status text - var pressures = new List(); - if (latest.BufferPoolPressureWarning) pressures.Add("BP"); - if (latest.PlanCachePressureWarning) pressures.Add("PC"); - - if (pressures.Count > 0) - { - MemoryStatsPressureText.Text = string.Join(", ", pressures); - MemoryStatsPressureText.Foreground = new System.Windows.Media.SolidColorBrush( - System.Windows.Media.Color.FromRgb(0xFF, 0x69, 0x69)); // Light red - } - else - { - MemoryStatsPressureText.Text = "None"; - MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - } - - #endregion - - #region Memory Grants - - private sealed class PoolGrantPoint - { - public DateTime CollectionTime { get; set; } - public int PoolId { get; set; } - public double AvailableMemoryMb { get; set; } - public double GrantedMemoryMb { get; set; } - public double UsedMemoryMb { get; set; } - public double GranteeCount { get; set; } - public double WaiterCount { get; set; } - public double TimeoutErrorCountDelta { get; set; } - public double ForcedGrantCountDelta { get; set; } - } - - private async System.Threading.Tasks.Task RefreshMemoryGrantsAsync() - { - if (_databaseService == null) return; - - try - { - if (!MemoryGrantSizingChart.Plot.GetPlottables().Any()) - { - MemoryGrantSizingLoading.IsLoading = true; - MemoryGrantSizingNoData.Visibility = Visibility.Collapsed; - MemoryGrantActivityNoData.Visibility = Visibility.Collapsed; - } - - var data = await _databaseService.GetMemoryGrantStatsAsync(_memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - var dataList = data.ToList(); - - bool hasData = dataList.Count > 0; - MemoryGrantSizingNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; - MemoryGrantActivityNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; - - // Aggregate across resource_semaphore_id within each pool - var aggregated = dataList - .GroupBy(d => new { d.CollectionTime, d.PoolId }) - .Select(g => new PoolGrantPoint - { - CollectionTime = g.Key.CollectionTime, - PoolId = g.Key.PoolId, - AvailableMemoryMb = g.Sum(x => (double)(x.AvailableMemoryMb ?? 0)), - GrantedMemoryMb = g.Sum(x => (double)(x.GrantedMemoryMb ?? 0)), - UsedMemoryMb = g.Sum(x => (double)(x.UsedMemoryMb ?? 0)), - GranteeCount = g.Sum(x => (double)(x.GranteeCount ?? 0)), - WaiterCount = g.Sum(x => (double)(x.WaiterCount ?? 0)), - TimeoutErrorCountDelta = g.Sum(x => (double)(x.TimeoutErrorCountDelta ?? 0)), - ForcedGrantCountDelta = g.Sum(x => (double)(x.ForcedGrantCountDelta ?? 0)) - }) - .OrderBy(d => d.CollectionTime) - .ToList(); - - LoadMemoryGrantSizingChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - LoadMemoryGrantActivityChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory grants: {ex.Message}"); - MemoryGrantSizingNoData.Visibility = Visibility.Visible; - MemoryGrantActivityNoData.Visibility = Visibility.Visible; - } - finally - { - MemoryGrantSizingLoading.IsLoading = false; - MemoryGrantActivityLoading.IsLoading = false; - } - } - - private void LoadMemoryGrantSizingChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryGrantSizingChart, out var existingPanel) && existingPanel != null) - { - MemoryGrantSizingChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryGrantSizingChart] = null; - } - MemoryGrantSizingChart.Plot.Clear(); - _memoryGrantSizingHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); - - var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); - int colorIndex = 0; - var colors = TabHelpers.ChartColors; - bool hasData = false; - - foreach (var poolId in poolIds) - { - var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - if (poolData.Count == 0) continue; - hasData = true; - - var metrics = new (string Name, Func Selector)[] { - ("Available MB", d => d.AvailableMemoryMb), - ("Granted MB", d => d.GrantedMemoryMb), - ("Used MB", d => d.UsedMemoryMb) - }; - - foreach (var (metricName, selector) in metrics) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps( - poolData.Select(d => d.CollectionTime), - poolData.Select(selector)); - - if (xs.Length > 0) - { - var scatter = MemoryGrantSizingChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = $"Pool {poolId}: {metricName}"; - scatter.LegendText = label; - _memoryGrantSizingHover?.Add(scatter, label); - colorIndex++; - } - } - } - - if (hasData) - { - _legendPanels[MemoryGrantSizingChart] = MemoryGrantSizingChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryGrantSizingChart.Plot.Legend.FontSize = 12; - } - - MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryGrantSizingChart.Plot.YLabel("MB"); - MemoryGrantSizingChart.Plot.Axes.AutoScaleY(); - var limits = MemoryGrantSizingChart.Plot.Axes.GetLimits(); - MemoryGrantSizingChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryGrantSizingChart); - MemoryGrantSizingChart.Refresh(); - } - - private void LoadMemoryGrantActivityChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryGrantActivityChart, out var existingPanel) && existingPanel != null) - { - MemoryGrantActivityChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryGrantActivityChart] = null; - } - MemoryGrantActivityChart.Plot.Clear(); - _memoryGrantActivityHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); - - var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); - int colorIndex = 0; - var colors = TabHelpers.ChartColors; - bool hasData = false; - - foreach (var poolId in poolIds) - { - var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - if (poolData.Count == 0) continue; - hasData = true; - - var metrics = new (string Name, Func Selector)[] { - ("Grantees", d => d.GranteeCount), - ("Waiters", d => d.WaiterCount), - ("Timeouts", d => d.TimeoutErrorCountDelta), - ("Forced Grants", d => d.ForcedGrantCountDelta) - }; - - foreach (var (metricName, selector) in metrics) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps( - poolData.Select(d => d.CollectionTime), - poolData.Select(selector)); - - if (xs.Length > 0) - { - var scatter = MemoryGrantActivityChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = $"Pool {poolId}: {metricName}"; - scatter.LegendText = label; - _memoryGrantActivityHover?.Add(scatter, label); - colorIndex++; - } - } - } - - if (hasData) - { - _legendPanels[MemoryGrantActivityChart] = MemoryGrantActivityChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryGrantActivityChart.Plot.Legend.FontSize = 12; - } - - MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryGrantActivityChart.Plot.YLabel("Count"); - MemoryGrantActivityChart.Plot.Axes.AutoScaleY(); - var limits = MemoryGrantActivityChart.Plot.Axes.GetLimits(); - MemoryGrantActivityChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryGrantActivityChart); - MemoryGrantActivityChart.Refresh(); - } - - #endregion - - #region Memory Clerks - - private async System.Threading.Tasks.Task RefreshMemoryClerksAsync() - { - if (_databaseService == null) return; - - try - { - if (!MemoryClerksChart.Plot.GetPlottables().Any()) - { - MemoryClerksLoading.IsLoading = true; - MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; - } - - var clerkTypes = await _databaseService.GetDistinctMemoryClerkTypesAsync(_memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); - PopulateMemoryClerkPicker(clerkTypes); - await UpdateMemoryClerksChartFromPickerAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory clerks: {ex.Message}"); - } - finally - { - MemoryClerksLoading.IsLoading = false; - } - } - - 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() - { - if (_databaseService == null) return; - - try - { - var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); - - if (_legendPanels.TryGetValue(MemoryClerksChart, out var existingPanel) && existingPanel != null) - { - MemoryClerksChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryClerksChart] = null; - } - MemoryClerksChart.Plot.Clear(); - _memoryClerksHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryClerksChart); - - DateTime rangeEnd = _memoryClerksToDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = _memoryClerksFromDate ?? rangeEnd.AddHours(-_memoryClerksHoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (selected.Count > 0) - { - var selectedTypes = selected.Select(s => s.DisplayName).ToList(); - var data = await _databaseService.GetMemoryClerksByTypesAsync(selectedTypes, _memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); - var dataList = data.ToList(); - - MemoryClerksNoDataMessage.Visibility = dataList.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - - if (dataList.Count > 0) - { - var colors = TabHelpers.ChartColors; - int colorIndex = 0; - - foreach (var clerkType in selectedTypes) - { - var clerkData = dataList.Where(d => d.ClerkType == clerkType) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (clerkData.Count >= 1) - { - var timePoints = clerkData.Select(d => d.CollectionTime); - var values = clerkData.Select(d => (double)d.PagesMb); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = MemoryClerksChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = clerkType.Length > 20 ? clerkType.Substring(0, 20) + "..." : clerkType; - scatter.LegendText = label; - _memoryClerksHover?.Add(scatter, label); - colorIndex++; - } - } - - _legendPanels[MemoryClerksChart] = MemoryClerksChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryClerksChart.Plot.Legend.FontSize = 12; - } - - UpdateMemoryClerksSummaryPanel(dataList); - } - else - { - MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; - MemoryClerksTotalText.Text = "N/A"; - MemoryClerksTopText.Text = "N/A"; - } - - MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryClerksChart.Plot.YLabel("MB"); - MemoryClerksChart.Plot.Axes.AutoScaleY(); - var clerksLimits = MemoryClerksChart.Plot.Axes.GetLimits(); - MemoryClerksChart.Plot.Axes.SetLimitsY(0, clerksLimits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryClerksChart); - MemoryClerksChart.Refresh(); - } - catch (Exception ex) - { - Logger.Error($"Error updating memory clerks chart: {ex.Message}"); - } - } - - private void UpdateMemoryClerksSummaryPanel(List dataList) - { - if (dataList == null || dataList.Count == 0) - { - MemoryClerksTotalText.Text = "N/A"; - MemoryClerksTopText.Text = "N/A"; - return; - } - - var latestTime = dataList.Max(d => d.CollectionTime); - var latestData = dataList - .Where(d => d.CollectionTime == latestTime) - .Where(d => d.ClerkType == null || !d.ClerkType.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - var totalMb = latestData.Sum(d => d.PagesMb); - MemoryClerksTotalText.Text = string.Format(CultureInfo.CurrentCulture, "{0:N0} MB", totalMb); - - var topClerk = latestData.OrderByDescending(d => d.PagesMb).FirstOrDefault(); - if (topClerk != null) - { - var name = topClerk.ClerkType ?? "Unknown"; - if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) - name = name.Substring(12); - if (name.Length > 20) name = name.Substring(0, 20) + "..."; - MemoryClerksTopText.Text = string.Format(CultureInfo.CurrentCulture, "{0} ({1:N0} MB)", name, topClerk.PagesMb); - } - else - { - MemoryClerksTopText.Text = "N/A"; - } - } - - #endregion - - #region Plan Cache - - private async System.Threading.Tasks.Task RefreshPlanCacheAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetPlanCacheStatsAsync(_planCacheHoursBack, _planCacheFromDate, _planCacheToDate); - LoadPlanCacheChart(data.ToList(), _planCacheHoursBack, _planCacheFromDate, _planCacheToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading plan cache stats: {ex.Message}"); - } - } - - private void LoadPlanCacheChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(PlanCacheChart, out var existingPanel) && existingPanel != null) - { - PlanCacheChart.Plot.Axes.Remove(existingPanel); - _legendPanels[PlanCacheChart] = null; - } - PlanCacheChart.Plot.Clear(); - _planCacheHover?.Clear(); - TabHelpers.ApplyThemeToChart(PlanCacheChart); - - var dataList = data?.ToList() ?? new List(); - if (dataList.Count > 0) - { - // Group by collection time and get single-use vs multi-use sizes - var grouped = dataList.GroupBy(d => d.CollectionTime) - .Select(g => new { - Time = g.Key, - SingleUseSizeMb = g.Sum(x => x.SingleUseSizeMb), - MultiUseSizeMb = g.Sum(x => x.MultiUseSizeMb) - }) - .OrderBy(x => x.Time) - .ToList(); - - if (grouped.Count > 0) - { - // Single-Use series with gap filling - var (singleXs, singleYs) = TabHelpers.FillTimeSeriesGaps( - grouped.Select(d => d.Time), - grouped.Select(d => (double)d.SingleUseSizeMb)); - - var singleScatter = PlanCacheChart.Plot.Add.Scatter(singleXs, singleYs); - singleScatter.LineWidth = 2; - singleScatter.MarkerSize = 5; - singleScatter.Color = TabHelpers.ChartColors[3]; - singleScatter.LegendText = "Single-Use"; - _planCacheHover?.Add(singleScatter, "Single-Use"); - - // Multi-Use series with gap filling - var (multiXs, multiYs) = TabHelpers.FillTimeSeriesGaps( - grouped.Select(d => d.Time), - grouped.Select(d => (double)d.MultiUseSizeMb)); - - var multiScatter = PlanCacheChart.Plot.Add.Scatter(multiXs, multiYs); - multiScatter.LineWidth = 2; - multiScatter.MarkerSize = 5; - multiScatter.Color = TabHelpers.ChartColors[1]; - multiScatter.LegendText = "Multi-Use"; - _planCacheHover?.Add(multiScatter, "Multi-Use"); - - _legendPanels[PlanCacheChart] = PlanCacheChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - PlanCacheChart.Plot.Legend.FontSize = 12; - } - - // Update summary panel with latest data point - var latestOldestPlan = dataList - .Where(d => d.OldestPlanCreateTime.HasValue) - .OrderByDescending(d => d.CollectionTime) - .FirstOrDefault(); - var latestTime = dataList.Max(d => d.CollectionTime); - int totalPlans = dataList.Where(d => d.CollectionTime == latestTime).Sum(d => d.TotalPlans); - UpdatePlanCacheSummary(latestOldestPlan, totalPlans); - } - else - { - UpdatePlanCacheSummary(null, 0); - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = PlanCacheChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); - PlanCacheChart.Plot.Axes.SetLimitsX(xMin, xMax); - PlanCacheChart.Plot.YLabel("MB"); - // Fixed negative space for legend - PlanCacheChart.Plot.Axes.AutoScaleY(); - var planCacheLimits = PlanCacheChart.Plot.Axes.GetLimits(); - PlanCacheChart.Plot.Axes.SetLimitsY(0, planCacheLimits.Top * 1.05); - - TabHelpers.LockChartVerticalAxis(PlanCacheChart); - PlanCacheChart.Refresh(); - } - - private void UpdatePlanCacheSummary(PlanCacheStatsItem? oldestPlanData, int totalPlans) - { - if (oldestPlanData?.OldestPlanCreateTime != null) - { - var age = ServerTimeHelper.ServerNow - oldestPlanData.OldestPlanCreateTime.Value; - string ageText; - if (age.TotalDays >= 1) - ageText = $"{age.Days}d {age.Hours}h"; - else if (age.TotalHours >= 1) - ageText = $"{age.Hours}h {age.Minutes}m"; - else - ageText = $"{age.Minutes}m"; - - PlanCacheOldestPlanText.Text = ageText; - - // Color code based on age - older is better (more stable) - if (age.TotalHours < 1) - PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed); - else if (age.TotalHours < 24) - PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Orange); - else - PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - else - { - PlanCacheOldestPlanText.Text = "N/A"; - PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - - PlanCacheTotalPlansText.Text = totalPlans > 0 ? totalPlans.ToString("N0", CultureInfo.CurrentCulture) : "N/A"; - } - - #endregion - - #region Memory Pressure Events - - private async System.Threading.Tasks.Task RefreshMemoryPressureEventsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetMemoryPressureEventsAsync(_memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); - LoadMemoryPressureEventsChart(data.ToList(), _memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory pressure events: {ex.Message}"); - } - } - - private void LoadMemoryPressureEventsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryPressureEventsChart, out var existingPanel) && existingPanel != null) - { - MemoryPressureEventsChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryPressureEventsChart] = null; - } - MemoryPressureEventsChart.Plot.Clear(); - _memoryPressureEventsHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - - // Count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). - var dataList = data? - .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) - .OrderBy(d => d.SampleTime) - .ToList() ?? new List(); - - bool hasData = false; - int maxBarCount = 0; - - if (dataList.Count > 0) - { - var grouped = dataList - .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; - - // Four series: SQL Server medium, SQL Server severe (stacked on top of medium), - // OS medium, OS severe. Stacking uses ValueBase so severe bars sit on top of medium. - var sqlMediumBars = new List(); - var sqlSevereBars = new List(); - var osMediumBars = new List(); - var osSevereBars = new List(); - - 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 - - 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.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; - } - - bool anyBars = sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 - || osMediumBars.Count > 0 || osSevereBars.Count > 0; - - if (anyBars) - { - 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)"); - } - - _legendPanels[MemoryPressureEventsChart] = MemoryPressureEventsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryPressureEventsChart.Plot.Legend.FontSize = 12; - } - } - - if (!hasData) - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No memory pressure events in selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); - MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); - - TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); - MemoryPressureEventsChart.Refresh(); - } - - #endregion - - - #region Context Menu Handlers - - 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)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - 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); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - 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(); - - // Add headers - 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)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - 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) - { - string prefix = "memory"; - - - var saveFileDialog = new SaveFileDialog - { - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - 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)); - - // Add all rows - 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); - } - } - } - } - } - - #endregion - } -} +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + /// + /// UserControl for the Memory tab content. + /// Displays memory stats, grants, clerks, and plan cache analysis. + /// + public partial class MemoryContent : UserControl + { + public event Action? ChartDrillDownRequested; + + private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, + Func hoverGetter, string label, string chartType) + { + menu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = label }; + menu.Items.Insert(0, item); + + menu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = hoverGetter()?.GetNearestSeries(pos); + item.Tag = nearest?.Time; + item.IsEnabled = nearest.HasValue; + }; + + item.Click += (s, _) => + { + if (item.Tag is DateTime time) + ChartDrillDownRequested?.Invoke(chartType, time); + }; + } + + private DatabaseService? _databaseService; + + // Memory Stats state + private int _memoryStatsHoursBack = 24; + private DateTime? _memoryStatsFromDate; + private DateTime? _memoryStatsToDate; + + // Memory Grants state + private int _memoryGrantsHoursBack = 24; + private DateTime? _memoryGrantsFromDate; + private DateTime? _memoryGrantsToDate; + + // Memory Clerks state + private int _memoryClerksHoursBack = 24; + private DateTime? _memoryClerksFromDate; + private DateTime? _memoryClerksToDate; + + // Plan Cache state + private int _planCacheHoursBack = 24; + private DateTime? _planCacheFromDate; + private DateTime? _planCacheToDate; + + // Memory Pressure Events state + private int _memoryPressureEventsHoursBack = 24; + private DateTime? _memoryPressureEventsFromDate; + private DateTime? _memoryPressureEventsToDate; + + // Filter state dictionaries removed - no more grids with filters in this control + + // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) + private Dictionary _legendPanels = new(); + + // Memory Clerks picker state + private List _memoryClerkItems = new(); + private bool _isUpdatingMemoryClerkSelection; + + // Chart hover tooltips + private Helpers.ChartHoverHelper? _memoryStatsOverviewHover; + private Helpers.ChartHoverHelper? _memoryGrantSizingHover; + private Helpers.ChartHoverHelper? _memoryGrantActivityHover; + private Helpers.ChartHoverHelper? _memoryClerksHover; + private Helpers.ChartHoverHelper? _planCacheHover; + private Helpers.ChartHoverHelper? _memoryPressureEventsHover; + + // No DataGrids with filters - all tabs are chart-only + + public MemoryContent() + { + InitializeComponent(); + SetupChartContextMenus(); + Loaded += OnLoaded; + Helpers.ThemeManager.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => + { + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + DisposeChartHelpers(); + }; + + // Apply dark theme immediately so charts don't flash white before data loads + TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); + TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); + TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); + TabHelpers.ApplyThemeToChart(MemoryClerksChart); + TabHelpers.ApplyThemeToChart(PlanCacheChart); + TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); + + _memoryStatsOverviewHover = new Helpers.ChartHoverHelper(MemoryStatsOverviewChart, "MB"); + _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); + _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, "count"); + _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); + _planCacheHover = new Helpers.ChartHoverHelper(PlanCacheChart, "MB"); + _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); + } + + public void DisposeChartHelpers() + { + _memoryStatsOverviewHover?.Dispose(); + _memoryGrantSizingHover?.Dispose(); + _memoryGrantActivityHover?.Dispose(); + _memoryClerksHover?.Dispose(); + _planCacheHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + // No grids to configure - all tabs are chart-only now + } + + 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) + { + Helpers.TabHelpers.ApplyThemeToChart(chart); + chart.Refresh(); + } + } + } + + private void SetupChartContextMenus() + { + // Memory Stats Overview chart + var memOverviewMenu = TabHelpers.SetupChartContextMenu(MemoryStatsOverviewChart, "Memory_Stats_Overview", "collect.memory_stats"); + AddDrillDown(MemoryStatsOverviewChart, memOverviewMenu, () => _memoryStatsOverviewHover, "Show Active Queries at This Time", "Memory"); + + // Memory Grant charts + var grantSizingMenu = TabHelpers.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing", "collect.memory_grant_stats"); + AddDrillDown(MemoryGrantSizingChart, grantSizingMenu, () => _memoryGrantSizingHover, "Show Active Queries at This Time", "MemoryGrant"); + var grantActivityMenu = TabHelpers.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity", "collect.memory_grant_stats"); + AddDrillDown(MemoryGrantActivityChart, grantActivityMenu, () => _memoryGrantActivityHover, "Show Active Queries at This Time", "MemoryGrant"); + + // Memory Clerks chart + var clerksMenu = TabHelpers.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks", "collect.memory_clerks_stats"); + AddDrillDown(MemoryClerksChart, clerksMenu, () => _memoryClerksHover, "Show Active Queries at This Time", "MemoryClerks"); + + // Plan Cache chart + TabHelpers.SetupChartContextMenu(PlanCacheChart, "Plan_Cache", "collect.plan_cache_stats"); + + // Memory Pressure Events chart + var pressureMenu = TabHelpers.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events", "collect.memory_pressure_events"); + AddDrillDown(MemoryPressureEventsChart, pressureMenu, () => _memoryPressureEventsHover, "Show Active Queries at This Time", "MemoryPressure"); + } + + /// + /// Initializes the control with required dependencies. + /// + public void Initialize(DatabaseService databaseService) + { + _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); + } + + /// + /// Sets the time range for all memory sub-tabs. + /// + public void SetTimeRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + _memoryStatsHoursBack = hoursBack; + _memoryStatsFromDate = fromDate; + _memoryStatsToDate = toDate; + + _memoryGrantsHoursBack = hoursBack; + _memoryGrantsFromDate = fromDate; + _memoryGrantsToDate = toDate; + + _memoryClerksHoursBack = hoursBack; + _memoryClerksFromDate = fromDate; + _memoryClerksToDate = toDate; + + _planCacheHoursBack = hoursBack; + _planCacheFromDate = fromDate; + _planCacheToDate = toDate; + + _memoryPressureEventsHoursBack = hoursBack; + _memoryPressureEventsFromDate = fromDate; + _memoryPressureEventsToDate = toDate; + } + + /// + /// Refreshes memory data. When fullRefresh is false, only the visible sub-tab is refreshed. + /// + public async Task RefreshAllDataAsync(bool fullRefresh = true) + { + try + { + using var _ = Helpers.MethodProfiler.StartTiming("Memory"); + + if (fullRefresh) + { + // Run all independent refreshes in parallel for initial load / manual refresh + await Task.WhenAll( + RefreshMemoryStatsAsync(), + RefreshMemoryGrantsAsync(), + RefreshMemoryClerksAsync(), + RefreshPlanCacheAsync(), + RefreshMemoryPressureEventsAsync() + ); + } + else + { + // Only refresh the visible sub-tab + switch (SubTabControl.SelectedIndex) + { + case 0: await RefreshMemoryStatsAsync(); break; + case 1: await RefreshMemoryGrantsAsync(); break; + case 2: await RefreshMemoryClerksAsync(); break; + case 3: await RefreshPlanCacheAsync(); break; + case 4: await RefreshMemoryPressureEventsAsync(); break; + } + } + } + catch (Exception ex) + { + Logger.Error($"Error refreshing Memory data: {ex.Message}", ex); + } + } + } +} diff --git a/Dashboard/Controls/PlanViewerControl.Interaction.cs b/Dashboard/Controls/PlanViewerControl.Interaction.cs new file mode 100644 index 00000000..b866168b --- /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 00000000..5aea7975 --- /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 00000000..24b63446 --- /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 00000000..7c5c9e43 --- /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 c7bcac47..69686e02 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 00000000..084b36da --- /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 00000000..ef7dbe85 --- /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 00000000..607892d1 --- /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