Skip to content

Commit 7fd992b

Browse files
erikdarlingdataClaudioESSilvacoderabbitai[bot]claude
authored
Add growth rate and VLF count columns (#567) (#625)
* Implement #567 for dashboard * Implement #567 for lite * Fix columns "order by" for dashboard * Fix columns "order by" for Lite * Fix upgrade script * Add filters to "Database Size" columns (matching Lite) * Do not swallow v22 migration failures. Update Lite/Database/DuckDbInitializer.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add upgrade script to the upgrade.txt * Remove the IF OBJECT_ID … ALTER TABLE block * Tidy up duplicate code * Fix collection health overflow and query stats DOP type mismatch - collection_health view: cast duration_ms and rows_collected to bigint before AVG/SUM to prevent arithmetic overflow after 7 days of data - collection_health C# reader: use Convert.ToInt64 for avg_duration_ms, update model property from int to long - query_stats reader: min_dop/max_dop columns are bigint in the table but reader called GetInt16() — changed to Convert.ToInt16(GetValue()) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clamp negative duration_ms to 0 in master collector DST clock-backward adjustments cause DATEDIFF(MILLISECOND, @start, SYSDATETIME()) to return negative values when the wall clock jumps back. This pollutes avg_duration_ms in report.collection_health. Clamp to 0 with IIF rather than switching to UTC, which would break consistency with existing data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix Database Sizes grid: scrollbar, filter handlers, per-file VLF count - Enable horizontal scrollbar (was Disabled, clipping new columns) - Unify all column filter buttons to DatabaseSizesFilter_Click (existing columns used FinOpsFilter_Click, causing split filter state) - VLF count: filter sys.dm_db_log_info by file_id so each log file gets its own count instead of the whole-database total (Dashboard + Lite) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix uninitialized @error_message in database size collector CATCH The per-database inner CATCH referenced @error_message which was never set — debug output always showed NULL. Use ERROR_MESSAGE() directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ClaudioESSilva <claudiosil100@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cláudio Silva <ClaudioESSilva@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e438b9d commit 7fd992b

19 files changed

Lines changed: 3138 additions & 2681 deletions

Dashboard/Controls/FinOpsContent.xaml

Lines changed: 2667 additions & 2646 deletions
Large diffs are not rendered by default.

Dashboard/Controls/FinOpsContent.xaml.cs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Threading.Tasks;
1515
using System.Windows;
1616
using System.Windows.Controls;
17+
using System.Windows.Controls.Primitives;
1718
using System.Windows.Data;
1819
using System.Windows.Controls.Primitives;
1920
using System.Windows.Media;
@@ -33,6 +34,10 @@ public partial class FinOpsContent : UserControl
3334
private DateTime _serverInventoryCacheTime;
3435
private decimal _currentServerMonthlyCost;
3536

37+
private DataGridFilterManager<FinOpsDatabaseSizeStats>? _dbSizesFilterMgr;
38+
private Popup? _dbSizeFilterPopup;
39+
private ColumnFilterPopup? _dbSizeFilterPopupContent;
40+
3641
public FinOpsContent()
3742
{
3843
InitializeComponent();
@@ -70,6 +75,8 @@ private void OnLoaded(object sender, RoutedEventArgs e)
7075
TabHelpers.FreezeColumns(ExpensiveQueriesDataGrid, 1);
7176
TabHelpers.FreezeColumns(IndexAnalysisDetailGrid, 1);
7277
TabHelpers.FreezeColumns(HighImpactDataGrid, 1);
78+
79+
_dbSizesFilterMgr = new DataGridFilterManager<FinOpsDatabaseSizeStats>(DatabaseSizesDataGrid);
7380
}
7481

7582
/// <summary>
@@ -624,16 +631,62 @@ private async Task LoadDatabaseSizesAsync()
624631
}
625632
}
626633

