diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs index 5f347a9..539d7c4 100644 --- a/Lite/Models/ServerConnection.cs +++ b/Lite/Models/ServerConnection.cs @@ -95,6 +95,13 @@ public bool UseWindowsAuth /// public bool MultiSubnetFailover { get; set; } = false; + /// + /// User databases to skip in per-database collectors (query_store, file_io_stats, etc.). + /// System databases (master/tempdb/model/msdb) and the connection database itself are always + /// excluded by the collectors and aren't represented here. + /// + public System.Collections.Generic.List ExcludedDatabases { get; set; } = new(); + /// /// Server name with "(Read-Only)" suffix when ReadOnlyIntent is enabled. /// Used for sidebar subtitle and status text. diff --git a/Lite/Services/RemoteCollectorService.DatabaseSize.cs b/Lite/Services/RemoteCollectorService.DatabaseSize.cs index ad765fd..7d9227a 100644 --- a/Lite/Services/RemoteCollectorService.DatabaseSize.cs +++ b/Lite/Services/RemoteCollectorService.DatabaseSize.cs @@ -30,7 +30,7 @@ private async Task CollectDatabaseSizeStatsAsync(ServerConnection server, C var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremQuery = @" + string onPremQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET NOCOUNT ON; @@ -52,6 +52,7 @@ FROM sys.databases AS d WHERE d.state_desc = N'ONLINE' AND d.database_id > 0 AND HAS_DBACCESS(d.name) = 1 + /*EXCLUSION_FILTER_CURSOR*/ ORDER BY d.name; @@ -131,11 +132,19 @@ LEFT JOIN #file_space AS fs ON fs.database_id = mf.database_id AND fs.file_id = mf.file_id WHERE d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER_OUTER*/ ORDER BY d.name, mf.file_id OPTION(RECOMPILE);"; + /* Both filter sites (cursor SELECT and final SELECT) are in outer T-SQL, not nested dynamic SQL, + so parameter bindings work fine and the same @excl_db_N can be referenced twice. */ + var (dbSizeExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremQuery = onPremQuery + .Replace("/*EXCLUSION_FILTER_CURSOR*/", dbSizeExclusionClause) + .Replace("/*EXCLUSION_FILTER_OUTER*/", dbSizeExclusionClause); + const string azureSqlDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -231,6 +240,8 @@ ORDER BY using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(onPremQuery, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, dbSizeExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in dbSizeExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs index 203573e..a1ac8a8 100644 --- a/Lite/Services/RemoteCollectorService.FileIo.cs +++ b/Lite/Services/RemoteCollectorService.FileIo.cs @@ -83,8 +83,13 @@ LEFT JOIN sys.databases AS d WHERE (vfs.database_id > 4 OR vfs.database_id = 2) AND vfs.database_id < 32761 AND vfs.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ OPTION(RECOMPILE);"; + /* Azure path filters via GetAzureDatabaseListAsync; on-prem path injects here */ + var (fileIoExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + query = query.Replace("/*EXCLUSION_FILTER*/", isAzureSqlDb ? string.Empty : fileIoExclusionClause); + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -125,6 +130,8 @@ AND vfs.database_id < 32761 { using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection) { CommandTimeout = CommandTimeoutSeconds }; + var (_, fileIoExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in fileIoExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) fileStats.Add(ReadFileIoRow(reader)); diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs index 58a5057..3bd37c3 100644 --- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs +++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs @@ -32,7 +32,7 @@ so the standard NOT IN filter excludes everything. Use a simplified query. */ /* total_spills/min_spills/max_spills exist in dm_exec_procedure_stats and dm_exec_trigger_stats on all supported versions, but do NOT exist in dm_exec_function_stats on any version. Use dynamic SQL to handle this. */ - const string standardQuery = @" + string standardQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; DECLARE @@ -81,6 +81,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ UNION ALL @@ -136,6 +137,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ UNION ALL @@ -178,6 +180,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ) AS combined ORDER BY total_elapsed_time DESC OPTION(RECOMPILE);' AS nvarchar(max)); @@ -220,9 +223,19 @@ No trigger stats or function stats — Azure SQL DB scope is single-database. */ FROM sys.dm_exec_procedure_stats AS s WHERE s.database_id = DB_ID() AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ORDER BY s.total_elapsed_time DESC OPTION(RECOMPILE);"; + /* Standard query is dynamic SQL (built into @sql then passed to sp_executesql), so the + exclusion filter is interpolated as literal N'...' values rather than parameter bindings. + Names come from a user-picked checklist of existing DBs, escaped against single-quote + injection. forNestedDynamicSql=true doubles the escape because @sql is itself a + single-quoted T-SQL string at the outer layer. */ + string standardExclusionClause = BuildDatabaseExclusionLiteralClause( + server.ExcludedDatabases, "d.name", forNestedDynamicSql: true); + standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", standardExclusionClause); + string query = isAzureSqlDb ? azureSqlDbQuery : standardQuery; var serverId = GetServerId(server); diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index c18523c..6e1de96 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -232,6 +232,18 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc var query = BuildQuerySnapshotsQuery(supportsLiveQueryPlan, isAzureSqlDatabase); + /* Append the per-database exclusion filter to the WHERE clause. The base query joins + sys.databases AS d, so we filter on d.name. When ExcludedDatabases is empty the + clause is "" so nothing changes. */ + var (qsExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + if (!string.IsNullOrEmpty(qsExclusionClause)) + { + /* Inject just before the OPTION clause so it lands inside the WHERE. */ + query = query.Replace( + "ORDER BY der.cpu_time DESC", + qsExclusionClause + "\nORDER BY der.cpu_time DESC"); + } + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -242,6 +254,8 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, qsExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in qsExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); sqlSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs index b47705a..4a139c3 100644 --- a/Lite/Services/RemoteCollectorService.QueryStats.cs +++ b/Lite/Services/RemoteCollectorService.QueryStats.cs @@ -31,7 +31,7 @@ Use a simplified query that skips plan_attributes entirely — there's only one var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - const string standardQuery = @" + string standardQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT /* PerformanceMonitorLite */ TOP (200) @@ -106,10 +106,14 @@ INNER JOIN sys.databases AS d WHERE pa.dbid NOT IN (1, 2, 3, 4, 32761, 32767, ISNULL(DB_ID(N'PerformanceMonitor'), 0)) AND st.text NOT LIKE N'%PerformanceMonitorLite%' AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ORDER BY qs.total_elapsed_time DESC OPTION(RECOMPILE);"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + /* Azure SQL DB: skip plan_attributes, use DB_NAME() for the single database context */ const string azureSqlDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -229,6 +233,11 @@ qs.total_elapsed_time DESC { using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + if (!isAzureSqlDb) + { + var (_, freshParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in freshParams) command.Parameters.Add(p); + } using var reader = await command.ExecuteReaderAsync(cancellationToken); diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs index 9265c32..5da8420 100644 --- a/Lite/Services/RemoteCollectorService.QueryStore.cs +++ b/Lite/Services/RemoteCollectorService.QueryStore.cs @@ -32,7 +32,7 @@ Uses sys.database_query_store_options.actual_state instead of var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremDbQuery = @" + string onPremDbQuery = @" SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -60,6 +60,7 @@ AND d.name <> N'PerformanceMonitor' drs.database_id IS NULL /*not in any AG*/ OR drs.is_primary_replica = 1 /*primary replica*/ ) + /*EXCLUSION_FILTER*/ OPTION(RECOMPILE); OPEN db_check; @@ -103,7 +104,7 @@ FROM @result ORDER BY name;"; - const string azureDbQuery = @" + string azureDbQuery = @" SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -123,6 +124,7 @@ WHERE d.database_id > 4 AND d.database_id < 32761 AND d.state_desc = N'ONLINE' AND d.name <> N'PerformanceMonitor' + /*EXCLUSION_FILTER*/ OPTION(RECOMPILE); OPEN db_check; @@ -166,6 +168,10 @@ FROM @result ORDER BY name;"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; var serverId = GetServerId(server); @@ -187,6 +193,8 @@ ORDER BY using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) { dbCommand.CommandTimeout = CommandTimeoutSeconds; + var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in exclusionParams) dbCommand.Parameters.Add(p); using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); while (await dbReader.ReadAsync(cancellationToken)) { diff --git a/Lite/Services/RemoteCollectorService.ServerConfig.cs b/Lite/Services/RemoteCollectorService.ServerConfig.cs index 66f4b60..6feecd7 100644 --- a/Lite/Services/RemoteCollectorService.ServerConfig.cs +++ b/Lite/Services/RemoteCollectorService.ServerConfig.cs @@ -149,6 +149,8 @@ private async Task CollectDatabaseConfigAsync(ServerConnection server, Canc is_optimized_locking_on = d.is_optimized_locking_on"; } + var (dbConfigExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + var query = $@" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -158,6 +160,7 @@ FROM sys.databases AS d WHERE (d.database_id > 4 OR d.database_id = 2) AND d.database_id < 32761 AND d.name <> N'PerformanceMonitor' +{dbConfigExclusionClause} ORDER BY d.name OPTION(RECOMPILE);"; @@ -173,6 +176,8 @@ ORDER BY d.name using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, dbConfigExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in dbConfigExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) @@ -329,7 +334,7 @@ private async Task CollectDatabaseScopedConfigAsync(ServerConnection server var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremDbQuery = @" + string onPremDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@ -347,10 +352,11 @@ AND d.name <> N'PerformanceMonitor' drs.database_id IS NULL /*not in any AG*/ OR drs.is_primary_replica = 1 /*primary replica*/ ) +/*EXCLUSION_FILTER*/ ORDER BY d.name OPTION(RECOMPILE);"; - const string azureDbQuery = @" + string azureDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@ -360,9 +366,14 @@ FROM sys.databases AS d AND d.database_id < 32761 AND d.name <> N'PerformanceMonitor' AND d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER*/ ORDER BY d.name OPTION(RECOMPILE);"; + var (scopedExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); + azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); + string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; var serverId = GetServerId(server); @@ -379,6 +390,8 @@ ORDER BY d.name using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) { dbCommand.CommandTimeout = CommandTimeoutSeconds; + var (_, scopedExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in scopedExclusionParams) dbCommand.Parameters.Add(p); using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); while (await dbReader.ReadAsync(cancellationToken)) { diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs index bd9373b..aaa7d2d 100644 --- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs +++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs @@ -24,7 +24,7 @@ public partial class RemoteCollectorService /// private async Task CollectWaitingTasksAsync(ServerConnection server, CancellationToken cancellationToken) { - const string query = @" + string query = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT /* PerformanceMonitorLite */ @@ -42,8 +42,12 @@ LEFT JOIN sys.databases AS d AND wt.session_id <> @@SPID AND wt.wait_type IS NOT NULL AND er.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ OPTION(RECOMPILE);"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + query = query.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -54,6 +58,8 @@ AND wt.wait_type IS NOT NULL using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in exclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); sqlSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index fdecb7d..055f370 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -634,15 +634,18 @@ protected async Task> GetAzureDatabaseListAsync(ServerConnection se InitialCatalog = "master" }.ConnectionString; + var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "name"); + var databases = new List(); try { using var conn = new SqlConnection(connStr); await conn.OpenAsync(cancellationToken); using var cmd = new SqlCommand( - "SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 ORDER BY name;", + $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;", conn) { CommandTimeout = CommandTimeoutSeconds }; + foreach (var p in exclusionParams) cmd.Parameters.Add(p); using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) databases.Add(reader.GetString(0)); @@ -668,6 +671,54 @@ protected async Task> GetAzureDatabaseListAsync(ServerConnection se } } + /// + /// Builds a SQL fragment and matching SqlParameters for excluding the supplied database names. + /// When the list is empty, returns ("", []) so callers can splice without effect. + /// Each name is parameterized — works on every supported SQL Server version (no STRING_SPLIT/OPENJSON + /// compatibility-level dependency). + /// + /// Names from server.ExcludedDatabases. + /// SQL column to filter, e.g. "d.name". + internal static (string Clause, List Parameters) BuildDatabaseExclusionFilter( + IList? excludedDatabaseNames, string columnExpression) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + return (string.Empty, new List()); + + var paramNames = new List(excludedDatabaseNames.Count); + var sqlParams = new List(excludedDatabaseNames.Count); + for (int i = 0; i < excludedDatabaseNames.Count; i++) + { + string p = $"@excl_db_{i}"; + paramNames.Add(p); + sqlParams.Add(new SqlParameter(p, System.Data.SqlDbType.NVarChar, 128) { Value = excludedDatabaseNames[i] }); + } + return ($"AND {columnExpression} NOT IN ({string.Join(", ", paramNames)})", sqlParams); + } + + /// + /// Builds a SQL fragment with database names interpolated as literal N'...' values. + /// Use this for dynamic SQL paths where parameter binding is awkward (e.g. inside + /// a string passed to sp_executesql). Names come from user-picked checklists of + /// existing databases, so literal interpolation with single-quote escaping is safe. + /// When forNestedDynamicSql=true, doubles the escape for use inside an outer T-SQL + /// string that itself becomes a dynamic-SQL @sql variable. + /// + internal static string BuildDatabaseExclusionLiteralClause( + IList? excludedDatabaseNames, string columnExpression, bool forNestedDynamicSql = false) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + return string.Empty; + + string escapedQuote = forNestedDynamicSql ? "''" : "'"; + string Escape(string s) => forNestedDynamicSql + ? s.Replace("'", "''''") + : s.Replace("'", "''"); + + var quoted = excludedDatabaseNames.Select(n => $"N{escapedQuote}{Escape(n)}{escapedQuote}"); + return $"AND {columnExpression} NOT IN ({string.Join(", ", quoted)})"; + } + private static List SingleDbOrEmpty(string? targetDb) { if (string.IsNullOrEmpty(targetDb) || string.Equals(targetDb, "master", StringComparison.OrdinalIgnoreCase)) diff --git a/Lite/Windows/ExcludedDatabasesDialog.xaml b/Lite/Windows/ExcludedDatabasesDialog.xaml new file mode 100644 index 0000000..7fbbae2 --- /dev/null +++ b/Lite/Windows/ExcludedDatabasesDialog.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +