diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index b8d0c16..9e97a53 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -86,7 +86,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 16; + internal const int CurrentSchemaVersion = 17; private readonly string _archivePath; @@ -505,6 +505,23 @@ New tables only — no existing table changes needed. Tables created by GetAllTableStatements() during initialization. */ _logger?.LogInformation("Running migration to v16: adding FinOps tables (database_size_stats, server_properties)"); } + + if (fromVersion < 17) + { + /* v17: Added volume-level drive space columns to database_size_stats. + Columns appended at end — safe for DuckDB appender positional writes. */ + _logger?.LogInformation("Running migration to v17: adding volume stats columns to database_size_stats"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_mount_point VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_total_mb DECIMAL(19,2)"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_free_mb DECIMAL(19,2)"); + } + catch + { + /* Table doesn't exist yet — will be created with correct schema below */ + } + } } /// diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 6472cb4..8d978dd 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -609,7 +609,10 @@ auto_growth_mb DECIMAL(19,2), max_size_mb DECIMAL(19,2), recovery_model_desc VARCHAR, compatibility_level INTEGER, - state_desc VARCHAR + state_desc VARCHAR, + volume_mount_point VARCHAR, + volume_total_mb DECIMAL(19,2), + volume_free_mb DECIMAL(19,2) )"; public const string CreateDatabaseSizeStatsIndex = @" diff --git a/Lite/Services/RemoteCollectorService.DatabaseSize.cs b/Lite/Services/RemoteCollectorService.DatabaseSize.cs index cdaace3..6357ded 100644 --- a/Lite/Services/RemoteCollectorService.DatabaseSize.cs +++ b/Lite/Services/RemoteCollectorService.DatabaseSize.cs @@ -22,8 +22,8 @@ public partial class RemoteCollectorService { /// /// Collects per-file database sizes for growth trending and capacity planning. - /// On-prem: queries sys.master_files + sys.databases for all online databases. - /// Azure SQL DB: queries sys.database_files for the single database. + /// On-prem: queries sys.master_files + sys.databases + dm_os_volume_stats for file and drive context. + /// Azure SQL DB: queries sys.database_files for the single database (no volume stats available). /// private async Task CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken) { @@ -63,10 +63,17 @@ ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0) compatibility_level = CONVERT(int, d.compatibility_level), state_desc = - d.state_desc + d.state_desc, + volume_mount_point = + RTRIM(vs.volume_mount_point), + volume_total_mb = + CONVERT(decimal(19,2), vs.total_bytes / 1048576.0), + volume_free_mb = + CONVERT(decimal(19,2), vs.available_bytes / 1048576.0) FROM sys.master_files AS mf JOIN sys.databases AS d ON d.database_id = mf.database_id +CROSS APPLY sys.dm_os_volume_stats(mf.database_id, mf.file_id) AS vs WHERE d.state_desc = N'ONLINE' ORDER BY d.name, @@ -106,7 +113,13 @@ ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0) compatibility_level = CONVERT(int, NULL), state_desc = - N'ONLINE' + N'ONLINE', + volume_mount_point = + CONVERT(nvarchar(256), NULL), + volume_total_mb = + CONVERT(decimal(19,2), NULL), + volume_free_mb = + CONVERT(decimal(19,2), NULL) FROM sys.database_files AS df ORDER BY df.file_id @@ -123,7 +136,8 @@ ORDER BY var rows = new List<(string DatabaseName, int DatabaseId, int FileId, string FileTypeDesc, string FileName, string PhysicalName, decimal TotalSizeMb, decimal? UsedSizeMb, decimal? AutoGrowthMb, decimal? MaxSizeMb, string? RecoveryModel, - int? CompatibilityLevel, string? StateDesc)>(); + int? CompatibilityLevel, string? StateDesc, string? VolumeMountPoint, + decimal? VolumeTotalMb, decimal? VolumeFreeMb)>(); var sqlSw = Stopwatch.StartNew(); using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); @@ -146,7 +160,10 @@ ORDER BY reader.IsDBNull(9) ? null : reader.GetDecimal(9), reader.IsDBNull(10) ? null : reader.GetString(10), reader.IsDBNull(11) ? null : reader.GetInt32(11), - reader.IsDBNull(12) ? null : reader.GetString(12))); + reader.IsDBNull(12) ? null : reader.GetString(12), + reader.IsDBNull(13) ? null : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetDecimal(14), + reader.IsDBNull(15) ? null : reader.GetDecimal(15))); } sqlSw.Stop(); @@ -178,6 +195,9 @@ ORDER BY .AppendValue(r.RecoveryModel) .AppendValue(r.CompatibilityLevel) .AppendValue(r.StateDesc) + .AppendValue(r.VolumeMountPoint) + .AppendValue(r.VolumeTotalMb) + .AppendValue(r.VolumeFreeMb) .EndRow(); rowsCollected++; } diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql index eb9a17a..d4e7562 100644 --- a/install/02_create_tables.sql +++ b/install/02_create_tables.sql @@ -1430,6 +1430,9 @@ BEGIN recovery_model_desc nvarchar(12) NULL, compatibility_level integer NULL, state_desc nvarchar(60) NULL, + volume_mount_point nvarchar(256) NULL, + volume_total_mb decimal(19,2) NULL, + volume_free_mb decimal(19,2) NULL, /*Analysis helpers - computed columns*/ free_space_mb AS ( diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql index 71d6f0c..3fad7f2 100644 --- a/install/06_ensure_collection_table.sql +++ b/install/06_ensure_collection_table.sql @@ -1110,6 +1110,9 @@ BEGIN recovery_model_desc nvarchar(12) NULL, compatibility_level integer NULL, state_desc nvarchar(60) NULL, + volume_mount_point nvarchar(256) NULL, + volume_total_mb decimal(19,2) NULL, + volume_free_mb decimal(19,2) NULL, free_space_mb AS ( total_size_mb - used_size_mb diff --git a/install/52_collect_database_size_stats.sql b/install/52_collect_database_size_stats.sql index 386bb72..10937e0 100644 --- a/install/52_collect_database_size_stats.sql +++ b/install/52_collect_database_size_stats.sql @@ -112,7 +112,10 @@ BEGIN max_size_mb, recovery_model_desc, compatibility_level, - state_desc + state_desc, + volume_mount_point, + volume_total_mb, + volume_free_mb ) SELECT collection_time = @start_time, @@ -147,7 +150,10 @@ BEGIN recovery_model_desc = CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')), compatibility_level = NULL, - state_desc = N'ONLINE' + state_desc = N'ONLINE', + volume_mount_point = NULL, + volume_total_mb = NULL, + volume_free_mb = NULL FROM sys.database_files AS df OPTION(RECOMPILE); @@ -200,7 +206,10 @@ BEGIN max_size_mb, recovery_model_desc, compatibility_level, - state_desc + state_desc, + volume_mount_point, + volume_total_mb, + volume_free_mb ) SELECT collection_time = @start_time, @@ -234,9 +243,16 @@ BEGIN END, recovery_model_desc = d.recovery_model_desc, compatibility_level = d.compatibility_level, - state_desc = d.state_desc + state_desc = d.state_desc, + volume_mount_point = + RTRIM(vs.volume_mount_point), + volume_total_mb = + CONVERT(decimal(19,2), vs.total_bytes / 1048576.0), + volume_free_mb = + CONVERT(decimal(19,2), vs.available_bytes / 1048576.0) FROM sys.database_files AS df CROSS JOIN sys.databases AS d + CROSS APPLY sys.dm_os_volume_stats(DB_ID(), df.file_id) AS vs WHERE d.database_id = DB_ID();'; EXECUTE sys.sp_executesql @@ -277,7 +293,10 @@ BEGIN dss.total_size_mb, dss.used_size_mb, dss.free_space_mb, - dss.used_pct + dss.used_pct, + dss.volume_mount_point, + dss.volume_total_mb, + dss.volume_free_mb FROM collect.database_size_stats AS dss WHERE dss.collection_time = @start_time ORDER BY diff --git a/upgrades/2.1.0-to-2.2.0/06_add_volume_stats_columns.sql b/upgrades/2.1.0-to-2.2.0/06_add_volume_stats_columns.sql new file mode 100644 index 0000000..a0cf2c2 --- /dev/null +++ b/upgrades/2.1.0-to-2.2.0/06_add_volume_stats_columns.sql @@ -0,0 +1,41 @@ +/* +Copyright 2026 Darling Data, LLC +https://www.erikdarling.com/ + +Upgrade from 2.1.0 to 2.2.0 +Adds volume-level drive space columns to database_size_stats. +*/ + +SET ANSI_NULLS ON; +SET ANSI_PADDING ON; +SET ANSI_WARNINGS ON; +SET ARITHABORT ON; +SET CONCAT_NULL_YIELDS_NULL ON; +SET QUOTED_IDENTIFIER ON; +SET NUMERIC_ROUNDABORT OFF; +SET IMPLICIT_TRANSACTIONS OFF; +SET STATISTICS TIME, IO OFF; +GO + +USE PerformanceMonitor; +GO + +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.columns + WHERE object_id = OBJECT_ID(N'collect.database_size_stats', N'U') + AND name = N'volume_mount_point' +) +BEGIN + ALTER TABLE + collect.database_size_stats + ADD + volume_mount_point nvarchar(256) NULL, + volume_total_mb decimal(19,2) NULL, + volume_free_mb decimal(19,2) NULL; + + PRINT 'Added volume_mount_point, volume_total_mb, volume_free_mb to collect.database_size_stats'; +END; +GO diff --git a/upgrades/2.1.0-to-2.2.0/upgrade.txt b/upgrades/2.1.0-to-2.2.0/upgrade.txt index 8f2a8d5..1fe7ffd 100644 --- a/upgrades/2.1.0-to-2.2.0/upgrade.txt +++ b/upgrades/2.1.0-to-2.2.0/upgrade.txt @@ -3,3 +3,4 @@ 03_compress_procedure_stats.sql 04_create_tracking_tables.sql 05_add_finops_collectors.sql +06_add_volume_stats_columns.sql