627-
DatabaseSizesDataGrid.ItemsSource = data;
628-
DatabaseSizesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
629-
DbSizeCountIndicator.Text = data.Count > 0 ? $"{data.Count} file(s)" : "";
634+
_dbSizesFilterMgr!.UpdateData(data);
635+
UpdateDbSizeCountUI();
630636
}
631637
catch (Exception ex)
632638
{
633639
Logger.Error($"Error loading database sizes: {ex.Message}", ex);
634640
}
635641
}
636642

643+
private void UpdateDbSizeCountUI()
644+
{
645+
var list = DatabaseSizesDataGrid.ItemsSource as System.Collections.IList;
646+
int count = list?.Count ?? 0;
647+
DatabaseSizesNoDataMessage.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
648+
DbSizeCountIndicator.Text = count > 0 ? $"{count} file(s)" : "";
649+
}
650+
651+
private void DatabaseSizesFilter_Click(object sender, RoutedEventArgs e)
652+
{
653+
if (sender is not Button button || button.Tag is not string columnName) return;
654+
655+
if (_dbSizeFilterPopup == null)
656+
{
657+
_dbSizeFilterPopupContent = new ColumnFilterPopup();
658+
_dbSizeFilterPopupContent.FilterApplied += FilterPopup_DbSizeFilterApplied;
659+
_dbSizeFilterPopupContent.FilterCleared += FilterPopup_DbSizeFilterCleared;
660+
_dbSizeFilterPopup = new Popup
661+
{
662+
Child = _dbSizeFilterPopupContent,
663+
StaysOpen = false,
664+
Placement = PlacementMode.Bottom,
665+
AllowsTransparency = true
666+
};
667+
}
668+
669+
_dbSizesFilterMgr!.Filters.TryGetValue(columnName, out var existingFilter);
670+
_dbSizeFilterPopupContent!.Initialize(columnName, existingFilter);
671+
_dbSizeFilterPopup.PlacementTarget = button;
672+
_dbSizeFilterPopup.IsOpen = true;
673+
}
674+
675+
private void FilterPopup_DbSizeFilterApplied(object? sender, FilterAppliedEventArgs e)
676+
{
677+
if (_dbSizeFilterPopup != null)
678+
_dbSizeFilterPopup.IsOpen = false;
679+
680+
_dbSizesFilterMgr!.SetFilter(e.FilterState);
681+
UpdateDbSizeCountUI();
682+
}
683+
684+
private void FilterPopup_DbSizeFilterCleared(object? sender, EventArgs e)
685+
{
686+
if (_dbSizeFilterPopup != null)
687+
_dbSizeFilterPopup.IsOpen = false;
688+
}
689+
637690
// ============================================
638691
// Application Connections Tab
639692
// ============================================

