diff --git a/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs b/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs
new file mode 100644
index 00000000..380c8ca1
--- /dev/null
+++ b/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs
@@ -0,0 +1,173 @@
+/*
+ * 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);
+ }
+
+ // ── ColumnLogIntensities (Phase 2 color-scale binding) ──
+
+ [Fact]
+ public void ColumnLogIntensities_MaxIsOne_ZeroIsZero_OthersBetween()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 0, 10, 100 });
+
+ Assert.Equal(0.0, intensities[0]); // zero -> no shade
+ Assert.Equal(1.0, intensities[2], 6); // column max -> full shade
+ Assert.InRange(intensities[1], 0.0001, 0.9999); // mid -> partial
+ Assert.True(intensities[1] < intensities[2]);
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_AllZero_ReturnsAllZero()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 0, 0, 0 });
+ Assert.All(intensities, v => Assert.Equal(0.0, v)); // max=0 divide-by-zero guard (§3B)
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_NegativeClampsToZero()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { -5, 100 });
+ Assert.Equal(0.0, intensities[0]);
+ Assert.Equal(1.0, intensities[1], 6);
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_LogScale_CompressesLargeRange()
+ {
+ // A single hot value among many quiet ones: the quiet values still get a visible (non-tiny)
+ // shade because of the log scale, not a linear one.
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 10, 10000 });
+ Assert.True(intensities[0] > 0.2, $"log scale should keep the small value visible, got {intensities[0]}");
+ Assert.Equal(1.0, intensities[1], 6);
+ }
+}
diff --git a/Dashboard/Controls/FinOpsContent.Loaders.cs b/Dashboard/Controls/FinOpsContent.Loaders.cs
index 7e68a095..04479b2e 100644
--- a/Dashboard/Controls/FinOpsContent.Loaders.cs
+++ b/Dashboard/Controls/FinOpsContent.Loaders.cs
@@ -287,57 +287,12 @@ 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 Locking & Contention loader moved to FinOpsContent.Locking.cs (the #1138 color-scaled grid).
+ // 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;
- try
- {
- var data = await _databaseService.GetIndexLockingAsync();
- IndexLockingDataGrid.ItemsSource = data;
- IndexLockingNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- IndexLockingCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : "";
- }
- catch (Exception ex)
- {
- Logger.Error($"Error loading index locking: {ex.Message}", ex);
- }
- }
-
// ============================================
// Optimization Tab — Idle Databases
// ============================================
diff --git a/Dashboard/Controls/FinOpsContent.Locking.cs b/Dashboard/Controls/FinOpsContent.Locking.cs
new file mode 100644
index 00000000..8b22da51
--- /dev/null
+++ b/Dashboard/Controls/FinOpsContent.Locking.cs
@@ -0,0 +1,182 @@
+/*
+ * 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.Input;
+using System.Windows.Media;
+using PerformanceMonitor.Common;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard.Controls
+{
+ ///
+ /// #1138 Phase 2 — the Locking & Contention tab as a color-scaled wait-type grid (a heatmap in grid
+ /// form) with a database selector and an index-detail drill. The four *_wait_in_ms columns are shaded
+ /// per-column (log) by their wait category's hue via ; the
+ /// numeric values stay visible. Cumulative snapshot, no delta (§3B), so this sidesteps the M1/M2 issues.
+ ///
+ public partial class FinOpsContent : UserControl
+ {
+ private enum LockingLevel { Parent, Detail }
+
+ private bool _suppressLockingDbChange;
+
+ /// Entry point (refresh / server change): reset to the grid, repopulate the DB selector, load.
+ private async Task LoadIndexLockingAsync()
+ {
+ if (_databaseService == null) return;
+ ShowLockingView(LockingLevel.Parent);
+ await PopulateLockingDbSelectorAsync();
+ await LoadIndexLockingGridAsync();
+ }
+
+ private async Task PopulateLockingDbSelectorAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var dbs = await _databaseService.GetIndexLockingDatabasesAsync();
+ var items = new List { "All databases" };
+ items.AddRange(dbs);
+
+ _suppressLockingDbChange = true;
+ LockingDbSelector.ItemsSource = items;
+ LockingDbSelector.SelectedIndex = 0;
+ _suppressLockingDbChange = false;
+ }
+ catch (Exception ex)
+ {
+ _suppressLockingDbChange = false;
+ Logger.Error($"Error loading locking databases: {ex.Message}", ex);
+ }
+ }
+
+ private string? SelectedLockingDb()
+ => LockingDbSelector.SelectedIndex <= 0 ? null : LockingDbSelector.SelectedItem as string;
+
+ private async Task LoadIndexLockingGridAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetIndexLockingAsync(databaseName: SelectedLockingDb());
+ ApplyLockingHeat(data);
+ IndexLockingDataGrid.ItemsSource = data;
+ IndexLockingNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ IndexLockingCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading index locking: {ex.Message}", ex);
+ }
+ }
+
+ /// Per-column log color-scale over the visible rows (#1138 §3B) — each wait column independent.
+ private static void ApplyLockingHeat(List rows)
+ {
+ var rowLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.RowLockWaitInMs).ToList());
+ var pageLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLockWaitInMs).ToList());
+ var pageLatch = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLatchWaitInMs).ToList());
+ var pageIo = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageIoLatchWaitInMs).ToList());
+ for (int i = 0; i < rows.Count; i++)
+ {
+ rows[i].RowLockHeat = rowLock[i];
+ rows[i].PageLockHeat = pageLock[i];
+ rows[i].PageLatchHeat = pageLatch[i];
+ rows[i].PageIoLatchHeat = pageIo[i];
+ }
+ }
+
+ private async void LockingDbSelector_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressLockingDbChange || !IsLoaded || _databaseService == null) return;
+ ShowLockingView(LockingLevel.Parent);
+ await LoadIndexLockingGridAsync();
+ }
+
+ private void IndexLockingGrid_DoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (IndexLockingDataGrid.SelectedItem is IndexLockingRow row)
+ ShowLockingDetail(row);
+ }
+
+ private void LockingBack_Click(object sender, RoutedEventArgs e)
+ => ShowLockingView(LockingLevel.Parent);
+
+ private void ShowLockingView(LockingLevel level)
+ {
+ LockingParentView.Visibility = level == LockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed;
+ LockingDetailView.Visibility = level == LockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed;
+ LockingBackButton.Visibility = level == LockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed;
+ var selectorVisible = level == LockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed;
+ LockingDbSelector.Visibility = selectorVisible;
+ LockingDbLabel.Visibility = selectorVisible;
+ LockingBreadcrumb.Text = level == LockingLevel.Detail ? "Locking & Contention › index detail" : "Locking & Contention";
+ }
+
+ private void ShowLockingDetail(IndexLockingRow row)
+ {
+ LockingDetailPanel.DataContext = row;
+
+ LockingDetailIdentity.Children.Clear();
+ AddLockingDetailRow(LockingDetailIdentity, "Database", row.DatabaseName);
+ AddLockingDetailRow(LockingDetailIdentity, "Schema", row.SchemaName);
+ AddLockingDetailRow(LockingDetailIdentity, "Table", row.TableName);
+ AddLockingDetailRow(LockingDetailIdentity, "Index", row.IndexName);
+ AddLockingDetailRow(LockingDetailIdentity, "Type", row.IndexTypeDesc);
+ AddLockingDetailRow(LockingDetailIdentity, "Reserved (MB)", row.ReservedMb.ToString("N1"));
+ AddLockingDetailRow(LockingDetailIdentity, "Rows", row.TotalRows.ToString("N0"));
+
+ LockingDetailCounters.Children.Clear();
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Count", row.RowLockCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Wait Count", row.RowLockWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Wait (ms)", row.RowLockWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Count", row.PageLockCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Wait Count", row.PageLockWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Wait (ms)", row.PageLockWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Lock Escalations", row.IndexLockPromotionCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Latch Wait Count", row.PageLatchWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Latch Wait (ms)", row.PageLatchWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page IO Latch Wait Count", row.PageIoLatchWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page IO Latch Wait (ms)", row.PageIoLatchWaitInMs.ToString("N0"));
+
+ ShowLockingView(LockingLevel.Detail);
+ }
+
+ private void AddLockingDetailRow(Panel panel, string label, string value)
+ {
+ var grid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(170) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = (Brush)FindResource("ForegroundMutedBrush")
+ };
+ var valueBlock = new TextBlock
+ {
+ Text = value,
+ Foreground = (Brush)FindResource("ForegroundBrush"),
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+ Grid.SetColumn(labelBlock, 0);
+ Grid.SetColumn(valueBlock, 1);
+ grid.Children.Add(labelBlock);
+ grid.Children.Add(valueBlock);
+ panel.Children.Add(grid);
+ }
+ }
+}
diff --git a/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs b/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs
new file mode 100644
index 00000000..2f522660
--- /dev/null
+++ b/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs
@@ -0,0 +1,265 @@
+/*
+ * 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());
+
+ // One canonical, deterministic top-of-chart-first ranking drives BOTH the companion grid and
+ // the heatmap rows, so they can never disagree (and stay identical across Dashboard/Lite).
+ var orderedKeys = FinOpsHeatmapBuilder.RankTopGrowers(
+ objects.Select(o => ($"{o.SchemaName}.{o.TableName}", (double)o.Growth30dMb)), objects.Count);
+ var byKey = objects.ToDictionary(o => $"{o.SchemaName}.{o.TableName}");
+ var orderedObjects = orderedKeys.Select(k => byKey[k]).ToList();
+
+ ObjectGrowthDetailGrid.ItemsSource = orderedObjects;
+ StorageGrowthCountIndicator.Text = orderedObjects.Count > 0 ? $"{orderedObjects.Count} object(s)" : "";
+ ObjectGrowthNoDataMessage.Visibility = orderedObjects.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ // Heatmap rows are bottom-to-top: the renderer flips vertically (row 0 = bottom), so reverse
+ // the top-first ranking to put the biggest grower at the TOP of the chart.
+ var rowKeysBottomToTop = Enumerable.Reverse(orderedKeys).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..f9001413 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 @@
-
+
+
+
+
+
+
+
+
+
+ SelectionMode="Single"
+ MouseDoubleClick="StorageGrowthGrid_DoubleClick"
+ RowStyle="{StaticResource StorageGrowthRowStyle}">
@@ -940,133 +975,88 @@
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -1074,20 +1064,30 @@
-
+
-
+
+
+
+
+ SelectionMode="Single" MouseDoubleClick="IndexLockingGrid_DoubleClick"
+ RowStyle="{StaticResource DefaultRowStyle}">
@@ -1108,11 +1108,35 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1120,6 +1144,31 @@
Text="No locking/contention data recorded. Index/object stats are collected daily."
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Controls/FinOpsContent.xaml.cs b/Dashboard/Controls/FinOpsContent.xaml.cs
index 9b113cb6..805b29fa 100644
--- a/Dashboard/Controls/FinOpsContent.xaml.cs
+++ b/Dashboard/Controls/FinOpsContent.xaml.cs
@@ -149,6 +149,7 @@ private async void ServerSelector_SelectionChanged(object sender, SelectionChang
var connectionString = server.GetConnectionString(_credentialService);
_databaseService = new DatabaseService(connectionString);
_currentServerMonthlyCost = server.MonthlyCostUsd;
+ ResetStorageDrill(); // a new server invalidates any open object/index drill
await RefreshDataAsync();
}
}
@@ -175,8 +176,6 @@ await Task.WhenAll(
LoadApplicationConnectionsAsync(),
LoadServerInventoryAsync(),
LoadStorageGrowthAsync(),
- LoadObjectSizeGrowthAsync(),
- LoadIndexUsageAsync(),
LoadIndexLockingAsync(),
LoadIdleDatabasesAsync(),
LoadTempdbSummaryAsync(),
diff --git a/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs b/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs
index 7e32f1d2..5d76470b 100644
--- a/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs
+++ b/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs
@@ -10,6 +10,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
+using PerformanceMonitor.Common;
using PerformanceMonitorDashboard.Models;
namespace PerformanceMonitorDashboard.Services
@@ -287,10 +288,12 @@ ios.reserved_mb DESC
}
///
- /// Per-index locking/latch contention from the latest snapshot, top objects by lock waits.
- /// Counters are cumulative since the metadata last entered cache (≈ instance restart).
+ /// Per-index locking/latch contention at each database's latest snapshot, top objects by total
+ /// lock+latch wait. Counters are cumulative since the metadata last entered cache (≈ instance
+ /// restart) — shown as raw totals, no delta (#1138 §3B). Optionally scoped to one database (the
+ /// Locking grid's DB selector); per-database latest so "all databases" shows every DB's newest row.
///
- public async Task> GetIndexLockingAsync(int topN = 200)
+ public async Task> GetIndexLockingAsync(int topN = 200, string? databaseName = null)
{
var items = new List();
@@ -300,6 +303,18 @@ public async Task> GetIndexLockingAsync(int topN = 200)
const string query = @"
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+WITH
+ latest AS
+ (
+ SELECT
+ database_name,
+ latest_time = MAX(collection_time)
+ FROM collect.index_object_stats
+ WHERE @db IS NULL
+ OR database_name = @db
+ GROUP BY
+ database_name
+ )
SELECT TOP (@topN)
ios.database_name,
ios.schema_name,
@@ -316,14 +331,14 @@ SELECT TOP (@topN)
page_lock_wait_in_ms = ISNULL(ios.page_lock_wait_in_ms, 0),
index_lock_promotion_count = ISNULL(ios.index_lock_promotion_count, 0),
page_latch_wait_in_ms = ISNULL(ios.page_latch_wait_in_ms, 0),
- page_io_latch_wait_in_ms = ISNULL(ios.page_io_latch_wait_in_ms, 0)
+ page_io_latch_wait_in_ms = ISNULL(ios.page_io_latch_wait_in_ms, 0),
+ page_latch_wait_count = ISNULL(ios.page_latch_wait_count, 0),
+ page_io_latch_wait_count = ISNULL(ios.page_io_latch_wait_count, 0)
FROM collect.index_object_stats AS ios
-WHERE ios.collection_time =
-(
- SELECT MAX(collection_time)
- FROM collect.index_object_stats
-)
-AND
+JOIN latest AS l
+ ON l.database_name = ios.database_name
+ AND l.latest_time = ios.collection_time
+WHERE
(
ISNULL(ios.row_lock_wait_in_ms, 0) > 0
OR ISNULL(ios.page_lock_wait_in_ms, 0) > 0
@@ -340,6 +355,7 @@ ORDER BY
using var command = new SqlCommand(query, connection);
command.Parameters.AddWithValue("@topN", topN);
+ command.Parameters.AddWithValue("@db", (object?)databaseName ?? DBNull.Value);
command.CommandTimeout = 120;
using (StartQueryTiming("FinOps_IndexLocking", query, connection))
@@ -364,7 +380,354 @@ ORDER BY
PageLockWaitInMs = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
IndexLockPromotionCount = reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13)),
PageLatchWaitInMs = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)),
- PageIoLatchWaitInMs = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))
+ PageIoLatchWaitInMs = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)),
+ PageLatchWaitCount = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)),
+ PageIoLatchWaitCount = reader.IsDBNull(17) ? 0L : Convert.ToInt64(reader.GetValue(17))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Distinct databases that have any lock/latch contention at their latest snapshot — the source for
+ /// the Locking grid's database selector (#1138 §3B).
+ ///
+ public async Task> GetIndexLockingDatabasesAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ latest AS
+ (
+ SELECT
+ database_name,
+ latest_time = MAX(collection_time)
+ FROM collect.index_object_stats
+ GROUP BY
+ database_name
+ )
+SELECT DISTINCT
+ ios.database_name
+FROM collect.index_object_stats AS ios
+JOIN latest AS l
+ ON l.database_name = ios.database_name
+ AND l.latest_time = ios.collection_time
+WHERE
+(
+ ISNULL(ios.row_lock_wait_in_ms, 0) > 0
+ OR ISNULL(ios.page_lock_wait_in_ms, 0) > 0
+ OR ISNULL(ios.page_latch_wait_in_ms, 0) > 0
+ OR ISNULL(ios.page_io_latch_wait_in_ms, 0) > 0
+ OR ISNULL(ios.index_lock_promotion_count, 0) > 0
+)
+ORDER BY
+ ios.database_name
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ if (!reader.IsDBNull(0)) items.Add(reader.GetString(0));
+ }
+
+ return items;
+ }
+
+ // ============================================
+ // #1138 — Object-growth heatmap drill (per-DB)
+ // ============================================
+
+ ///
+ /// Data for the per-database object-growth heatmap drill (#1138 §3A): the top-N objects in a single
+ /// database ranked by reserved-MB growth over the window, plus their daily reserved-MB series for the
+ /// heatmap. Two-step + DB-scoped for perf: the rank and the series both filter on database_name (the
+ /// leading column of IX_index_object_stats_object_lookup), and the series only touches the ranked
+ /// top-N — never the uncovered all-objects 90-day scan that caused #1135. Returns the ranked summary
+ /// rows (for the companion grid) and the long-form samples (pivoted to a matrix by FinOpsHeatmapBuilder).
+ ///
+ public async Task<(List Objects, List Samples)> GetObjectGrowthHeatmapDataAsync(
+ string databaseName, int daysBack = 30, int topN = 20)
+ {
+ var objects = new List();
+ var samples = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET NOCOUNT ON;
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+DECLARE
+ @window_start datetime2(7) = DATEADD(DAY, -@daysBack, SYSDATETIME()),
+ @latest_time datetime2(7),
+ @earliest_time datetime2(7);
+
+SELECT
+ @latest_time = MAX(ios.collection_time),
+ @earliest_time = MIN(ios.collection_time)
+FROM collect.index_object_stats AS ios
+WHERE ios.database_name = @db
+AND ios.collection_time >= @window_start
+OPTION(MAXDOP 1, RECOMPILE);
+
+CREATE TABLE
+ #ranked
+(
+ schema_name sysname NOT NULL,
+ table_name sysname NOT NULL,
+ cur_reserved_mb decimal(19,2) NULL,
+ cur_used_mb decimal(19,2) NULL,
+ cur_rows bigint NULL,
+ index_count integer NULL,
+ growth_mb decimal(19,2) NULL,
+ PRIMARY KEY CLUSTERED (schema_name, table_name)
+);
+
+INSERT
+ #ranked
+WITH
+ (TABLOCK)
+(
+ schema_name,
+ table_name,
+ cur_reserved_mb,
+ cur_used_mb,
+ cur_rows,
+ index_count,
+ growth_mb
+)
+SELECT TOP (@topN)
+ l.schema_name,
+ l.table_name,
+ l.cur_reserved_mb,
+ l.cur_used_mb,
+ l.cur_rows,
+ l.index_count,
+ growth_mb =
+ l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb)
+FROM
+(
+ SELECT
+ ios.schema_name,
+ ios.table_name,
+ cur_reserved_mb = SUM(ios.reserved_mb),
+ cur_used_mb = SUM(ios.used_mb),
+ cur_rows = MAX(ios.total_rows),
+ index_count = COUNT_BIG(*)
+ FROM collect.index_object_stats AS ios
+ WHERE ios.database_name = @db
+ AND ios.collection_time = @latest_time
+ GROUP BY
+ ios.schema_name,
+ ios.table_name
+) AS l
+LEFT JOIN
+(
+ SELECT
+ ios.schema_name,
+ ios.table_name,
+ e_reserved_mb = SUM(ios.reserved_mb)
+ FROM collect.index_object_stats AS ios
+ WHERE ios.database_name = @db
+ AND ios.collection_time = @earliest_time
+ GROUP BY
+ ios.schema_name,
+ ios.table_name
+) AS e
+ ON e.schema_name = l.schema_name
+ AND e.table_name = l.table_name
+ORDER BY
+ l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb) DESC,
+ l.schema_name,
+ l.table_name
+OPTION(MAXDOP 1, RECOMPILE);
+
+SELECT
+ r.schema_name,
+ r.table_name,
+ r.cur_reserved_mb,
+ r.cur_used_mb,
+ r.cur_rows,
+ r.index_count,
+ r.growth_mb
+FROM #ranked AS r
+ORDER BY
+ r.growth_mb DESC,
+ r.schema_name,
+ r.table_name
+OPTION(MAXDOP 1, RECOMPILE);
+
+SELECT
+ ios.schema_name,
+ ios.table_name,
+ the_day = CONVERT(date, ios.collection_time),
+ reserved_mb = SUM(ios.reserved_mb)
+FROM collect.index_object_stats AS ios
+JOIN #ranked AS r
+ ON r.schema_name = ios.schema_name
+ AND r.table_name = ios.table_name
+WHERE ios.database_name = @db
+AND ios.collection_time >= @window_start
+GROUP BY
+ ios.schema_name,
+ ios.table_name,
+ CONVERT(date, ios.collection_time)
+ORDER BY
+ ios.schema_name,
+ ios.table_name,
+ CONVERT(date, ios.collection_time)
+OPTION(MAXDOP 1, RECOMPILE);
+
+DROP TABLE #ranked;";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@db", databaseName);
+ command.Parameters.AddWithValue("@daysBack", daysBack);
+ command.Parameters.AddWithValue("@topN", topN);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ObjectGrowthHeatmap", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+
+ while (await reader.ReadAsync())
+ {
+ var current = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2));
+ var growth = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6));
+ var earlier = current - growth;
+ objects.Add(new ObjectSizeGrowthRow
+ {
+ DatabaseName = databaseName,
+ SchemaName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TableName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ CurrentReservedMb = current,
+ CurrentUsedMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TotalRows = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)),
+ IndexCount = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ Growth30dMb = growth,
+ DailyGrowthRateMb = daysBack > 0 ? growth / daysBack : 0m,
+ GrowthPct30d = earlier > 0 ? growth * 100m / earlier : 0m
+ });
+ }
+
+ await reader.NextResultAsync();
+
+ while (await reader.ReadAsync())
+ {
+ var schema = reader.IsDBNull(0) ? "" : reader.GetString(0);
+ var table = reader.IsDBNull(1) ? "" : reader.GetString(1);
+ var day = reader.IsDBNull(2) ? DateTime.MinValue : reader.GetDateTime(2);
+ var reservedMb = reader.IsDBNull(3) ? 0d : Convert.ToDouble(reader.GetValue(3));
+ samples.Add(new FinOpsObjectDaySample($"{schema}.{table}", day, reservedMb));
+ }
+ }
+
+ return (objects, samples);
+ }
+
+ ///
+ /// Per-index detail for a single object at its database's latest snapshot (#1138 §3C): the leaf of
+ /// the Storage Growth → object → index drill, folding the old Index Usage tab's per-index seeks /
+ /// scans / lookups / updates alongside size. Reuses .
+ ///
+ public async Task> GetObjectIndexDetailAsync(string databaseName, string schemaName, string tableName)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ ios.database_name,
+ ios.schema_name,
+ ios.table_name,
+ ios.index_name,
+ ios.index_type_desc,
+ ios.index_id,
+ ios.reserved_mb,
+ ios.total_rows,
+ user_seeks = ISNULL(ios.user_seeks, 0),
+ user_scans = ISNULL(ios.user_scans, 0),
+ user_lookups = ISNULL(ios.user_lookups, 0),
+ total_reads = ios.total_reads,
+ user_updates = ISNULL(ios.user_updates, 0),
+ last_user_access =
+ (
+ SELECT MAX(v)
+ FROM
+ (
+ VALUES
+ (ios.last_user_seek),
+ (ios.last_user_scan),
+ (ios.last_user_lookup),
+ (ios.last_user_update)
+ ) AS x (v)
+ ),
+ classification =
+ CASE
+ WHEN ios.total_reads = 0 AND ISNULL(ios.user_updates, 0) = 0
+ THEN N'Unused'
+ WHEN ios.total_reads = 0 AND ISNULL(ios.user_updates, 0) > 0
+ THEN N'Write-only'
+ ELSE N'Active'
+ END
+FROM collect.index_object_stats AS ios
+WHERE ios.database_name = @db
+AND ios.schema_name = @schema
+AND ios.table_name = @table
+AND ios.collection_time =
+(
+ SELECT MAX(collection_time)
+ FROM collect.index_object_stats
+ WHERE database_name = @db
+)
+ORDER BY
+ ios.index_id
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@db", databaseName);
+ command.Parameters.AddWithValue("@schema", schemaName);
+ command.Parameters.AddWithValue("@table", tableName);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ObjectIndexDetail", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new IndexUsageRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ TableName = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ IndexName = reader.IsDBNull(3) ? "(heap)" : reader.GetString(3),
+ IndexTypeDesc = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ IndexId = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ ReservedMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ TotalRows = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)),
+ UserSeeks = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)),
+ UserScans = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)),
+ UserLookups = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)),
+ TotalReads = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)),
+ UserUpdates = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
+ LastUserAccess = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
+ Classification = reader.IsDBNull(14) ? "" : reader.GetString(14)
});
}
}
@@ -431,5 +794,18 @@ public class IndexLockingRow
public long IndexLockPromotionCount { get; set; }
public long PageLatchWaitInMs { get; set; }
public long PageIoLatchWaitInMs { get; set; }
+ public long PageLatchWaitCount { get; set; }
+ public long PageIoLatchWaitCount { get; set; }
+
+ ///
+ /// Per-column 0..1 log color-scale intensities for the four *_wait_in_ms cells (#1138 §3B). Set by
+ /// the loader after fetch (each column normalized over the visible rows via
+ /// ); bound to the
+ /// cell background through HeatIntensityToBrushConverter. Not from the database.
+ ///
+ public double RowLockHeat { get; set; }
+ public double PageLockHeat { get; set; }
+ public double PageLatchHeat { get; set; }
+ public double PageIoLatchHeat { get; set; }
}
}
diff --git a/Lite.Tests/FinOpsHeatmapBuilderTests.cs b/Lite.Tests/FinOpsHeatmapBuilderTests.cs
new file mode 100644
index 00000000..b10d3f6b
--- /dev/null
+++ b/Lite.Tests/FinOpsHeatmapBuilderTests.cs
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace PerformanceMonitorLite.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. Mirror of Dashboard.Tests for parity.
+///
+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]);
+ 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);
+ }
+
+ // ── ColumnLogIntensities (Phase 2 color-scale binding) ──
+
+ [Fact]
+ public void ColumnLogIntensities_MaxIsOne_ZeroIsZero_OthersBetween()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 0, 10, 100 });
+
+ Assert.Equal(0.0, intensities[0]); // zero -> no shade
+ Assert.Equal(1.0, intensities[2], 6); // column max -> full shade
+ Assert.InRange(intensities[1], 0.0001, 0.9999); // mid -> partial
+ Assert.True(intensities[1] < intensities[2]);
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_AllZero_ReturnsAllZero()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 0, 0, 0 });
+ Assert.All(intensities, v => Assert.Equal(0.0, v)); // max=0 divide-by-zero guard (§3B)
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_NegativeClampsToZero()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { -5, 100 });
+ Assert.Equal(0.0, intensities[0]);
+ Assert.Equal(1.0, intensities[1], 6);
+ }
+
+ [Fact]
+ public void ColumnLogIntensities_LogScale_CompressesLargeRange()
+ {
+ var intensities = FinOpsHeatmapBuilder.ColumnLogIntensities(new long[] { 10, 10000 });
+ Assert.True(intensities[0] > 0.2, $"log scale should keep the small value visible, got {intensities[0]}");
+ Assert.Equal(1.0, intensities[1], 6);
+ }
+}
diff --git a/Lite.Tests/IndexObjectStatsTests.cs b/Lite.Tests/IndexObjectStatsTests.cs
index 08ae83c1..f6dc5d51 100644
--- a/Lite.Tests/IndexObjectStatsTests.cs
+++ b/Lite.Tests/IndexObjectStatsTests.cs
@@ -140,6 +140,58 @@ public async Task IndexLocking_ReturnsContendedObjects()
Assert.Equal(100_000, hot!.RowLockWaitInMs);
}
+ // ── #1138 object-growth heatmap producer ──
+
+ [Fact]
+ public async Task ObjectGrowthHeatmap_RanksByGrowth_AndReturnsDailySeries()
+ {
+ await SeedScenarioAsync();
+ var (objects, samples) = await _dataService.GetObjectGrowthHeatmapDataAsync(ServerId, "AppDb", daysBack: 30, topN: 20);
+
+ Assert.NotEmpty(objects);
+ // BigTable grew 200 -> 600 MB; the others are flat, so BigTable ranks first by growth.
+ Assert.Equal("BigTable", objects[0].TableName);
+ Assert.Equal(400m, objects[0].Growth30dMb);
+
+ // The daily series for BigTable carries both snapshots (prior + latest), keyed schema.table.
+ var bigSamples = samples.Where(s => s.ObjectKey == "dbo.BigTable").ToList();
+ Assert.Equal(2, bigSamples.Count);
+ Assert.Contains(bigSamples, s => s.ReservedMb == 600);
+ Assert.Contains(bigSamples, s => s.ReservedMb == 200);
+ }
+
+ [Fact]
+ public async Task ObjectIndexDetail_ReturnsPerIndexRowsForObject()
+ {
+ await SeedScenarioAsync();
+ var rows = await _dataService.GetObjectIndexDetailAsync(ServerId, "AppDb", "dbo", "BigTable");
+
+ Assert.Single(rows);
+ Assert.Equal("PK_BigTable", rows[0].IndexName);
+ Assert.Equal(600m, rows[0].ReservedMb);
+ }
+
+ [Fact]
+ public async Task IndexLocking_DatabaseSelector_ListsContendedDatabases()
+ {
+ await SeedScenarioAsync();
+ var dbs = await _dataService.GetIndexLockingDatabasesAsync(ServerId);
+
+ // HotTable has lock waits in AppDb, so AppDb is offered in the selector.
+ Assert.Contains("AppDb", dbs);
+ }
+
+ [Fact]
+ public async Task IndexLocking_FilteredByDatabase_ReturnsThatDbOnly()
+ {
+ await SeedScenarioAsync();
+ var rows = await _dataService.GetIndexLockingAsync(ServerId, 200, "AppDb");
+
+ Assert.NotEmpty(rows);
+ Assert.All(rows, r => Assert.Equal("AppDb", r.DatabaseName));
+ Assert.Contains(rows, r => r.TableName == "HotTable" && r.RowLockWaitInMs == 100_000);
+ }
+
// ── anomaly detection ──
[Fact]
diff --git a/Lite/Controls/FinOpsTab.Locking.cs b/Lite/Controls/FinOpsTab.Locking.cs
new file mode 100644
index 00000000..a0d79af3
--- /dev/null
+++ b/Lite/Controls/FinOpsTab.Locking.cs
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using PerformanceMonitor.Common;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Controls;
+
+///
+/// #1138 Phase 2 — the Locking & Contention tab as a color-scaled wait-type grid (a heatmap in grid
+/// form) with a database selector and an index-detail drill. The four *_wait_in_ms columns are shaded
+/// per-column (log) by their wait category's hue via ; the
+/// numeric values stay visible. Cumulative snapshot, no delta (§3B), so this sidesteps the M1/M2 issues.
+///
+public partial class FinOpsTab : UserControl
+{
+ private enum LockingLevel { Parent, Detail }
+
+ private bool _suppressLockingDbChange;
+
+ /// Entry point (refresh / server change): reset to the grid, repopulate the DB selector, load.
+ private async Task LoadIndexLockingAsync(int serverId)
+ {
+ if (_dataService == null) return;
+ ShowLockingView(LockingLevel.Parent);
+ await PopulateLockingDbSelectorAsync(serverId);
+ await LoadIndexLockingGridAsync(serverId);
+ }
+
+ private async Task PopulateLockingDbSelectorAsync(int serverId)
+ {
+ if (_dataService == null) return;
+ try
+ {
+ var dbs = await Task.Run(() => _dataService.GetIndexLockingDatabasesAsync(serverId));
+ var items = new List { "All databases" };
+ items.AddRange(dbs);
+
+ _suppressLockingDbChange = true;
+ LockingDbSelector.ItemsSource = items;
+ LockingDbSelector.SelectedIndex = 0;
+ _suppressLockingDbChange = false;
+ }
+ catch (Exception ex)
+ {
+ _suppressLockingDbChange = false;
+ AppLogger.Error("FinOps", $"Failed to load locking databases: {ex.Message}");
+ }
+ }
+
+ private string? SelectedLockingDb()
+ => LockingDbSelector.SelectedIndex <= 0 ? null : LockingDbSelector.SelectedItem as string;
+
+ private async Task LoadIndexLockingGridAsync(int serverId)
+ {
+ if (_dataService == null) return;
+ try
+ {
+ var db = SelectedLockingDb();
+ var data = await Task.Run(() => _dataService.GetIndexLockingAsync(serverId, 200, db));
+ ApplyLockingHeat(data);
+ _indexLockingFilterMgr!.UpdateData(data);
+ NoIndexLockingMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ IndexLockingCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load index locking: {ex.Message}");
+ }
+ }
+
+ /// Per-column log color-scale over the visible rows (#1138 §3B) — each wait column independent.
+ private static void ApplyLockingHeat(List rows)
+ {
+ var rowLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.RowLockWaitInMs).ToList());
+ var pageLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLockWaitInMs).ToList());
+ var pageLatch = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLatchWaitInMs).ToList());
+ var pageIo = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageIoLatchWaitInMs).ToList());
+ for (int i = 0; i < rows.Count; i++)
+ {
+ rows[i].RowLockHeat = rowLock[i];
+ rows[i].PageLockHeat = pageLock[i];
+ rows[i].PageLatchHeat = pageLatch[i];
+ rows[i].PageIoLatchHeat = pageIo[i];
+ }
+ }
+
+ private async void LockingDbSelector_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressLockingDbChange || !IsLoaded || _dataService == null) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ ShowLockingView(LockingLevel.Parent);
+ await LoadIndexLockingGridAsync(serverId);
+ }
+
+ private void IndexLockingGrid_DoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (IndexLockingDataGrid.SelectedItem is IndexLockingRow row)
+ ShowLockingDetail(row);
+ }
+
+ private void LockingBack_Click(object sender, RoutedEventArgs e)
+ => ShowLockingView(LockingLevel.Parent);
+
+ private void ShowLockingView(LockingLevel level)
+ {
+ LockingParentView.Visibility = level == LockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed;
+ LockingDetailView.Visibility = level == LockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed;
+ LockingBackButton.Visibility = level == LockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed;
+ var selectorVisible = level == LockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed;
+ LockingDbSelector.Visibility = selectorVisible;
+ LockingDbLabel.Visibility = selectorVisible;
+ LockingBreadcrumb.Text = level == LockingLevel.Detail ? "Locking & Contention › index detail" : "Locking & Contention";
+ }
+
+ private void ShowLockingDetail(IndexLockingRow row)
+ {
+ LockingDetailPanel.DataContext = row;
+
+ LockingDetailIdentity.Children.Clear();
+ AddLockingDetailRow(LockingDetailIdentity, "Database", row.DatabaseName);
+ AddLockingDetailRow(LockingDetailIdentity, "Schema", row.SchemaName);
+ AddLockingDetailRow(LockingDetailIdentity, "Table", row.TableName);
+ AddLockingDetailRow(LockingDetailIdentity, "Index", row.IndexName);
+ AddLockingDetailRow(LockingDetailIdentity, "Type", row.IndexTypeDesc);
+ AddLockingDetailRow(LockingDetailIdentity, "Reserved (MB)", row.ReservedMb.ToString("N1"));
+ AddLockingDetailRow(LockingDetailIdentity, "Rows", row.TotalRows.ToString("N0"));
+
+ LockingDetailCounters.Children.Clear();
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Count", row.RowLockCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Wait Count", row.RowLockWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Row Lock Wait (ms)", row.RowLockWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Count", row.PageLockCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Wait Count", row.PageLockWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Lock Wait (ms)", row.PageLockWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Lock Escalations", row.IndexLockPromotionCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Latch Wait Count", row.PageLatchWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page Latch Wait (ms)", row.PageLatchWaitInMs.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page IO Latch Wait Count", row.PageIoLatchWaitCount.ToString("N0"));
+ AddLockingDetailRow(LockingDetailCounters, "Page IO Latch Wait (ms)", row.PageIoLatchWaitInMs.ToString("N0"));
+
+ ShowLockingView(LockingLevel.Detail);
+ }
+
+ private void AddLockingDetailRow(Panel panel, string label, string value)
+ {
+ var grid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(170) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = (Brush)FindResource("ForegroundMutedBrush")
+ };
+ var valueBlock = new TextBlock
+ {
+ Text = value,
+ Foreground = (Brush)FindResource("ForegroundBrush"),
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+ Grid.SetColumn(labelBlock, 0);
+ Grid.SetColumn(valueBlock, 1);
+ grid.Children.Add(labelBlock);
+ grid.Children.Add(valueBlock);
+ panel.Children.Add(grid);
+ }
+}
diff --git a/Lite/Controls/FinOpsTab.ObjectHeatmap.cs b/Lite/Controls/FinOpsTab.ObjectHeatmap.cs
new file mode 100644
index 00000000..8764907d
--- /dev/null
+++ b/Lite/Controls/FinOpsTab.ObjectHeatmap.cs
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.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 FinOpsTab : 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 StorageGrowthRow row)
+ await DrillToObjectsAsync(row.DatabaseName);
+ }
+
+ private async void StorageGrowthShowObjects_Click(object sender, RoutedEventArgs e)
+ {
+ if (GetFinOpsRow(sender) is StorageGrowthRow 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 || _dataService == null) return;
+ if (_storageLevel != StorageDrillLevel.Objects || string.IsNullOrEmpty(_objDrillDb)) return;
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadObjectGrowthAsync(serverId, _objDrillDb);
+ }
+
+ private async Task DrillToObjectsAsync(string databaseName)
+ {
+ if (string.IsNullOrEmpty(databaseName)) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ _objDrillDb = databaseName;
+ ShowStorageView(StorageDrillLevel.Objects);
+ await LoadObjectGrowthAsync(serverId, databaseName);
+ }
+
+ private async Task DrillToIndexesAsync(string databaseName, string schemaName, string tableName)
+ {
+ if (string.IsNullOrEmpty(tableName)) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ _objDrillSchema = schemaName;
+ _objDrillTable = tableName;
+ ShowStorageView(StorageDrillLevel.Indexes);
+ await LoadObjectIndexDetailAsync(serverId, databaseName, schemaName, tableName);
+ }
+
+ private async Task LoadObjectGrowthAsync(int serverId, string databaseName)
+ {
+ if (_dataService == null) return;
+ try
+ {
+ var days = GetObjectHeatmapDaysBack();
+ var (objects, samples) = await Task.Run(() => _dataService.GetObjectGrowthHeatmapDataAsync(serverId, databaseName, days));
+
+ // One canonical, deterministic top-of-chart-first ranking drives BOTH the companion grid and
+ // the heatmap rows, so they can never disagree (and stay identical across Dashboard/Lite).
+ var orderedKeys = FinOpsHeatmapBuilder.RankTopGrowers(
+ objects.Select(o => ($"{o.SchemaName}.{o.TableName}", (double)o.Growth30dMb)), objects.Count);
+ var byKey = objects.ToDictionary(o => $"{o.SchemaName}.{o.TableName}");
+ var orderedObjects = orderedKeys.Select(k => byKey[k]).ToList();
+
+ ObjectGrowthDetailGrid.ItemsSource = orderedObjects;
+ StorageGrowthCountIndicator.Text = orderedObjects.Count > 0 ? $"{orderedObjects.Count} object(s)" : "";
+ ObjectGrowthNoDataMessage.Visibility = orderedObjects.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ // Heatmap rows are bottom-to-top: the renderer flips vertically (row 0 = bottom), so reverse
+ // the top-first ranking to put the biggest grower at the TOP of the chart.
+ var rowKeysBottomToTop = Enumerable.Reverse(orderedKeys).ToList();
+ _objHeatmapMatrix = FinOpsHeatmapBuilder.BuildMatrix(rowKeysBottomToTop, samples);
+ _objHeatmapHandle = FinOpsHeatmapRenderer.Render(
+ ObjectGrowthHeatmapChart,
+ _objHeatmapMatrix,
+ "Reserved (MB)",
+ $"{databaseName} — Object Reserved Footprint",
+ _objHeatmapHandle);
+ _objHeatmapPlottable = _objHeatmapHandle.Plottable;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load object growth heatmap: {ex.Message}");
+ }
+ }
+
+ private async Task LoadObjectIndexDetailAsync(int serverId, string databaseName, string schemaName, string tableName)
+ {
+ if (_dataService == null) return;
+ try
+ {
+ var data = await Task.Run(() => _dataService.GetObjectIndexDetailAsync(serverId, 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)
+ {
+ AppLogger.Error("FinOps", $"Failed to load object index detail: {ex.Message}");
+ }
+ }
+
+ // ── 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/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml
index e260e455..77d25c0b 100644
--- a/Lite/Controls/FinOpsTab.xaml
+++ b/Lite/Controls/FinOpsTab.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="450" d:DesignWidth="800">
@@ -57,6 +59,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -771,7 +790,9 @@
-
+
+
+
+
+
+
+
+
+
+ SelectionMode="Single"
+ MouseDoubleClick="StorageGrowthGrid_DoubleClick"
+ RowStyle="{StaticResource StorageGrowthRowStyle}">
@@ -899,131 +933,88 @@
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
@@ -1031,20 +1022,29 @@
-
+
-
+
+
+
+
+ HeadersVisibility="Column" SelectionMode="Single" MouseDoubleClick="IndexLockingGrid_DoubleClick"
+ RowStyle="{StaticResource DefaultRowStyle}" ScrollViewer.HorizontalScrollBarVisibility="Auto">
@@ -1065,17 +1065,66 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs
index 809059d6..322e5930 100644
--- a/Lite/Controls/FinOpsTab.xaml.cs
+++ b/Lite/Controls/FinOpsTab.xaml.cs
@@ -50,8 +50,6 @@ public partial class FinOpsTab : UserControl
private DataGridFilterManager? _waitCategoryFilterMgr;
private DataGridFilterManager? _expensiveQueriesFilterMgr;
private DataGridFilterManager? _memoryGrantFilterMgr;
- private DataGridFilterManager? _objectSizeGrowthFilterMgr;
- private DataGridFilterManager? _indexUsageFilterMgr;
private DataGridFilterManager? _indexLockingFilterMgr;
public FinOpsTab()
@@ -216,8 +214,6 @@ await System.Threading.Tasks.Task.WhenAll(
LoadApplicationConnectionsAsync(serverId),
LoadDatabaseSizesAsync(serverId),
LoadStorageGrowthAsync(serverId),
- LoadObjectSizeGrowthAsync(serverId),
- LoadIndexUsageAsync(serverId),
LoadIndexLockingAsync(serverId),
LoadIdleDatabasesAsync(serverId),
LoadTempdbSummaryAsync(serverId),
@@ -604,68 +600,13 @@ private async System.Threading.Tasks.Task LoadStorageGrowthAsync(int serverId)
}
// ============================================
- // 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 (FinOpsTab.ObjectHeatmap.cs).
+ // The read methods GetObjectSizeGrowthAsync / GetIndexUsageAsync remain on LocalDataService for MCP.
// ============================================
- private async System.Threading.Tasks.Task LoadObjectSizeGrowthAsync(int serverId)
- {
- if (_dataService == null) return;
- try
- {
- var data = await Task.Run(() => _dataService.GetObjectSizeGrowthAsync(serverId));
- _objectSizeGrowthFilterMgr!.UpdateData(data);
- NoObjectSizeGrowthMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- ObjectSizeGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} table(s)" : "";
- }
- catch (Exception ex)
- {
- AppLogger.Error("FinOps", $"Failed to load object size/growth: {ex.Message}");
- }
- }
-
- private async System.Threading.Tasks.Task LoadIndexUsageAsync(int serverId)
- {
- if (_dataService == null) return;
- try
- {
- var data = await Task.Run(() => _dataService.GetIndexUsageAsync(serverId));
- _indexUsageFilterMgr!.UpdateData(data);
- NoIndexUsageMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- IndexUsageCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : "";
- }
- catch (Exception ex)
- {
- AppLogger.Error("FinOps", $"Failed to load index usage: {ex.Message}");
- }
- }
-
- private async System.Threading.Tasks.Task LoadIndexLockingAsync(int serverId)
- {
- if (_dataService == null) return;
- try
- {
- var data = await Task.Run(() => _dataService.GetIndexLockingAsync(serverId));
- _indexLockingFilterMgr!.UpdateData(data);
- NoIndexLockingMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- IndexLockingCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : "";
- }
- catch (Exception ex)
- {
- AppLogger.Error("FinOps", $"Failed to load index locking: {ex.Message}");
- }
- }
-
- private async void RefreshObjectSizeGrowth_Click(object sender, RoutedEventArgs e)
- {
- var serverId = GetSelectedServerId();
- if (serverId != 0) await LoadObjectSizeGrowthAsync(serverId);
- }
-
- private async void RefreshIndexUsage_Click(object sender, RoutedEventArgs e)
- {
- var serverId = GetSelectedServerId();
- if (serverId != 0) await LoadIndexUsageAsync(serverId);
- }
+ // LoadIndexLockingAsync (the #1138 color-scaled grid + DB selector + index drill) lives in
+ // FinOpsTab.Locking.cs.
private async void RefreshIndexLocking_Click(object sender, RoutedEventArgs e)
{
@@ -846,6 +787,7 @@ private async System.Threading.Tasks.Task LoadMemoryGrantEfficiencyAsync(int ser
private async void ServerSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
+ ResetStorageDrill(); // a new server invalidates any open object/index drill
await LoadPerServerDataAsync();
}
@@ -887,7 +829,20 @@ private async void RefreshServerInventory_Click(object sender, RoutedEventArgs e
private async void RefreshStorageGrowth_Click(object sender, RoutedEventArgs e)
{
var serverId = GetSelectedServerId();
- if (serverId != 0) await LoadStorageGrowthAsync(serverId);
+ if (serverId == 0) return;
+ // 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(serverId, _objDrillDb);
+ break;
+ case StorageDrillLevel.Indexes when !string.IsNullOrEmpty(_objDrillTable):
+ await LoadObjectIndexDetailAsync(serverId, _objDrillDb, _objDrillSchema, _objDrillTable);
+ break;
+ default:
+ await LoadStorageGrowthAsync(serverId);
+ break;
+ }
}
private async void WaitStatsTimeRange_Changed(object sender, SelectionChangedEventArgs e)
@@ -1115,8 +1070,6 @@ private void InitializeFilterManagers()
_waitCategoryFilterMgr = new DataGridFilterManager(WaitCategorySummaryDataGrid);
_expensiveQueriesFilterMgr = new DataGridFilterManager(ExpensiveQueriesDataGrid);
_memoryGrantFilterMgr = new DataGridFilterManager(MemoryGrantEfficiencyDataGrid);
- _objectSizeGrowthFilterMgr = new DataGridFilterManager(ObjectSizeGrowthDataGrid);
- _indexUsageFilterMgr = new DataGridFilterManager(IndexUsageDataGrid);
_indexLockingFilterMgr = new DataGridFilterManager(IndexLockingDataGrid);
_filterManagers[DatabaseResourcesDataGrid] = _dbResourcesFilterMgr;
@@ -1132,8 +1085,6 @@ private void InitializeFilterManagers()
_filterManagers[WaitCategorySummaryDataGrid] = _waitCategoryFilterMgr;
_filterManagers[ExpensiveQueriesDataGrid] = _expensiveQueriesFilterMgr;
_filterManagers[MemoryGrantEfficiencyDataGrid] = _memoryGrantFilterMgr;
- _filterManagers[ObjectSizeGrowthDataGrid] = _objectSizeGrowthFilterMgr;
- _filterManagers[IndexUsageDataGrid] = _indexUsageFilterMgr;
_filterManagers[IndexLockingDataGrid] = _indexLockingFilterMgr;
}
diff --git a/Lite/Services/LocalDataService.FinOps.IndexObjects.cs b/Lite/Services/LocalDataService.FinOps.IndexObjects.cs
index 0dbe5067..9549c65a 100644
--- a/Lite/Services/LocalDataService.FinOps.IndexObjects.cs
+++ b/Lite/Services/LocalDataService.FinOps.IndexObjects.cs
@@ -10,6 +10,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DuckDB.NET.Data;
+using PerformanceMonitor.Common;
namespace PerformanceMonitorLite.Services;
@@ -191,47 +192,62 @@ reserved_mb DESC
}
///
- /// Per-index locking/latch contention from the latest snapshot for a server.
+ /// Per-index locking/latch contention at each database's latest snapshot for a server, top objects by
+ /// total lock+latch wait. Cumulative totals, no delta (#1138 §3B). Optionally scoped to one database
+ /// (the Locking grid's DB selector); per-database latest so "all databases" shows every DB's newest row.
///
- public async Task> GetIndexLockingAsync(int serverId, int topN = 200)
+ public async Task> GetIndexLockingAsync(int serverId, int topN = 200, string? databaseName = null)
{
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
+ // Build the optional DB filter as literal SQL so a NULL parameter never has to be typed by DuckDB.
+ var dbFilter = databaseName == null ? "" : " AND database_name = $2";
+
command.CommandText = $@"
+WITH latest AS (
+ SELECT database_name, MAX(collection_time) AS latest_time
+ FROM v_index_object_stats
+ WHERE server_id = $1{dbFilter}
+ GROUP BY database_name
+)
SELECT
- database_name,
- schema_name,
- table_name,
- index_name,
- index_type_desc,
- reserved_mb,
- total_rows,
- COALESCE(row_lock_count, 0) AS row_lock_count,
- COALESCE(row_lock_wait_count, 0) AS row_lock_wait_count,
- COALESCE(row_lock_wait_in_ms, 0) AS row_lock_wait_in_ms,
- COALESCE(page_lock_count, 0) AS page_lock_count,
- COALESCE(page_lock_wait_count, 0) AS page_lock_wait_count,
- COALESCE(page_lock_wait_in_ms, 0) AS page_lock_wait_in_ms,
- COALESCE(index_lock_promotion_count, 0) AS index_lock_promotion_count,
- COALESCE(page_latch_wait_in_ms, 0) AS page_latch_wait_in_ms,
- COALESCE(page_io_latch_wait_in_ms, 0) AS page_io_latch_wait_in_ms
-FROM v_index_object_stats
-WHERE server_id = $1
-AND collection_time = (SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1)
+ ios.database_name,
+ ios.schema_name,
+ ios.table_name,
+ ios.index_name,
+ ios.index_type_desc,
+ ios.reserved_mb,
+ ios.total_rows,
+ COALESCE(ios.row_lock_count, 0) AS row_lock_count,
+ COALESCE(ios.row_lock_wait_count, 0) AS row_lock_wait_count,
+ COALESCE(ios.row_lock_wait_in_ms, 0) AS row_lock_wait_in_ms,
+ COALESCE(ios.page_lock_count, 0) AS page_lock_count,
+ COALESCE(ios.page_lock_wait_count, 0) AS page_lock_wait_count,
+ COALESCE(ios.page_lock_wait_in_ms, 0) AS page_lock_wait_in_ms,
+ COALESCE(ios.index_lock_promotion_count, 0) AS index_lock_promotion_count,
+ COALESCE(ios.page_latch_wait_in_ms, 0) AS page_latch_wait_in_ms,
+ COALESCE(ios.page_io_latch_wait_in_ms, 0) AS page_io_latch_wait_in_ms,
+ COALESCE(ios.page_latch_wait_count, 0) AS page_latch_wait_count,
+ COALESCE(ios.page_io_latch_wait_count, 0) AS page_io_latch_wait_count
+FROM v_index_object_stats ios
+JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time
+WHERE ios.server_id = $1
AND (
- COALESCE(row_lock_wait_in_ms, 0) > 0
- OR COALESCE(page_lock_wait_in_ms, 0) > 0
- OR COALESCE(page_latch_wait_in_ms, 0) > 0
- OR COALESCE(page_io_latch_wait_in_ms, 0) > 0
- OR COALESCE(index_lock_promotion_count, 0) > 0
+ COALESCE(ios.row_lock_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0
+ OR COALESCE(ios.index_lock_promotion_count, 0) > 0
)
ORDER BY
- COALESCE(row_lock_wait_in_ms, 0) + COALESCE(page_lock_wait_in_ms, 0)
- + COALESCE(page_latch_wait_in_ms, 0) + COALESCE(page_io_latch_wait_in_ms, 0) DESC
+ COALESCE(ios.row_lock_wait_in_ms, 0) + COALESCE(ios.page_lock_wait_in_ms, 0)
+ + COALESCE(ios.page_latch_wait_in_ms, 0) + COALESCE(ios.page_io_latch_wait_in_ms, 0) DESC
LIMIT {topN}";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ if (databaseName != null)
+ command.Parameters.Add(new DuckDBParameter { Value = databaseName });
var items = new List();
using var reader = await command.ExecuteReaderAsync();
@@ -254,7 +270,263 @@ ORDER BY
PageLockWaitInMs = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
IndexLockPromotionCount = reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13)),
PageLatchWaitInMs = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)),
- PageIoLatchWaitInMs = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))
+ PageIoLatchWaitInMs = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)),
+ PageLatchWaitCount = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)),
+ PageIoLatchWaitCount = reader.IsDBNull(17) ? 0L : Convert.ToInt64(reader.GetValue(17))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Distinct databases that have any lock/latch contention at their latest snapshot — the source for the
+ /// Locking grid's database selector (#1138 §3B).
+ ///
+ public async Task> GetIndexLockingDatabasesAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ command.CommandText = @"
+WITH latest AS (
+ SELECT database_name, MAX(collection_time) AS latest_time
+ FROM v_index_object_stats
+ WHERE server_id = $1
+ GROUP BY database_name
+)
+SELECT DISTINCT ios.database_name
+FROM v_index_object_stats ios
+JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time
+WHERE ios.server_id = $1
+AND (
+ COALESCE(ios.row_lock_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0
+ OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0
+ OR COALESCE(ios.index_lock_promotion_count, 0) > 0
+)
+ORDER BY ios.database_name";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ if (!reader.IsDBNull(0)) items.Add(reader.GetString(0));
+ }
+ return items;
+ }
+
+ // ============================================
+ // #1138 — Object-growth heatmap drill (per-DB)
+ // ============================================
+
+ ///
+ /// Data for the per-database object-growth heatmap drill (#1138 §3A): the top-N objects in a single
+ /// database ranked by reserved-MB growth over the window, plus their daily reserved-MB series for the
+ /// heatmap. Two-step + DB-scoped for perf — the series only touches the ranked top-N. Returns the ranked
+ /// summary rows (companion grid) and the long-form samples (pivoted to a matrix by FinOpsHeatmapBuilder).
+ ///
+ public async Task<(List Objects, List Samples)> GetObjectGrowthHeatmapDataAsync(
+ int serverId, string databaseName, int daysBack = 30, int topN = 20)
+ {
+ using var connection = await OpenConnectionAsync();
+
+ var windowStart = DateTime.UtcNow.AddDays(-daysBack);
+
+ var objects = new List();
+ var samples = new List();
+
+ // Query 1 — ranked top-N summary (drives the companion grid).
+ using (var command = connection.CreateCommand())
+ {
+ command.CommandText = $@"
+WITH bounds AS (
+ SELECT MAX(collection_time) AS latest_time, MIN(collection_time) AS earliest_time
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time >= $3
+),
+latest AS (
+ SELECT schema_name, table_name,
+ SUM(reserved_mb) AS cur_reserved_mb,
+ SUM(used_mb) AS cur_used_mb,
+ MAX(total_rows) AS cur_rows,
+ COUNT(*) AS index_count
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT latest_time FROM bounds)
+ GROUP BY schema_name, table_name
+),
+earliest AS (
+ SELECT schema_name, table_name, SUM(reserved_mb) AS e_reserved_mb
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT earliest_time FROM bounds)
+ GROUP BY schema_name, table_name
+)
+SELECT
+ l.schema_name,
+ l.table_name,
+ l.cur_reserved_mb,
+ l.cur_used_mb,
+ l.cur_rows,
+ l.index_count,
+ l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb) AS growth_mb
+FROM latest l
+LEFT JOIN earliest e ON e.schema_name = l.schema_name AND e.table_name = l.table_name
+ORDER BY growth_mb DESC, l.schema_name, l.table_name
+LIMIT {topN}";
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = databaseName });
+ command.Parameters.Add(new DuckDBParameter { Value = windowStart });
+
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var current = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2));
+ var growth = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6));
+ var earlier = current - growth;
+ objects.Add(new ObjectSizeGrowthRow
+ {
+ DatabaseName = databaseName,
+ SchemaName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TableName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ CurrentReservedMb = current,
+ CurrentUsedMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TotalRows = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)),
+ IndexCount = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ Growth30dMb = growth,
+ DailyGrowthRateMb = daysBack > 0 ? growth / daysBack : 0m,
+ GrowthPct30d = earlier > 0 ? growth * 100m / earlier : 0m
+ });
+ }
+ }
+
+ // Query 2 — daily reserved-MB series for the same ranked top-N (ranking recomputed as a CTE; DuckDB
+ // .NET returns one result set per command, so this is a second command rather than NextResult()).
+ using (var command = connection.CreateCommand())
+ {
+ command.CommandText = $@"
+WITH bounds AS (
+ SELECT MAX(collection_time) AS latest_time, MIN(collection_time) AS earliest_time
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time >= $3
+),
+latest AS (
+ SELECT schema_name, table_name, SUM(reserved_mb) AS cur_reserved_mb
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT latest_time FROM bounds)
+ GROUP BY schema_name, table_name
+),
+earliest AS (
+ SELECT schema_name, table_name, SUM(reserved_mb) AS e_reserved_mb
+ FROM v_index_object_stats
+ WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT earliest_time FROM bounds)
+ GROUP BY schema_name, table_name
+),
+ranked AS (
+ SELECT l.schema_name, l.table_name,
+ l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb) AS growth_mb
+ FROM latest l
+ LEFT JOIN earliest e ON e.schema_name = l.schema_name AND e.table_name = l.table_name
+ ORDER BY growth_mb DESC, l.schema_name, l.table_name
+ LIMIT {topN}
+)
+SELECT
+ ios.schema_name,
+ ios.table_name,
+ date_trunc('day', ios.collection_time) AS the_day,
+ SUM(ios.reserved_mb) AS reserved_mb
+FROM v_index_object_stats ios
+JOIN ranked r ON r.schema_name = ios.schema_name AND r.table_name = ios.table_name
+WHERE ios.server_id = $1 AND ios.database_name = $2 AND ios.collection_time >= $3
+GROUP BY ios.schema_name, ios.table_name, date_trunc('day', ios.collection_time)
+ORDER BY ios.schema_name, ios.table_name, the_day";
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = databaseName });
+ command.Parameters.Add(new DuckDBParameter { Value = windowStart });
+
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var schema = reader.IsDBNull(0) ? "" : reader.GetString(0);
+ var table = reader.IsDBNull(1) ? "" : reader.GetString(1);
+ var day = reader.IsDBNull(2) ? DateTime.MinValue : reader.GetDateTime(2);
+ var reservedMb = reader.IsDBNull(3) ? 0d : Convert.ToDouble(reader.GetValue(3));
+ samples.Add(new FinOpsObjectDaySample($"{schema}.{table}", day, reservedMb));
+ }
+ }
+
+ return (objects, samples);
+ }
+
+ ///
+ /// Per-index detail for a single object at its database's latest snapshot (#1138 §3C): the leaf of the
+ /// Storage Growth → object → index drill, folding the old Index Usage tab's per-index usage alongside
+ /// size. Reuses .
+ ///
+ public async Task> GetObjectIndexDetailAsync(int serverId, string databaseName, string schemaName, string tableName)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ command.CommandText = @"
+SELECT
+ database_name,
+ schema_name,
+ table_name,
+ index_name,
+ index_type_desc,
+ index_id,
+ reserved_mb,
+ total_rows,
+ COALESCE(user_seeks, 0) AS user_seeks,
+ COALESCE(user_scans, 0) AS user_scans,
+ COALESCE(user_lookups, 0) AS user_lookups,
+ COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) AS total_reads,
+ COALESCE(user_updates, 0) AS user_updates,
+ GREATEST(last_user_seek, last_user_scan, last_user_lookup, last_user_update) AS last_user_access,
+ CASE
+ WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0
+ AND COALESCE(user_updates, 0) = 0 THEN 'Unused'
+ WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0
+ AND COALESCE(user_updates, 0) > 0 THEN 'Write-only'
+ ELSE 'Active'
+ END AS classification
+FROM v_index_object_stats
+WHERE server_id = $1
+AND database_name = $2
+AND schema_name = $3
+AND table_name = $4
+AND collection_time = (
+ SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1 AND database_name = $2)
+ORDER BY index_id";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = databaseName });
+ command.Parameters.Add(new DuckDBParameter { Value = schemaName });
+ command.Parameters.Add(new DuckDBParameter { Value = tableName });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new IndexUsageRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ TableName = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ IndexName = reader.IsDBNull(3) ? "(heap)" : reader.GetString(3),
+ IndexTypeDesc = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ IndexId = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ ReservedMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ TotalRows = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)),
+ UserSeeks = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)),
+ UserScans = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)),
+ UserLookups = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)),
+ TotalReads = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)),
+ UserUpdates = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
+ LastUserAccess = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
+ Classification = reader.IsDBNull(14) ? "" : reader.GetString(14)
});
}
return items;
@@ -316,4 +588,17 @@ public class IndexLockingRow
public long IndexLockPromotionCount { get; set; }
public long PageLatchWaitInMs { get; set; }
public long PageIoLatchWaitInMs { get; set; }
+ public long PageLatchWaitCount { get; set; }
+ public long PageIoLatchWaitCount { get; set; }
+
+ ///
+ /// Per-column 0..1 log color-scale intensities for the four *_wait_in_ms cells (#1138 §3B). Set by the
+ /// loader after fetch (each column normalized over the visible rows via
+ /// ); bound to the cell
+ /// background through HeatIntensityToBrushConverter. Not from the database.
+ ///
+ public double RowLockHeat { get; set; }
+ public double PageLockHeat { get; set; }
+ public double PageLatchHeat { get; set; }
+ public double PageIoLatchHeat { get; set; }
}
diff --git a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs
index afe1e6c9..fa4db587 100644
--- a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs
+++ b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs
@@ -35,9 +35,10 @@ public partial class RemoteCollectorService
///
/// Collects per-table and per-index size, usage, and locking statistics for growth
/// trending, unused-index detection, and contention analysis.
- /// Size columns are absolute point-in-time values; usage and locking counters are
- /// cumulative (reset on instance restart / DB detach / AUTO_CLOSE) - sqlserver_start_time
- /// carries the reset boundary so deltas can be computed safely in the read layer.
+ /// Size columns are absolute point-in-time values (growth = size(t2) - size(t1) in the
+ /// read layer); usage and locking counters are raw cumulative totals shown as-is - the
+ /// read layer does NOT compute counter deltas (sqlserver_start_time flags only
+ /// restart / DB detach / AUTO_CLOSE resets, not the metadata-cache-eviction reset; see #1138).
/// All three DMVs are database-scoped, so collection runs ONE COMMAND PER DATABASE:
/// on-prem enumerates databases then sends each through [db].sys.sp_executesql; Azure
/// SQL DB connects to each database individually. Each database is collected with its
diff --git a/PerformanceMonitor.Common/FinOpsHeatmap.cs b/PerformanceMonitor.Common/FinOpsHeatmap.cs
new file mode 100644
index 00000000..66017a67
--- /dev/null
+++ b/PerformanceMonitor.Common/FinOpsHeatmap.cs
@@ -0,0 +1,137 @@
+/*
+ * 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;
+
+namespace PerformanceMonitor.Common
+{
+ ///
+ /// One (object, day) size sample feeding the FinOps object-growth heatmap.
+ /// is the absolute reserved footprint for that object on that day (already summed across its indexes
+ /// by the read layer), NOT a per-day delta — see #1138 plan §3A.
+ ///
+ public readonly record struct FinOpsObjectDaySample(string ObjectKey, DateTime Day, double ReservedMb);
+
+ ///
+ /// A long-form series pivoted into the dense matrix the heatmap renderer consumes. Row index 0 is the
+ /// BOTTOM row (matching the query-heatmap renderer's FlipVertically = true), so callers pass row
+ /// keys bottom-to-top. Columns are the distinct sample days, ascending left-to-right.
+ ///
+ public sealed class FinOpsHeatmapMatrix
+ {
+ public double[,] Intensities { get; init; } = new double[0, 0];
+ public string[] RowLabels { get; init; } = Array.Empty();
+ public DateTime[] Days { get; init; } = Array.Empty();
+
+ public bool IsEmpty => RowLabels.Length == 0 || Days.Length == 0;
+ }
+
+ ///
+ /// Pure, app-agnostic shaping helpers for the FinOps heatmaps (#1138). Lives in Common (no ScottPlot /
+ /// WPF dependency) so Dashboard and Lite share ONE implementation of the top-N ranking, the long→matrix
+ /// pivot, and the per-column color-scale math — the parity-critical logic — and both test projects can
+ /// exercise it directly without a database.
+ ///
+ public static class FinOpsHeatmapBuilder
+ {
+ ///
+ /// Ranks objects by growth descending (biggest grower first) and returns the top-N keys. Ties break
+ /// on the key (ordinal) so the result is deterministic. Used both to choose WHICH objects appear and
+ /// to order the companion grid; the heatmap reverses this for bottom-to-top row order.
+ ///
+ public static List RankTopGrowers(IEnumerable<(string Key, double Growth)> growthByKey, int topN)
+ {
+ if (growthByKey == null) throw new ArgumentNullException(nameof(growthByKey));
+ if (topN < 0) topN = 0;
+
+ return growthByKey
+ .OrderByDescending(x => x.Growth)
+ .ThenBy(x => x.Key, StringComparer.Ordinal)
+ .Take(topN)
+ .Select(x => x.Key)
+ .ToList();
+ }
+
+ ///
+ /// Pivots the long-form (object, day, reserved) samples into a dense [rows, cols] matrix. Rows follow
+ /// exactly (index 0 = bottom). Columns are the distinct days
+ /// present in , ascending. Cells with no sample stay 0.0, which the renderer
+ /// maps to a blank (NaN) cell — the desired "object absent / truncated that day" rendering (#1138 M2).
+ /// Samples whose key is not in are ignored; duplicate
+ /// (key, day) pairs are summed defensively.
+ ///
+ public static FinOpsHeatmapMatrix BuildMatrix(
+ IReadOnlyList rowKeysBottomToTop,
+ IEnumerable samples)
+ {
+ if (rowKeysBottomToTop == null) throw new ArgumentNullException(nameof(rowKeysBottomToTop));
+ if (samples == null) throw new ArgumentNullException(nameof(samples));
+
+ var sampleList = samples as IReadOnlyList ?? samples.ToList();
+
+ var rowIndex = new Dictionary(StringComparer.Ordinal);
+ for (int i = 0; i < rowKeysBottomToTop.Count; i++)
+ rowIndex[rowKeysBottomToTop[i]] = i; // last write wins on accidental dupes
+
+ var days = sampleList
+ .Select(s => s.Day)
+ .Distinct()
+ .OrderBy(d => d)
+ .ToArray();
+
+ var colIndex = new Dictionary();
+ for (int c = 0; c < days.Length; c++)
+ colIndex[days[c]] = c;
+
+ int rows = rowKeysBottomToTop.Count;
+ var intensities = new double[rows, days.Length];
+
+ foreach (var s in sampleList)
+ {
+ if (!rowIndex.TryGetValue(s.ObjectKey, out int r)) continue;
+ if (!colIndex.TryGetValue(s.Day, out int c)) continue;
+ intensities[r, c] += s.ReservedMb; // sum guards accidental same-(key,day) duplicates
+ }
+
+ return new FinOpsHeatmapMatrix
+ {
+ Intensities = intensities,
+ RowLabels = rowKeysBottomToTop.ToArray(),
+ Days = days
+ };
+ }
+
+ ///
+ /// Computes per-column color intensities (0..1) on a log1p scale, normalized to the column max. A
+ /// column whose max is 0 yields all-zero intensities (the max=0 divide-by-zero guard, #1138 §3B).
+ /// Negative values clamp to 0. This is the math behind the Locking grid's per-column cell shading;
+ /// each wait-type column is scaled independently so a quiet column never washes out under a loud one.
+ ///
+ public static double[] ColumnLogIntensities(IReadOnlyList values)
+ {
+ if (values == null) throw new ArgumentNullException(nameof(values));
+
+ var result = new double[values.Count];
+ long max = 0;
+ for (int i = 0; i < values.Count; i++)
+ if (values[i] > max) max = values[i];
+
+ if (max <= 0) return result; // all zero — guard against divide-by-zero
+
+ double denom = Math.Log(1 + max);
+ for (int i = 0; i < values.Count; i++)
+ {
+ long v = values[i];
+ result[i] = v <= 0 ? 0.0 : Math.Log(1 + v) / denom;
+ }
+ return result;
+ }
+ }
+}
diff --git a/PerformanceMonitor.Ui/FinOpsHeatmapRenderer.cs b/PerformanceMonitor.Ui/FinOpsHeatmapRenderer.cs
new file mode 100644
index 00000000..7e5b0a6c
--- /dev/null
+++ b/PerformanceMonitor.Ui/FinOpsHeatmapRenderer.cs
@@ -0,0 +1,156 @@
+/*
+ * 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 PerformanceMonitor.Common;
+using ScottPlot.WPF;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// What a call leaves on the chart: the heatmap plottable
+ /// (for hover hit-testing) and the colorbar panel (so the next render can remove it before re-adding).
+ /// The host stores this between renders, exactly as the query-heatmap code tracks its plottable + legend.
+ ///
+ public sealed class FinOpsHeatmapHandle
+ {
+ public ScottPlot.Plottables.Heatmap? Plottable { get; init; }
+ public ScottPlot.Panels.ColorBar? ColorBar { get; init; }
+ }
+
+ ///
+ /// The single, shared ScottPlot heatmap renderer for the FinOps object-growth heatmap (#1138), used by
+ /// BOTH apps so the two hand-duplicated FinOps hosts can never drift from each other (the parity risk
+ /// the plan calls out, R4/R5). It is the query-heatmap renderer with the count-specific couplings
+ /// stripped: continuous-metric colorbar (no integer "nice-number" ticks, no 10000 cap), a "M/d" daily
+ /// X axis, and a caller-supplied colorbar label. Chrome/theming flows through ,
+ /// identical to every other chart. The 0→NaN behaviour is reused unchanged (a blank cell = object
+ /// absent/empty that day), which is benign for an absolute-MB, growth-ranked top-N (plan §3A/M2).
+ ///
+ public static class FinOpsHeatmapRenderer
+ {
+ ///
+ /// Renders into and returns a handle for hover +
+ /// the next render's cleanup. Pass the previous handle so its colorbar panel is removed first (panels
+ /// survive Plot.Clear()). Row index 0 renders at the BOTTOM (FlipVertically), matching the
+ /// query heatmap, so the caller orders rows bottom-to-top.
+ ///
+ public static FinOpsHeatmapHandle Render(
+ WpfPlot chart,
+ FinOpsHeatmapMatrix matrix,
+ string colorBarLabel,
+ string title,
+ FinOpsHeatmapHandle? previous,
+ Func? colorBarTickFormat = null)
+ {
+ if (previous?.ColorBar != null)
+ chart.Plot.Axes.Remove(previous.ColorBar);
+ chart.Plot.Clear();
+ ChartStyle.ApplyThemeToChart(chart);
+
+ if (matrix.IsEmpty)
+ {
+ chart.Plot.Title($"{title} — No Data");
+ chart.Refresh();
+ return new FinOpsHeatmapHandle();
+ }
+
+ int numRows = matrix.Intensities.GetLength(0);
+ int numCols = matrix.Intensities.GetLength(1);
+
+ // Log1p scaling; NaN for empty/zero cells so they render as background (the renderer's existing,
+ // unchanged 0→NaN behaviour — see class summary).
+ var scaled = new double[numRows, numCols];
+ for (int r = 0; r < numRows; r++)
+ for (int c = 0; c < numCols; c++)
+ scaled[r, c] = matrix.Intensities[r, c] > 0
+ ? Math.Log(1 + matrix.Intensities[r, c])
+ : double.NaN;
+
+ var heatmap = chart.Plot.Add.Heatmap(scaled);
+ heatmap.FlipVertically = true; // row 0 at the bottom
+ heatmap.Colormap = new ScottPlot.Colormaps.Viridis();
+ heatmap.NaNCellColor = chart.Plot.DataBackground.Color;
+
+ // X-axis: daily date labels at column positions ("M/d"), ~12 labels max.
+ var xTicks = new ScottPlot.TickGenerators.NumericManual();
+ int xStep = Math.Max(1, numCols / 12);
+ for (int i = 0; i < numCols; i += xStep)
+ xTicks.AddMajor(i, matrix.Days[i].ToString("M/d", CultureInfo.InvariantCulture));
+ chart.Plot.Axes.Bottom.TickGenerator = xTicks;
+
+ // Y-axis: object identity labels.
+ var yTicks = new ScottPlot.TickGenerators.NumericManual();
+ for (int i = 0; i < matrix.RowLabels.Length; i++)
+ yTicks.AddMajor(i, matrix.RowLabels[i]);
+ chart.Plot.Axes.Left.TickGenerator = yTicks;
+
+ chart.Plot.Axes.SetLimitsX(-0.5, numCols - 0.5);
+ chart.Plot.Axes.SetLimitsY(-0.5, numRows - 0.5);
+
+ ChartStyle.ReapplyAxisColors(chart);
+
+ // Continuous-metric colorbar: log positions, "nice" round values up to the data max (no integer
+ // tick coercion, no 10000 cap — those were query-count specifics).
+ double maxRaw = 0;
+ for (int r = 0; r < numRows; r++)
+ for (int c = 0; c < numCols; c++)
+ if (matrix.Intensities[r, c] > maxRaw) maxRaw = matrix.Intensities[r, c];
+
+ var colorBar = new ScottPlot.Panels.ColorBar(heatmap, ScottPlot.Edge.Right) { Label = colorBarLabel };
+ var format = colorBarTickFormat ?? DefaultMetricTickFormat;
+ var cbTicks = new ScottPlot.TickGenerators.NumericManual();
+ cbTicks.AddMajor(0, "0");
+ foreach (var n in NiceTicksUpTo(maxRaw))
+ cbTicks.AddMajor(Math.Log(1 + n), format(n));
+ if (maxRaw > 0)
+ cbTicks.AddMajor(Math.Log(1 + maxRaw), format(maxRaw));
+ colorBar.Axis.TickGenerator = cbTicks;
+ colorBar.LabelStyle.ForeColor = chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor;
+ colorBar.Axis.TickLabelStyle.ForeColor = chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor;
+ chart.Plot.Axes.AddPanel(colorBar);
+
+ chart.Plot.Title(title);
+ chart.Plot.Axes.Title.Label.ForeColor = chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor;
+
+ chart.Refresh();
+ return new FinOpsHeatmapHandle { Plottable = heatmap, ColorBar = colorBar };
+ }
+
+ private static string DefaultMetricTickFormat(double v)
+ => v >= 100
+ ? v.ToString("N0", CultureInfo.InvariantCulture)
+ : v.ToString("N1", CultureInfo.InvariantCulture);
+
+ ///
+ /// "Nice" 1/2/5 × 10^k tick values strictly below , ascending. Scales to any
+ /// range (unlike the query heatmap's fixed {1..10000} list), so MB footprints of any size get a
+ /// readable continuous colorbar.
+ ///
+ private static IEnumerable NiceTicksUpTo(double max)
+ {
+ if (max <= 0) yield break;
+ double magnitude = 1;
+ // Start a few orders below the max so small-range heatmaps still get interior ticks.
+ while (magnitude * 10 <= max) magnitude *= 10;
+ while (magnitude >= 1 && magnitude > max / 1000.0) magnitude /= 10;
+ if (magnitude < 1) magnitude = 1;
+
+ for (double scale = magnitude; scale <= max; scale *= 10)
+ {
+ foreach (var mult in new[] { 1.0, 2.0, 5.0 })
+ {
+ double val = mult * scale;
+ if (val < max) yield return val;
+ }
+ }
+ }
+ }
+}
diff --git a/PerformanceMonitor.Ui/HeatIntensityToBrushConverter.cs b/PerformanceMonitor.Ui/HeatIntensityToBrushConverter.cs
new file mode 100644
index 00000000..1edd01f5
--- /dev/null
+++ b/PerformanceMonitor.Ui/HeatIntensityToBrushConverter.cs
@@ -0,0 +1,67 @@
+/*
+ * 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.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+using PerformanceMonitor.Common;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// Turns a 0..1 heat intensity into a themed cell-background brush for the Locking & Contention
+ /// grid (#1138 §3B). The ConverterParameter names the wait category whose hue to use — its color
+ /// comes from (the shared wait taxonomy) so the four wait-type
+ /// columns read as their kind (Lock / Buffer Latch / Buffer IO), and the hue is alpha-scaled by the
+ /// intensity so a quiet cell fades to the theme background and the numeric value stays readable in every
+ /// theme. The light/CoolBreeze hue override is honored via .
+ /// Shared in .Ui so both apps color cells identically.
+ ///
+ public sealed class HeatIntensityToBrushConverter : IValueConverter
+ {
+ /// Singleton — the converter is stateless, so one instance serves every cell in both apps.
+ public static readonly HeatIntensityToBrushConverter Instance = new();
+
+ // Max alpha at full intensity. Deliberately below 255 so the warm hue tints rather than masks the
+ // cell, keeping the right-aligned value text legible on dark and light themes alike.
+ private const double MaxAlpha = 200.0;
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ double intensity = value switch
+ {
+ double d => d,
+ float f => f,
+ _ => 0.0
+ };
+ if (double.IsNaN(intensity) || intensity <= 0)
+ return Brushes.Transparent;
+ if (intensity > 1) intensity = 1;
+
+ var category = parameter as string ?? "Others";
+ var hex = ChartPalette.WaitColor(category, ThemeManager.HasLightBackground);
+
+ try
+ {
+ var baseColor = (Color)ColorConverter.ConvertFromString(hex);
+ var a = (byte)Math.Round(intensity * MaxAlpha);
+ var brush = new SolidColorBrush(Color.FromArgb(a, baseColor.R, baseColor.G, baseColor.B));
+ brush.Freeze();
+ return brush;
+ }
+ catch
+ {
+ return Brushes.Transparent;
+ }
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql
index ea72a225..35d2e8b3 100644
--- a/install/02_create_tables.sql
+++ b/install/02_create_tables.sql
@@ -1533,9 +1533,12 @@ END;
Index/Object Statistics Table (FinOps)
Per-table and per-index size, usage, and locking stats for growth trending,
unused-index detection, and contention analysis. Size columns are absolute
-point-in-time values; usage and locking counters are cumulative (reset on
-instance restart / DB detach / AUTO_CLOSE) - sqlserver_start_time carries the
-reset boundary so deltas can be computed safely in the read layer.
+point-in-time values, so growth is a plain size(t2) - size(t1) in the read layer.
+Usage and locking counters are cumulative; the read layer shows them as raw totals
+at the latest snapshot - it does NOT compute counter deltas. sqlserver_start_time
+flags only an instance restart / DB detach / AUTO_CLOSE reset, NOT the
+metadata-cache-eviction reset that dm_db_index_operational_stats also performs, so
+a cumulative-counter delta is not reliable and is deliberately not attempted (#1138).
*/
IF OBJECT_ID(N'collect.index_object_stats', N'U') IS NULL
BEGIN
diff --git a/install/55_collect_index_object_stats.sql b/install/55_collect_index_object_stats.sql
index 22cccaea..a90db6b1 100644
--- a/install/55_collect_index_object_stats.sql
+++ b/install/55_collect_index_object_stats.sql
@@ -22,9 +22,11 @@ GO
Collector: index_object_stats_collector
Purpose: Captures per-table and per-index size, usage, and locking statistics
for growth trending, unused-index detection, and contention analysis.
-Collection Type: Point-in-time snapshot for sizes; cumulative counters for
- usage/locking (deltas derived in the read layer using
- sqlserver_start_time as the reset boundary).
+Collection Type: Point-in-time snapshot for sizes; raw cumulative counters for
+ usage/locking. The read layer shows size growth as size(t2) - size(t1)
+ and usage/locking as raw cumulative totals - it does NOT derive counter
+ deltas (sqlserver_start_time flags only restart/detach/AUTO_CLOSE resets,
+ not the metadata-cache-eviction reset; see #1138).
Target Table: collect.index_object_stats
Frequency: Every 1440 minutes (daily) - object grain is high volume.
Dependencies: sys.dm_db_partition_stats, sys.dm_db_index_usage_stats,