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 @@ - +