Dashboard/Models/CollectionHealthItem.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public class CollectionHealthItem
1919
public decimal FailureRatePercent { get; set; }
2020
public long TotalRuns7d { get; set; }
2121
public long FailedRuns7d { get; set; }
22-
public int AvgDurationMs { get; set; }
22+
public long AvgDurationMs { get; set; }
2323
public long TotalRowsCollected7d { get; set; }
2424
}
2525
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright (c) 2026 Erik Darling, Darling Data LLC
3+
*
4+
* This file is part of the SQL Server Performance Monitor.
5+
*
6+
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
7+
*/
8+
9+
using System;
10+
using System.Collections.Generic;
11+
using System.Linq;
12+
using System.Windows;
13+
using System.Windows.Controls;
14+
using System.Windows.Media;
15+
using PerformanceMonitorDashboard.Models;
16+
17+
namespace PerformanceMonitorDashboard.Services;
18+
19+
/// <summary>
20+
/// Non-generic interface for looking up filter state from a shared dictionary.
21+
/// </summary>
22+
public interface IDataGridFilterManager
23+
{
24+
Dictionary<string, ColumnFilterState> Filters { get; }
25+
void SetFilter(ColumnFilterState filterState);
26+
void UpdateFilterButtonStyles();
27+
}
28+
29+
/// <summary>
30+
/// Manages column filter state, unfiltered data capture, and filter application
31+
/// for a single DataGrid. Eliminates per-grid boilerplate code.
32+
/// </summary>
33+
public class DataGridFilterManager<T> : IDataGridFilterManager
34+
{
35+
private readonly DataGrid _dataGrid;
36+
private readonly Dictionary<string, ColumnFilterState> _filters = new();
37+
private List<T>? _unfilteredData;
38+
39+
public DataGridFilterManager(DataGrid dataGrid)
40+
{
41+
_dataGrid = dataGrid;
42+
}
43+
44+
public Dictionary<string, ColumnFilterState> Filters => _filters;
45+
46+
/// <summary>
47+
/// Called when new data arrives (refresh cycle). Captures unfiltered data,
48+
/// then re-applies any active filters.
49+
/// </summary>
50+
public void UpdateData(List<T> newData)
51+
{
52+
_unfilteredData = newData;
53+
54+
if (!HasActiveFilters())
55+
{
56+
_dataGrid.ItemsSource = newData;
57+
return;
58+
}
59+
60+
ApplyFilters();
61+
}
62+
63+
/// <summary>
64+
/// Applies or removes a filter and re-filters the data.
65+
/// </summary>
66+
public void SetFilter(ColumnFilterState filterState)
67+
{
68+
if (filterState.IsActive)
69+
_filters[filterState.ColumnName] = filterState;
70+
else
71+
_filters.Remove(filterState.ColumnName);
72+
73+
ApplyFilters();
74+
UpdateFilterButtonStyles();
75+
}
76+
77+
private bool HasActiveFilters()
78+
{
79+
return _filters.Count > 0 && _filters.Values.Any(f => f.IsActive);
80+
}
81+
82+
private void ApplyFilters()
83+
{
84+
if (_unfilteredData == null) return;
85+
86+
if (!HasActiveFilters())
87+
{
88+
_dataGrid.ItemsSource = _unfilteredData;
89+
return;
90+
}
91+
92+
var filteredData = _unfilteredData.Where(item =>
93+
{
94+
foreach (var filter in _filters.Values)
95+
{
96+
if (filter.IsActive && !DataGridFilterService.MatchesFilter(item!, filter))
97+
return false;
98+
}
99+
return true;
100+
}).ToList();
101+
102+
_dataGrid.ItemsSource = filteredData;
103+
}
104+
105+
/// <summary>
106+
/// Updates filter icon colors (gold when active, dim when inactive).
107+
/// </summary>
108+
public void UpdateFilterButtonStyles()
109+
{
110+
foreach (var column in _dataGrid.Columns)
111+
{
112+
if (column.Header is StackPanel headerPanel)
113+
{
114+
var filterButton = headerPanel.Children.OfType<Button>().FirstOrDefault();
115+
if (filterButton != null && filterButton.Tag is string columnName)
116+
{
117+
bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive;
118+
119+
var textBlock = new TextBlock
120+
{
121+
Text = hasActive ? "\uE16E" : "\uE71C",
122+
FontFamily = new FontFamily("Segoe MDL2 Assets"),
123+
Foreground = hasActive
124+
? new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00))
125+
: (Brush)Application.Current.FindResource("ForegroundDimBrush")
126+
};
127+
filterButton.Content = textBlock;
128+
129+
filterButton.ToolTip = hasActive && filter != null
130+
? $"Filter: {filter.DisplayText}\n(Click to modify)"
131+
: "Click to filter";
132+
}
133+
}
134+
}
135+
}
136+
}

