Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Lite/Database/DuckDbInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public void Dispose()
/// <summary>
/// Current schema version. Increment this when schema changes require table rebuilds.
/// </summary>
internal const int CurrentSchemaVersion = 15;
internal const int CurrentSchemaVersion = 16;

private readonly string _archivePath;

Expand Down Expand Up @@ -497,6 +497,14 @@ Must drop/recreate because DuckDB appender writes by position. */
_logger?.LogInformation("Running migration to v15: rebuilding file_io_stats for queued I/O columns");
await ExecuteNonQueryAsync(connection, "DROP TABLE IF EXISTS file_io_stats");
}

if (fromVersion < 16)
{
/* v16: Added database_size_stats and server_properties tables for FinOps monitoring.
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)");
}
}

/// <summary>
Expand Down
53 changes: 53 additions & 0 deletions Lite/Database/Schema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,55 @@ percent_of_average DECIMAL(10,1)
public const string CreateRunningJobsIndex = @"
CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time)";

public const string CreateDatabaseSizeStatsTable = @"
CREATE TABLE IF NOT EXISTS database_size_stats (
collection_id BIGINT PRIMARY KEY,
collection_time TIMESTAMP NOT NULL,
server_id INTEGER NOT NULL,
server_name VARCHAR NOT NULL,
database_name VARCHAR NOT NULL,
database_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
file_type_desc VARCHAR NOT NULL,
file_name VARCHAR NOT NULL,
physical_name VARCHAR NOT NULL,
total_size_mb DECIMAL(19,2) NOT NULL,
used_size_mb DECIMAL(19,2),
auto_growth_mb DECIMAL(19,2),
max_size_mb DECIMAL(19,2),
recovery_model_desc VARCHAR,
compatibility_level INTEGER,
state_desc VARCHAR
)";

public const string CreateDatabaseSizeStatsIndex = @"
CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time)";

public const string CreateServerPropertiesTable = @"
CREATE TABLE IF NOT EXISTS server_properties (
collection_id BIGINT PRIMARY KEY,
collection_time TIMESTAMP NOT NULL,
server_id INTEGER NOT NULL,
server_name VARCHAR NOT NULL,
edition VARCHAR NOT NULL,
product_version VARCHAR NOT NULL,
product_level VARCHAR NOT NULL,
product_update_level VARCHAR,
engine_edition INTEGER NOT NULL,
cpu_count INTEGER NOT NULL,
hyperthread_ratio INTEGER NOT NULL,
physical_memory_mb BIGINT NOT NULL,
socket_count INTEGER,
cores_per_socket INTEGER,
is_hadr_enabled BOOLEAN,
is_clustered BOOLEAN,
enterprise_features VARCHAR,
service_objective VARCHAR
)";

public const string CreateServerPropertiesIndex = @"
CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time)";

public const string CreateAlertLogTable = @"
CREATE TABLE IF NOT EXISTS config_alert_log (
alert_time TIMESTAMP NOT NULL,
Expand Down Expand Up @@ -633,6 +682,8 @@ public static IEnumerable<string> GetAllTableStatements()
yield return CreateDatabaseScopedConfigTable;
yield return CreateTraceFlagsTable;
yield return CreateRunningJobsTable;
yield return CreateDatabaseSizeStatsTable;
yield return CreateServerPropertiesTable;
yield return CreateAlertLogTable;
}

Expand Down Expand Up @@ -660,5 +711,7 @@ public static IEnumerable<string> GetAllIndexStatements()
yield return CreateDatabaseScopedConfigIndex;
yield return CreateTraceFlagsIndex;
yield return CreateRunningJobsIndex;
yield return CreateDatabaseSizeStatsIndex;
yield return CreateServerPropertiesIndex;
}
}
194 changes: 194 additions & 0 deletions Lite/Services/RemoteCollectorService.DatabaseSize.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* 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.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using PerformanceMonitorLite.Models;

namespace PerformanceMonitorLite.Services;

public partial class RemoteCollectorService
{
/// <summary>
/// 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.
/// </summary>
private async Task<int> CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken)
{
var serverStatus = _serverManager.GetConnectionStatus(server.Id);
bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;

const string onPremQuery = @"
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

SELECT
database_name = d.name,
database_id = d.database_id,
file_id = mf.file_id,
file_type_desc = mf.type_desc,
file_name = mf.name,
physical_name = mf.physical_name,
total_size_mb =
CONVERT(decimal(19,2), mf.size * 8.0 / 1024.0),
used_size_mb =
CONVERT(decimal(19,2), NULL),
auto_growth_mb =
CASE
WHEN mf.is_percent_growth = 1
THEN CONVERT(decimal(19,2), NULL)
ELSE CONVERT(decimal(19,2), mf.growth * 8.0 / 1024.0)
END,
max_size_mb =
CASE
WHEN mf.max_size = -1
THEN CONVERT(decimal(19,2), -1)
WHEN mf.max_size = 268435456
THEN CONVERT(decimal(19,2), 2097152)
ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0)
END,
recovery_model_desc =
d.recovery_model_desc,
compatibility_level =
d.compatibility_level,
state_desc =
d.state_desc
FROM sys.master_files AS mf
JOIN sys.databases AS d
ON d.database_id = mf.database_id
WHERE d.state_desc = N'ONLINE'
ORDER BY
d.name,
mf.file_id
OPTION(RECOMPILE);";

const string azureSqlDbQuery = @"
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

SELECT
database_name = DB_NAME(),
database_id = DB_ID(),
file_id = df.file_id,
file_type_desc = df.type_desc,
file_name = df.name,
physical_name = df.physical_name,
total_size_mb =
CONVERT(decimal(19,2), df.size * 8.0 / 1024.0),
used_size_mb =
CONVERT(decimal(19,2), FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0),
auto_growth_mb =
CASE
WHEN df.is_percent_growth = 1
THEN CONVERT(decimal(19,2), NULL)
ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0)
END,
max_size_mb =
CASE
WHEN df.max_size = -1
THEN CONVERT(decimal(19,2), -1)
WHEN df.max_size = 268435456
THEN CONVERT(decimal(19,2), 2097152)
ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0)
END,
recovery_model_desc =
CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')),
compatibility_level =
CONVERT(int, NULL),
state_desc =
N'ONLINE'
FROM sys.database_files AS df
ORDER BY
df.file_id
OPTION(RECOMPILE);";

string query = isAzureSqlDb ? azureSqlDbQuery : onPremQuery;

var serverId = GetServerId(server);
var collectionTime = DateTime.UtcNow;
var rowsCollected = 0;
_lastSqlMs = 0;
_lastDuckDbMs = 0;

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)>();

var sqlSw = Stopwatch.StartNew();
using var sqlConnection = await CreateConnectionAsync(server, cancellationToken);
using var command = new SqlCommand(query, sqlConnection);
command.CommandTimeout = CommandTimeoutSeconds;

using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
rows.Add((
reader.GetString(0),
reader.GetInt32(1),
reader.GetInt32(2),
reader.GetString(3),
reader.GetString(4),
reader.GetString(5),
reader.GetDecimal(6),
reader.IsDBNull(7) ? null : reader.GetDecimal(7),
reader.IsDBNull(8) ? null : reader.GetDecimal(8),
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)));
}
sqlSw.Stop();

var duckSw = Stopwatch.StartNew();

using (var duckConnection = _duckDb.CreateConnection())
{
await duckConnection.OpenAsync(cancellationToken);

using (var appender = duckConnection.CreateAppender("database_size_stats"))
{
foreach (var r in rows)
{
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
.AppendValue(server.ServerName)
.AppendValue(r.DatabaseName)
.AppendValue(r.DatabaseId)
.AppendValue(r.FileId)
.AppendValue(r.FileTypeDesc)
.AppendValue(r.FileName)
.AppendValue(r.PhysicalName)
.AppendValue(r.TotalSizeMb)
.AppendValue(r.UsedSizeMb)
.AppendValue(r.AutoGrowthMb)
.AppendValue(r.MaxSizeMb)
.AppendValue(r.RecoveryModel)
.AppendValue(r.CompatibilityLevel)
.AppendValue(r.StateDesc)
.EndRow();
rowsCollected++;
}
}
}

duckSw.Stop();
_lastSqlMs = sqlSw.ElapsedMilliseconds;
_lastDuckDbMs = duckSw.ElapsedMilliseconds;

_logger?.LogDebug("Collected {RowCount} database size rows for server '{Server}'", rowsCollected, server.DisplayName);
return rowsCollected;
}
}
Loading
Loading