From 6527c1684ca5231bba7b8d2d468124c91df80e9f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:33:14 -0400 Subject: [PATCH 1/2] #1138 Phase 1: object-growth heatmap + Storage Growth drill (both apps) Replace the standalone Object Sizes & Growth (#5) and Index Usage (#6) FinOps tabs with a Storage Growth (#4) parent -> object -> index drill: - Storage Growth grid: double-click a database row (or "Show objects") drills to that DB's object-growth heatmap (rows = top-N objects by reserved-MB growth over the window, X = daily buckets, color = absolute reserved MB) plus a companion per-object grid; double-click an object drills to its per-index size + usage. Back/breadcrumb navigation; an in-UI substrate caveat (object reservation vs file allocation). - Shared foundations so both apps render identically (no drift): pure PerformanceMonitor.Common/FinOpsHeatmap (top-N ranking, long->matrix pivot, per-column log color-scale); PerformanceMonitor.Ui/FinOpsHeatmapRenderer (the query-heatmap renderer with count couplings stripped: MB colorbar, M/d axis); PerformanceMonitor.Ui/HeatIntensityToBrushConverter (themed cell shading, used by Phase 2). - Read layer (both apps): GetObjectGrowthHeatmapDataAsync ranks the top-N cheaply (2-snapshot diff) then series-scans only those, DB-scoped on the covering index lead column to avoid the #1135 uncovered scan; GetObjectIndexDetailAsync for the index leaf. Existing GetObjectSizeGrowthAsync / GetIndexUsageAsync stay for MCP. - Tests: FinOpsHeatmapBuilder ranking + long->matrix pivot, in both test projects. #7 Locking & Contention is untouched here (it is Phase 2's home). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/FinOpsHeatmapBuilderTests.cs | 135 +++++++++ Dashboard/Controls/FinOpsContent.Loaders.cs | 36 +-- .../Controls/FinOpsContent.ObjectHeatmap.cs | 261 ++++++++++++++++ Dashboard/Controls/FinOpsContent.Refresh.cs | 24 +- Dashboard/Controls/FinOpsContent.xaml | 238 +++++++-------- Dashboard/Controls/FinOpsContent.xaml.cs | 3 +- .../DatabaseService.FinOps.IndexObjects.cs | 280 ++++++++++++++++++ Lite.Tests/FinOpsHeatmapBuilderTests.cs | 134 +++++++++ Lite/Controls/FinOpsTab.ObjectHeatmap.cs | 263 ++++++++++++++++ Lite/Controls/FinOpsTab.xaml | 235 +++++++-------- Lite/Controls/FinOpsTab.xaml.cs | 72 ++--- .../LocalDataService.FinOps.IndexObjects.cs | 215 ++++++++++++++ PerformanceMonitor.Common/FinOpsHeatmap.cs | 137 +++++++++ .../FinOpsHeatmapRenderer.cs | 156 ++++++++++ .../HeatIntensityToBrushConverter.cs | 67 +++++ 15 files changed, 1910 insertions(+), 346 deletions(-) create mode 100644 Dashboard.Tests/FinOpsHeatmapBuilderTests.cs create mode 100644 Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs create mode 100644 Lite.Tests/FinOpsHeatmapBuilderTests.cs create mode 100644 Lite/Controls/FinOpsTab.ObjectHeatmap.cs create mode 100644 PerformanceMonitor.Common/FinOpsHeatmap.cs create mode 100644 PerformanceMonitor.Ui/FinOpsHeatmapRenderer.cs create mode 100644 PerformanceMonitor.Ui/HeatIntensityToBrushConverter.cs diff --git a/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs b/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs new file mode 100644 index 00000000..e548ae00 --- /dev/null +++ b/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs @@ -0,0 +1,135 @@ +/* + * 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 PerformanceMonitor.Common; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Tests the shared, app-agnostic FinOps heatmap shaping (#1138): the top-N-by-growth ranking and the +/// long→matrix pivot that both Dashboard and Lite depend on. (Phase-2 color-scale math is exercised by the +/// ColumnLogIntensities tests below.) Pure functions, no database. +/// +public class FinOpsHeatmapBuilderTests +{ + private static readonly DateTime D1 = new(2026, 6, 1); + private static readonly DateTime D2 = new(2026, 6, 2); + private static readonly DateTime D3 = new(2026, 6, 3); + + // ── RankTopGrowers ── + + [Fact] + public void RankTopGrowers_OrdersByGrowthDescending_TakesTopN() + { + var ranked = FinOpsHeatmapBuilder.RankTopGrowers( + new[] { ("a", 10.0), ("b", 50.0), ("c", 30.0), ("d", 5.0) }, topN: 2); + + Assert.Equal(new[] { "b", "c" }, ranked); + } + + [Fact] + public void RankTopGrowers_TieBreaksOnKeyOrdinal_ForDeterminism() + { + var ranked = FinOpsHeatmapBuilder.RankTopGrowers( + new[] { ("z", 10.0), ("a", 10.0), ("m", 10.0) }, topN: 2); + + Assert.Equal(new[] { "a", "m" }, ranked); + } + + [Fact] + public void RankTopGrowers_TopNExceedsCount_ReturnsAll() + { + var ranked = FinOpsHeatmapBuilder.RankTopGrowers( + new[] { ("a", 1.0), ("b", 2.0) }, topN: 10); + + Assert.Equal(new[] { "b", "a" }, ranked); + } + + // ── BuildMatrix (long → dense pivot) ── + + [Fact] + public void BuildMatrix_PivotsToRowsByDays_WithMissingCellsZero() + { + var samples = new[] + { + new FinOpsObjectDaySample("big", D1, 100), + new FinOpsObjectDaySample("big", D2, 200), + new FinOpsObjectDaySample("small", D1, 10), + // "small" has no D2 sample — that cell must stay 0. + }; + + // Rows bottom-to-top: index 0 = "small" (bottom), index 1 = "big" (top). + var matrix = FinOpsHeatmapBuilder.BuildMatrix(new[] { "small", "big" }, samples); + + Assert.Equal(new[] { "small", "big" }, matrix.RowLabels); + Assert.Equal(new[] { D1, D2 }, matrix.Days); // ascending + Assert.Equal(10, matrix.Intensities[0, 0]); // small / D1 + Assert.Equal(0, matrix.Intensities[0, 1]); // small / D2 (missing) + Assert.Equal(100, matrix.Intensities[1, 0]); // big / D1 + Assert.Equal(200, matrix.Intensities[1, 1]); // big / D2 + } + + [Fact] + public void BuildMatrix_IgnoresSamplesNotInRowKeys() + { + var samples = new[] + { + new FinOpsObjectDaySample("a", D1, 5), + new FinOpsObjectDaySample("ghost", D1, 999), + }; + + var matrix = FinOpsHeatmapBuilder.BuildMatrix(new[] { "a" }, samples); + + Assert.Single(matrix.RowLabels); + Assert.Equal(5, matrix.Intensities[0, 0]); + // "ghost" introduced no extra row; only its day survives as a column. + Assert.Equal(1, matrix.Intensities.GetLength(0)); + } + + [Fact] + public void BuildMatrix_SumsDuplicateKeyDayPairs() + { + var samples = new[] + { + new FinOpsObjectDaySample("a", D1, 5), + new FinOpsObjectDaySample("a", D1, 7), + }; + + var matrix = FinOpsHeatmapBuilder.BuildMatrix(new[] { "a" }, samples); + + Assert.Equal(12, matrix.Intensities[0, 0]); + } + + [Fact] + public void BuildMatrix_OrdersDaysAscendingRegardlessOfInputOrder() + { + var samples = new[] + { + new FinOpsObjectDaySample("a", D3, 3), + new FinOpsObjectDaySample("a", D1, 1), + new FinOpsObjectDaySample("a", D2, 2), + }; + + var matrix = FinOpsHeatmapBuilder.BuildMatrix(new[] { "a" }, samples); + + Assert.Equal(new[] { D1, D2, D3 }, matrix.Days); + Assert.Equal(1, matrix.Intensities[0, 0]); + Assert.Equal(2, matrix.Intensities[0, 1]); + Assert.Equal(3, matrix.Intensities[0, 2]); + } + + [Fact] + public void BuildMatrix_NoSamples_IsEmpty() + { + var matrix = FinOpsHeatmapBuilder.BuildMatrix(new[] { "a" }, Array.Empty()); + Assert.True(matrix.IsEmpty); + } +} diff --git a/Dashboard/Controls/FinOpsContent.Loaders.cs b/Dashboard/Controls/FinOpsContent.Loaders.cs index 7e68a095..3921cccb 100644 --- a/Dashboard/Controls/FinOpsContent.Loaders.cs +++ b/Dashboard/Controls/FinOpsContent.Loaders.cs @@ -287,41 +287,11 @@ private async Task LoadStorageGrowthAsync() } // ============================================ - // Object/Index stats (#1103) + // Object/Index stats (#1103) — the standalone Object Sizes & Index Usage loaders were removed + // in #1138; that data is now the Storage Growth -> object -> index drill (FinOpsContent.ObjectHeatmap.cs). + // The MCP read methods GetObjectSizeGrowthAsync / GetIndexUsageAsync remain on DatabaseService. // ============================================ - private async Task LoadObjectSizeGrowthAsync() - { - if (_databaseService == null) return; - try - { - var data = await _databaseService.GetObjectSizeGrowthAsync(); - ObjectSizeGrowthDataGrid.ItemsSource = data; - ObjectSizeGrowthNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - ObjectSizeGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} table(s)" : ""; - } - catch (Exception ex) - { - Logger.Error($"Error loading object size/growth: {ex.Message}", ex); - } - } - - private async Task LoadIndexUsageAsync() - { - if (_databaseService == null) return; - try - { - var data = await _databaseService.GetIndexUsageAsync(); - IndexUsageDataGrid.ItemsSource = data; - IndexUsageNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - IndexUsageCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : ""; - } - catch (Exception ex) - { - Logger.Error($"Error loading index usage: {ex.Message}", ex); - } - } - private async Task LoadIndexLockingAsync() { if (_databaseService == null) return; diff --git a/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs b/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs new file mode 100644 index 00000000..7ff665df --- /dev/null +++ b/Dashboard/Controls/FinOpsContent.ObjectHeatmap.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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + /// + /// #1138 — the Storage Growth tab's parent → object → index drill. The parent grid (per-database + /// growth) drills into a database's object-growth heatmap (top-N objects by reserved-MB growth × daily + /// buckets, colored by absolute reserved MB) plus a companion grid, and an object drills to its per-index + /// detail. Heatmap shaping is shared via / + /// so Dashboard and Lite render identically. + /// + public partial class FinOpsContent : UserControl + { + private enum StorageDrillLevel { Parent, Objects, Indexes } + + private StorageDrillLevel _storageLevel = StorageDrillLevel.Parent; + private string _objDrillDb = ""; + private string _objDrillSchema = ""; + private string _objDrillTable = ""; + + private FinOpsHeatmapMatrix? _objHeatmapMatrix; + private FinOpsHeatmapHandle? _objHeatmapHandle; + private ScottPlot.Plottables.Heatmap? _objHeatmapPlottable; + private Popup? _objHeatmapPopup; + private TextBlock? _objHeatmapPopupText; + private DateTime _lastObjHeatmapHover; + + private int GetObjectHeatmapDaysBack() => ObjectHeatmapWindowCombo?.SelectedIndex switch + { + 0 => 7, + 1 => 30, + 2 => 90, + _ => 30 + }; + + /// Resets the Storage Growth drill back to the per-database parent view (server change / refresh). + private void ResetStorageDrill() + { + _objDrillDb = _objDrillSchema = _objDrillTable = ""; + ShowStorageView(StorageDrillLevel.Parent); + } + + private void ShowStorageView(StorageDrillLevel level) + { + _storageLevel = level; + StorageParentView.Visibility = level == StorageDrillLevel.Parent ? Visibility.Visible : Visibility.Collapsed; + StorageObjectView.Visibility = level == StorageDrillLevel.Objects ? Visibility.Visible : Visibility.Collapsed; + StorageIndexView.Visibility = level == StorageDrillLevel.Indexes ? Visibility.Visible : Visibility.Collapsed; + + StorageBackButton.Visibility = level == StorageDrillLevel.Parent ? Visibility.Collapsed : Visibility.Visible; + var windowVisible = level == StorageDrillLevel.Objects ? Visibility.Visible : Visibility.Collapsed; + ObjectHeatmapWindowCombo.Visibility = windowVisible; + ObjectHeatmapWindowLabel.Visibility = windowVisible; + + StorageBreadcrumb.Text = level switch + { + StorageDrillLevel.Objects => $"Storage Growth › {_objDrillDb}", + StorageDrillLevel.Indexes => $"Storage Growth › {_objDrillDb} › {_objDrillSchema}.{_objDrillTable}", + _ => "Storage Growth" + }; + } + + private async void StorageGrowthGrid_DoubleClick(object sender, MouseButtonEventArgs e) + { + if (StorageGrowthDataGrid.SelectedItem is FinOpsStorageGrowthRow row) + await DrillToObjectsAsync(row.DatabaseName); + } + + private async void StorageGrowthShowObjects_Click(object sender, RoutedEventArgs e) + { + if (GetFinOpsRow(sender) is FinOpsStorageGrowthRow row) + await DrillToObjectsAsync(row.DatabaseName); + } + + private async void ObjectGrowthGrid_DoubleClick(object sender, MouseButtonEventArgs e) + { + if (ObjectGrowthDetailGrid.SelectedItem is ObjectSizeGrowthRow row) + await DrillToIndexesAsync(row.DatabaseName, row.SchemaName, row.TableName); + } + + private void StorageGrowthBack_Click(object sender, RoutedEventArgs e) + { + if (_storageLevel == StorageDrillLevel.Indexes) + ShowStorageView(StorageDrillLevel.Objects); + else + ShowStorageView(StorageDrillLevel.Parent); + } + + private async void ObjectHeatmapWindow_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _databaseService == null) return; + if (_storageLevel != StorageDrillLevel.Objects || string.IsNullOrEmpty(_objDrillDb)) return; + await LoadObjectGrowthAsync(_objDrillDb); + } + + private async Task DrillToObjectsAsync(string databaseName) + { + if (string.IsNullOrEmpty(databaseName)) return; + _objDrillDb = databaseName; + ShowStorageView(StorageDrillLevel.Objects); + await LoadObjectGrowthAsync(databaseName); + } + + private async Task DrillToIndexesAsync(string databaseName, string schemaName, string tableName) + { + if (string.IsNullOrEmpty(tableName)) return; + _objDrillSchema = schemaName; + _objDrillTable = tableName; + ShowStorageView(StorageDrillLevel.Indexes); + await LoadObjectIndexDetailAsync(databaseName, schemaName, tableName); + } + + private async Task LoadObjectGrowthAsync(string databaseName) + { + if (_databaseService == null) return; + try + { + var (objects, samples) = await _databaseService.GetObjectGrowthHeatmapDataAsync( + databaseName, GetObjectHeatmapDaysBack()); + + ObjectGrowthDetailGrid.ItemsSource = objects; + StorageGrowthCountIndicator.Text = objects.Count > 0 ? $"{objects.Count} object(s)" : ""; + ObjectGrowthNoDataMessage.Visibility = objects.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + + // Heatmap rows bottom-to-top: the renderer flips vertically (row 0 = bottom), so reverse the + // descending-by-growth ranking to put the biggest grower at the TOP of the chart. + var rowKeysBottomToTop = objects + .Select(o => $"{o.SchemaName}.{o.TableName}") + .Reverse() + .ToList(); + _objHeatmapMatrix = FinOpsHeatmapBuilder.BuildMatrix(rowKeysBottomToTop, samples); + _objHeatmapHandle = FinOpsHeatmapRenderer.Render( + ObjectGrowthHeatmapChart, + _objHeatmapMatrix, + "Reserved (MB)", + $"{databaseName} — Object Reserved Footprint", + _objHeatmapHandle); + _objHeatmapPlottable = _objHeatmapHandle.Plottable; + } + catch (Exception ex) + { + Logger.Error($"Error loading object growth heatmap: {ex.Message}", ex); + } + } + + private async Task LoadObjectIndexDetailAsync(string databaseName, string schemaName, string tableName) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetObjectIndexDetailAsync(databaseName, schemaName, tableName); + ObjectIndexDetailGrid.ItemsSource = data; + StorageGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : ""; + ObjectIndexNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + catch (Exception ex) + { + Logger.Error($"Error loading object index detail: {ex.Message}", ex); + } + } + + // ── Heatmap hover ── + + private void EnsureObjHeatmapPopup() + { + if (_objHeatmapPopup != null) return; + _objHeatmapPopupText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 13, + MaxWidth = 460, + TextTrimming = TextTrimming.CharacterEllipsis + }; + _objHeatmapPopup = new Popup + { + PlacementTarget = ObjectGrowthHeatmapChart, + Placement = PlacementMode.Relative, + IsHitTestVisible = false, + AllowsTransparency = true, + Child = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(8, 4, 8, 4), + Child = _objHeatmapPopupText + } + }; + } + + private void ObjectHeatmapChart_MouseLeave(object sender, MouseEventArgs e) + { + if (_objHeatmapPopup != null) _objHeatmapPopup.IsOpen = false; + } + + private void ObjectHeatmapChart_MouseMove(object sender, MouseEventArgs e) + { + EnsureObjHeatmapPopup(); + if (_objHeatmapPopup == null || _objHeatmapPopupText == null || _objHeatmapPlottable == null) return; + if (_objHeatmapMatrix == null || _objHeatmapMatrix.IsEmpty) return; + + var now = DateTime.UtcNow; + if ((now - _lastObjHeatmapHover).TotalMilliseconds < 50) return; + _lastObjHeatmapHover = now; + + var pos = e.GetPosition(ObjectGrowthHeatmapChart); + var dpi = VisualTreeHelper.GetDpi(ObjectGrowthHeatmapChart); + var pixel = new ScottPlot.Pixel((float)(pos.X * dpi.DpiScaleX), (float)(pos.Y * dpi.DpiScaleY)); + var coords = ObjectGrowthHeatmapChart.Plot.GetCoordinates(pixel); + + int numRows = _objHeatmapMatrix.Intensities.GetLength(0); + int numCols = _objHeatmapMatrix.Intensities.GetLength(1); + + var (col, rowIdx) = _objHeatmapPlottable.GetIndexes(coords); + int row = (numRows - 1) - rowIdx; // FlipVertically + + if (row < 0 || row >= numRows || col < 0 || col >= numCols) + { + _objHeatmapPopup.IsOpen = false; + return; + } + + double mb = _objHeatmapMatrix.Intensities[row, col]; + if (mb <= 0) + { + _objHeatmapPopup.IsOpen = false; + return; + } + + var label = row < _objHeatmapMatrix.RowLabels.Length ? _objHeatmapMatrix.RowLabels[row] : "?"; + var day = _objHeatmapMatrix.Days[col]; + _objHeatmapPopupText.Text = $"{label} | {day:M/d} | {mb:N1} MB reserved"; + + _objHeatmapPopup.HorizontalOffset = pos.X + 15; + _objHeatmapPopup.VerticalOffset = pos.Y + 15; + _objHeatmapPopup.IsOpen = true; + } + } +} diff --git a/Dashboard/Controls/FinOpsContent.Refresh.cs b/Dashboard/Controls/FinOpsContent.Refresh.cs index c7fbe6b0..f526d1e6 100644 --- a/Dashboard/Controls/FinOpsContent.Refresh.cs +++ b/Dashboard/Controls/FinOpsContent.Refresh.cs @@ -50,17 +50,19 @@ private async void DatabaseResourcesRefresh_Click(object sender, RoutedEventArgs private async void StorageGrowthRefresh_Click(object sender, RoutedEventArgs e) { - await LoadStorageGrowthAsync(); - } - - private async void ObjectSizeGrowthRefresh_Click(object sender, RoutedEventArgs e) - { - await LoadObjectSizeGrowthAsync(); - } - - private async void IndexUsageRefresh_Click(object sender, RoutedEventArgs e) - { - await LoadIndexUsageAsync(); + // Refresh the view the user is actually looking at (#1138 drill), not just the parent grid. + switch (_storageLevel) + { + case StorageDrillLevel.Objects when !string.IsNullOrEmpty(_objDrillDb): + await LoadObjectGrowthAsync(_objDrillDb); + break; + case StorageDrillLevel.Indexes when !string.IsNullOrEmpty(_objDrillTable): + await LoadObjectIndexDetailAsync(_objDrillDb, _objDrillSchema, _objDrillTable); + break; + default: + await LoadStorageGrowthAsync(); + break; + } } private async void IndexLockingRefresh_Click(object sender, RoutedEventArgs e) diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index a234ebdd..c3cf4dfc 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -3,6 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui" mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="1200"> @@ -58,6 +60,24 @@ + + + + + + + + + + + + + + @@ -812,7 +832,9 @@ - +