Dashboard/Services/DatabaseService.FinOps.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,10 @@ public async Task<List<FinOpsDatabaseSizeStats>> GetFinOpsDatabaseSizeStatsAsync
322322
state_desc,
323323
volume_mount_point,
324324
volume_total_mb,
325-
volume_free_mb
325+
volume_free_mb,
326+
is_percent_growth,
327+
growth_pct,
328+
vlf_count
326329
FROM collect.database_size_stats
327330
WHERE collection_time =
328331
(
@@ -364,7 +367,10 @@ ORDER BY
364367
StateDesc = reader.IsDBNull(15) ? "" : reader.GetString(15),
365368
VolumeMountPoint = reader.IsDBNull(16) ? "" : reader.GetString(16),
366369
VolumeTotalMb = reader.IsDBNull(17) ? 0m : Convert.ToDecimal(reader.GetValue(17)),
367-
VolumeFreeMb = reader.IsDBNull(18) ? 0m : Convert.ToDecimal(reader.GetValue(18))
370+
VolumeFreeMb = reader.IsDBNull(18) ? 0m : Convert.ToDecimal(reader.GetValue(18)),
371+
IsPercentGrowth = reader.IsDBNull(19) ? null : (bool?)(Convert.ToInt32(reader.GetValue(19)) == 1),
372+
GrowthPct = reader.IsDBNull(20) ? null : Convert.ToInt32(reader.GetValue(20)),
373+
VlfCount = reader.IsDBNull(21) ? null : Convert.ToInt32(reader.GetValue(21))
368374
});
369375
}
370376
}
@@ -2540,9 +2546,32 @@ public class FinOpsDatabaseSizeStats
25402546
public string VolumeMountPoint { get; set; } = "";
25412547
public decimal VolumeTotalMb { get; set; }
25422548
public decimal VolumeFreeMb { get; set; }
2549+
public bool? IsPercentGrowth { get; set; }
2550+
public int? GrowthPct { get; set; }
2551+
public int? VlfCount { get; set; }
25432552

25442553
// FinOps cost — proportional share of server monthly budget
25452554
public decimal MonthlyCostShare { get; set; }
2555+
2556+
public string GrowthDisplay => IsPercentGrowth switch
2557+
{
2558+
null => "-",
2559+
true => GrowthPct.HasValue ? $"{GrowthPct}%" : "-",
2560+
false => AutoGrowthMb == 0 ? "Disabled" : $"{AutoGrowthMb:N0} MB"
2561+
};
2562+
2563+
public decimal AutoGrowthSort => IsPercentGrowth switch
2564+
{
2565+
null => -1m,
2566+
true => (decimal)(GrowthPct ?? -1),
2567+
false => AutoGrowthMb
2568+
};
2569+
2570+
public string VlfCountDisplay => string.Equals(FileTypeDesc, "LOG", StringComparison.OrdinalIgnoreCase)
2571+
? (VlfCount?.ToString() ?? "-") : "N/A";
2572+
2573+
public int VlfCountSort => string.Equals(FileTypeDesc, "LOG", StringComparison.OrdinalIgnoreCase)
2574+
? (VlfCount ?? 0) : -1;
25462575
}
25472576

25482577
public class FinOpsTopResourceConsumer

Dashboard/Services/DatabaseService.QueryPerformance.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,8 +1014,8 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE')
10141014
AvgRows = reader.IsDBNull(24) ? null : reader.GetInt64(24),
10151015
MinRows = reader.IsDBNull(25) ? null : reader.GetInt64(25),
10161016
MaxRows = reader.IsDBNull(26) ? null : reader.GetInt64(26),
1017-
MinDop = reader.IsDBNull(27) ? null : reader.GetInt16(27),
1018-
MaxDop = reader.IsDBNull(28) ? null : reader.GetInt16(28),
1017+
MinDop = reader.IsDBNull(27) ? null : Convert.ToInt16(reader.GetValue(27)),
1018+
MaxDop = reader.IsDBNull(28) ? null : Convert.ToInt16(reader.GetValue(28)),
10191019
MinGrantKb = reader.IsDBNull(29) ? null : reader.GetInt64(29),
10201020
MaxGrantKb = reader.IsDBNull(30) ? null : reader.GetInt64(30),
10211021
TotalSpills = reader.IsDBNull(31) ? 0 : reader.GetInt64(31),

