Skip to content

Commit 6c55dc6

Browse files
Merge pull request #905 from erikdarlingdata/feature/887-per-database-exclusions
Per-database exclusions for collectors — Dashboard side (#887)
2 parents 2ad8c67 + c1dcfc2 commit 6c55dc6

15 files changed

Lines changed: 465 additions & 1 deletion
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<Window x:Class="PerformanceMonitorDashboard.ExcludedDatabasesDialog"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
Title="Excluded Databases"
5+
Height="500" Width="500"
6+
WindowStartupLocation="CenterOwner"
7+
ResizeMode="CanResizeWithGrip"
8+
Background="{DynamicResource BackgroundBrush}">
9+
<Grid Margin="16">
10+
<Grid.RowDefinitions>
11+
<RowDefinition Height="Auto"/>
12+
<RowDefinition Height="Auto"/>
13+
<RowDefinition Height="*"/>
14+
<RowDefinition Height="Auto"/>
15+
<RowDefinition Height="Auto"/>
16+
</Grid.RowDefinitions>
17+
18+
<TextBlock Grid.Row="0" x:Name="HeaderText" FontWeight="Bold" FontSize="14"
19+
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,8"/>
20+
21+
<TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11"
22+
Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,12"
23+
Text="Databases marked here are skipped by per-database collectors (query_store, file_io_stats, etc.) on this server. System databases (master/tempdb/model/msdb) are always excluded."/>
24+
25+
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4">
26+
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8">
27+
<ItemsControl x:Name="DatabasesItemsControl">
28+
<ItemsControl.ItemTemplate>
29+
<DataTemplate>
30+
<CheckBox Content="{Binding DisplayName}"
31+
IsChecked="{Binding IsExcluded, Mode=TwoWay}"
32+
IsEnabled="{Binding IsEnabled}"
33+
Foreground="{Binding ForegroundBrush}"
34+
Margin="2,3"/>
35+
</DataTemplate>
36+
</ItemsControl.ItemTemplate>
37+
</ItemsControl>
38+
</ScrollViewer>
39+
</Border>
40+
41+
<TextBlock Grid.Row="3" x:Name="StatusText" FontSize="10" FontStyle="Italic"
42+
Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,8,0,0"/>
43+
44+
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
45+
<Button Content="Save" Width="80" Height="28" Margin="0,0,8,0" Click="Save_Click"/>
46+
<Button Content="Cancel" Width="80" Height="28" Click="Cancel_Click" IsCancel="True"/>
47+
</StackPanel>
48+
</Grid>
49+
</Window>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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.Collections.ObjectModel;
12+
using System.ComponentModel;
13+
using System.Linq;
14+
using System.Windows;
15+
using System.Windows.Media;
16+
using PerformanceMonitorDashboard.Models;
17+
using PerformanceMonitorDashboard.Services;
18+
19+
namespace PerformanceMonitorDashboard
20+
{
21+
public partial class ExcludedDatabasesDialog : Window
22+
{
23+
private readonly ServerManager _serverManager;
24+
private readonly ServerConnection _server;
25+
private ObservableCollection<DatabaseExclusionItem> _items = new();
26+
27+
public bool ExclusionsModified { get; private set; }
28+
29+
public ExcludedDatabasesDialog(ServerManager serverManager, ServerConnection server)
30+
{
31+
InitializeComponent();
32+
_serverManager = serverManager;
33+
_server = server;
34+
HeaderText.Text = $"Excluded Databases — {server.DisplayNameWithIntent}";
35+
Loaded += async (_, _) => await LoadAsync();
36+
}
37+
38+
private async System.Threading.Tasks.Task LoadAsync()
39+
{
40+
StatusText.Text = "Loading databases…";
41+
DatabasesItemsControl.ItemsSource = null;
42+
43+
try
44+
{
45+
var liveDatabases = await _serverManager.GetUserDatabasesAsync(_server);
46+
var existingExclusions = await _serverManager.GetCollectorDatabaseExclusionsAsync(_server);
47+
48+
var liveSet = new HashSet<string>(liveDatabases, StringComparer.OrdinalIgnoreCase);
49+
50+
_items = new ObservableCollection<DatabaseExclusionItem>();
51+
52+
/* Live databases: sortable, checkable */
53+
foreach (var name in liveDatabases.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
54+
{
55+
_items.Add(new DatabaseExclusionItem
56+
{
57+
Name = name,
58+
DisplayName = name,
59+
IsExcluded = existingExclusions.Contains(name, StringComparer.OrdinalIgnoreCase),
60+
IsEnabled = true,
61+
IsStale = false
62+
});
63+
}
64+
65+
/* Stale entries: in exclusion list but not present on the server. Show greyed, disabled, pre-checked. */
66+
foreach (var name in existingExclusions
67+
.Where(n => !liveSet.Contains(n))
68+
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
69+
{
70+
_items.Add(new DatabaseExclusionItem
71+
{
72+
Name = name,
73+
DisplayName = $"{name} (missing)",
74+
IsExcluded = true,
75+
IsEnabled = false,
76+
IsStale = true
77+
});
78+
}
79+
80+
DatabasesItemsControl.ItemsSource = _items;
81+
StatusText.Text = $"{liveDatabases.Count} database(s) on this server, {existingExclusions.Count} currently excluded.";
82+
}
83+
catch (Exception ex)
84+
{
85+
StatusText.Text = $"Failed to load: {ex.Message}";
86+
MessageBox.Show(this,
87+
$"Could not read database list from '{_server.DisplayNameWithIntent}':\n\n{ex.Message}",
88+
"Load Failed",
89+
MessageBoxButton.OK,
90+
MessageBoxImage.Error);
91+
}
92+
}
93+
94+
private async void Save_Click(object sender, RoutedEventArgs e)
95+
{
96+
/* Collect every checked item (live + stale). Stale ones can't be unchecked, so they stay if they were excluded. */
97+
var checkedNames = _items
98+
.Where(i => i.IsExcluded)
99+
.Select(i => i.Name)
100+
.ToList();
101+
102+
Cursor = System.Windows.Input.Cursors.Wait;
103+
try
104+
{
105+
await _serverManager.SaveCollectorDatabaseExclusionsAsync(_server, checkedNames);
106+
ExclusionsModified = true;
107+
DialogResult = true;
108+
}
109+
catch (Exception ex)
110+
{
111+
MessageBox.Show(this,
112+
$"Failed to save exclusions on '{_server.DisplayNameWithIntent}':\n\n{ex.Message}",
113+
"Save Failed",
114+
MessageBoxButton.OK,
115+
MessageBoxImage.Error);
116+
}
117+
finally
118+
{
119+
Cursor = null;
120+
}
121+
}
122+
123+
private void Cancel_Click(object sender, RoutedEventArgs e)
124+
{
125+
DialogResult = false;
126+
}
127+
}
128+
129+
public class DatabaseExclusionItem : INotifyPropertyChanged
130+
{
131+
public string Name { get; set; } = "";
132+
public string DisplayName { get; set; } = "";
133+
private bool _isExcluded;
134+
public bool IsExcluded
135+
{
136+
get => _isExcluded;
137+
set { _isExcluded = value; OnPropertyChanged(nameof(IsExcluded)); }
138+
}
139+
public bool IsEnabled { get; set; } = true;
140+
public bool IsStale { get; set; }
141+
142+
public Brush ForegroundBrush => IsStale
143+
? (Brush)Application.Current.FindResource("ForegroundMutedBrush")
144+
: (Brush)Application.Current.FindResource("ForegroundBrush");
145+
146+
public event PropertyChangedEventHandler? PropertyChanged;
147+
private void OnPropertyChanged(string propertyName)
148+
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
149+
}
150+
}

Dashboard/ManageServersWindow.xaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
33
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
44
Title="Manage Servers"
5-
Height="450" Width="780"
5+
Height="450" Width="960"
66
WindowStartupLocation="CenterOwner"
77
ResizeMode="CanResizeWithGrip"
88
Background="{DynamicResource BackgroundBrush}">
@@ -12,6 +12,7 @@
1212
<MenuItem Header="Edit..." Click="EditServer_Click"/>
1313
<MenuItem Header="Toggle Favorite" Click="ToggleFavorite_Click"/>
1414
<MenuItem Header="Check Server Version" Click="CheckForUpdates_Click"/>
15+
<MenuItem Header="Excluded Databases" Click="ExcludedDatabases_Click"/>
1516
<MenuItem Header="Purge Now" Click="PurgeNow_Click"/>
1617
<MenuItem Header="Remove" Click="RemoveServer_Click"
1718
Foreground="{DynamicResource ErrorBrush}"/>
@@ -98,6 +99,7 @@
9899
<Button Content="Edit..." Width="80" Height="30" Margin="0,0,8,0" Click="EditServer_Click"/>
99100
<Button x:Name="ToggleFavoriteButton" Content="Toggle Favorite" Width="110" Height="30" Margin="0,0,8,0" Click="ToggleFavorite_Click"/>
100101
<Button x:Name="CheckUpdatesButton" Content="Check Server Version" Width="140" Height="30" Margin="0,0,8,0" Click="CheckForUpdates_Click"/>
102+
<Button Content="Excluded Databases" Width="140" Height="30" Margin="0,0,8,0" Click="ExcludedDatabases_Click"/>
101103
<Button Content="Purge Now" Width="100" Height="30" Margin="0,0,8,0" Click="PurgeNow_Click"/>
102104
<Button Content="Remove" Width="80" Height="30" Margin="0,0,8,0" Click="RemoveServer_Click"
103105
Foreground="{DynamicResource ErrorBrush}"/>

Dashboard/ManageServersWindow.xaml.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,25 @@ string Normalize(string v)
297297
}
298298
}
299299

300+
private void ExcludedDatabases_Click(object sender, RoutedEventArgs e)
301+
{
302+
if (ServersDataGrid.SelectedItem is not ServerConnection server)
303+
{
304+
MessageBox.Show(
305+
"Please select a server to configure excluded databases.",
306+
"No Server Selected",
307+
MessageBoxButton.OK,
308+
MessageBoxImage.Information);
309+
return;
310+
}
311+
312+
var dialog = new ExcludedDatabasesDialog(_serverManager, server) { Owner = this };
313+
if (dialog.ShowDialog() == true && dialog.ExclusionsModified)
314+
{
315+
ServersModified = true;
316+
}
317+
}
318+
300319
private async void PurgeNow_Click(object sender, RoutedEventArgs e)
301320
{
302321
if (ServersDataGrid.SelectedItem is not ServerConnection server)

Dashboard/Services/ServerManager.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,115 @@ FROM config.collection_log AS cl
284284
return result;
285285
}
286286

287+
/// <summary>
288+
/// Returns user database names (excluding system DBs and PerformanceMonitor) on the target server,
289+
/// for use in the Excluded Databases dialog.
290+
/// </summary>
291+
public async Task<List<string>> GetUserDatabasesAsync(ServerConnection server)
292+
{
293+
var connectionString = server.GetConnectionString(_credentialService);
294+
var builder = new SqlConnectionStringBuilder(connectionString)
295+
{
296+
InitialCatalog = "master",
297+
ConnectTimeout = 10
298+
};
299+
300+
using var connection = new SqlConnection(builder.ConnectionString);
301+
await connection.OpenAsync();
302+
303+
using var cmd = new SqlCommand(@"
304+
SELECT d.name
305+
FROM sys.databases AS d
306+
WHERE d.database_id > 4
307+
AND d.state_desc = N'ONLINE'
308+
AND d.name <> N'PerformanceMonitor'
309+
AND d.database_id < 32761 /*exclude contained AG system databases*/
310+
ORDER BY d.name;", connection);
311+
cmd.CommandTimeout = 30;
312+
313+
var names = new List<string>();
314+
using var reader = await cmd.ExecuteReaderAsync();
315+
while (await reader.ReadAsync())
316+
{
317+
names.Add(reader.GetString(0));
318+
}
319+
return names;
320+
}
321+
322+
/// <summary>
323+
/// Returns the current per-database exclusion list from config.collector_database_exclusions on the target.
324+
/// </summary>
325+
public async Task<List<string>> GetCollectorDatabaseExclusionsAsync(ServerConnection server)
326+
{
327+
var connectionString = server.GetConnectionString(_credentialService);
328+
var builder = new SqlConnectionStringBuilder(connectionString)
329+
{
330+
InitialCatalog = "PerformanceMonitor",
331+
ConnectTimeout = 10
332+
};
333+
334+
using var connection = new SqlConnection(builder.ConnectionString);
335+
await connection.OpenAsync();
336+
337+
using var cmd = new SqlCommand(@"
338+
SELECT e.database_name
339+
FROM config.collector_database_exclusions AS e
340+
ORDER BY e.database_name;", connection);
341+
cmd.CommandTimeout = 30;
342+
343+
var names = new List<string>();
344+
using var reader = await cmd.ExecuteReaderAsync();
345+
while (await reader.ReadAsync())
346+
{
347+
names.Add(reader.GetString(0));
348+
}
349+
return names;
350+
}
351+
352+
/// <summary>
353+
/// Replaces the contents of config.collector_database_exclusions with the supplied list, transactionally.
354+
/// </summary>
355+
public async Task SaveCollectorDatabaseExclusionsAsync(ServerConnection server, IEnumerable<string> databaseNames)
356+
{
357+
var connectionString = server.GetConnectionString(_credentialService);
358+
var builder = new SqlConnectionStringBuilder(connectionString)
359+
{
360+
InitialCatalog = "PerformanceMonitor",
361+
ConnectTimeout = 10
362+
};
363+
364+
using var connection = new SqlConnection(builder.ConnectionString);
365+
await connection.OpenAsync();
366+
367+
using var transaction = connection.BeginTransaction();
368+
try
369+
{
370+
using (var deleteCmd = new SqlCommand("DELETE FROM config.collector_database_exclusions;", connection, transaction))
371+
{
372+
deleteCmd.CommandTimeout = 30;
373+
await deleteCmd.ExecuteNonQueryAsync();
374+
}
375+
376+
foreach (var name in databaseNames.Distinct(StringComparer.OrdinalIgnoreCase))
377+
{
378+
using var insertCmd = new SqlCommand(
379+
"INSERT INTO config.collector_database_exclusions (database_name) VALUES (@name);",
380+
connection, transaction);
381+
insertCmd.CommandTimeout = 30;
382+
insertCmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 128) { Value = name });
383+
await insertCmd.ExecuteNonQueryAsync();
384+
}
385+
386+
transaction.Commit();
387+
Logger.Info($"Saved collector database exclusions on '{server.DisplayName}'");
388+
}
389+
catch
390+
{
391+
transaction.Rollback();
392+
throw;
393+
}
394+
}
395+
287396
public void UpdateLastConnected(string id)
288397
{
289398
lock (_serversLock)

0 commit comments

Comments
 (0)