Dashboard/Services/DatabaseService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ CASE health_status
176176
FailureRatePercent = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4), CultureInfo.InvariantCulture),
177177
TotalRuns7d = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5), CultureInfo.InvariantCulture),
178178
FailedRuns7d = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6), CultureInfo.InvariantCulture),
179-
AvgDurationMs = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7), CultureInfo.InvariantCulture),
179+
AvgDurationMs = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7), CultureInfo.InvariantCulture),
180180
TotalRowsCollected7d = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8), CultureInfo.InvariantCulture)
181181
});
182182
}

Lite/Controls/FinOpsTab.xaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,27 @@
10161016
</StackPanel>
10171017
</DataGridTextColumn.Header>
10181018
</DataGridTextColumn>
1019+
<DataGridTextColumn Binding="{Binding GrowthDisplay}" Width="110" SortMemberPath="AutoGrowthSort">
1020+
<DataGridTextColumn.Header>
1021+
<StackPanel Orientation="Horizontal">
1022+
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrowthDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
1023+
<TextBlock Text="Auto Growth" FontWeight="Bold" VerticalAlignment="Center"/>
1024+
</StackPanel>
1025+
</DataGridTextColumn.Header>
1026+
</DataGridTextColumn>
1027+
<DataGridTextColumn Binding="{Binding VlfCountDisplay}" Width="80" SortMemberPath="VlfCountSort">
1028+
<DataGridTextColumn.Header>
1029+
<StackPanel Orientation="Horizontal">
1030+
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VlfCountDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
1031+
<TextBlock Text="VLF Count" FontWeight="Bold" VerticalAlignment="Center"/>
1032+
</StackPanel>
1033+
</DataGridTextColumn.Header>
1034+
<DataGridTextColumn.ElementStyle>
1035+
<Style TargetType="TextBlock">
1036+
<Setter Property="HorizontalAlignment" Value="Right"/>
1037+
</Style>
1038+
</DataGridTextColumn.ElementStyle>
1039+
</DataGridTextColumn>
10191040
<DataGridTextColumn Header="Monthly Cost ($)" Binding="{Binding MonthlyCostShare, StringFormat='{}{0:N2}'}" Width="110">
10201041
<DataGridTextColumn.ElementStyle>
10211042
<Style TargetType="TextBlock">

Lite/Database/DuckDbInitializer.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public void Dispose()
8686
/// <summary>
8787
/// Current schema version. Increment this when schema changes require table rebuilds.
8888
/// </summary>
89-
internal const int CurrentSchemaVersion = 21;
89+
internal const int CurrentSchemaVersion = 22;
9090

9191
private readonly string _archivePath;
9292

@@ -583,6 +583,22 @@ New tables only — no existing table changes needed. Tables created by
583583
_logger?.LogWarning("Migration to v21 encountered an error (non-fatal): {Error}", ex.Message);
584584
}
585585
}
586+
587+
if (fromVersion < 22)
588+
{
589+
_logger?.LogInformation("Running migration to v22: adding growth rate and VLF count columns to database_size_stats");
590+
try
591+
{
592+
await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS is_percent_growth BOOLEAN");
593+
await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS growth_pct INTEGER");
594+
await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS vlf_count INTEGER");
595+
}
596+
catch (Exception ex)
597+
{
598+
_logger?.LogError(ex, "Migration to v22 failed");
599+
throw;
600+
}
601+
}
586602
}
587603

588604
/// <summary>

Lite/Database/Schema.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,10 @@ max_size_mb DECIMAL(19,2),
614614
state_desc VARCHAR,
615615
volume_mount_point VARCHAR,
616616
volume_total_mb DECIMAL(19,2),
617-
volume_free_mb DECIMAL(19,2)
617+
volume_free_mb DECIMAL(19,2),
618+
is_percent_growth BOOLEAN,
619+
growth_pct INTEGER,
620+
vlf_count INTEGER
618621
)";
619622

620623
public const string CreateDatabaseSizeStatsIndex = @"

0 commit comments

Comments
 (0)