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
49 changes: 49 additions & 0 deletions Dashboard/ExcludedDatabasesDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<Window x:Class="PerformanceMonitorDashboard.ExcludedDatabasesDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Excluded Databases"
Height="500" Width="500"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResizeWithGrip"
Background="{DynamicResource BackgroundBrush}">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<TextBlock Grid.Row="0" x:Name="HeaderText" FontWeight="Bold" FontSize="14"
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,8"/>

<TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,12"
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."/>

<Border Grid.Row="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8">
<ItemsControl x:Name="DatabasesItemsControl">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding DisplayName}"
IsChecked="{Binding IsExcluded, Mode=TwoWay}"
IsEnabled="{Binding IsEnabled}"
Foreground="{Binding ForegroundBrush}"
Margin="2,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>

<TextBlock Grid.Row="3" x:Name="StatusText" FontSize="10" FontStyle="Italic"
Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,8,0,0"/>

<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Save" Width="80" Height="28" Margin="0,0,8,0" Click="Save_Click"/>
<Button Content="Cancel" Width="80" Height="28" Click="Cancel_Click" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
150 changes: 150 additions & 0 deletions Dashboard/ExcludedDatabasesDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Services;

namespace PerformanceMonitorDashboard
{
public partial class ExcludedDatabasesDialog : Window
{
private readonly ServerManager _serverManager;
private readonly ServerConnection _server;
private ObservableCollection<DatabaseExclusionItem> _items = new();

public bool ExclusionsModified { get; private set; }

public ExcludedDatabasesDialog(ServerManager serverManager, ServerConnection server)
{
InitializeComponent();
_serverManager = serverManager;
_server = server;
HeaderText.Text = $"Excluded Databases — {server.DisplayNameWithIntent}";
Loaded += async (_, _) => await LoadAsync();
}

private async System.Threading.Tasks.Task LoadAsync()
{
StatusText.Text = "Loading databases…";
DatabasesItemsControl.ItemsSource = null;

try
{
var liveDatabases = await _serverManager.GetUserDatabasesAsync(_server);
var existingExclusions = await _serverManager.GetCollectorDatabaseExclusionsAsync(_server);

var liveSet = new HashSet<string>(liveDatabases, StringComparer.OrdinalIgnoreCase);

_items = new ObservableCollection<DatabaseExclusionItem>();

/* Live databases: sortable, checkable */
foreach (var name in liveDatabases.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
{
_items.Add(new DatabaseExclusionItem
{
Name = name,
DisplayName = name,
IsExcluded = existingExclusions.Contains(name, StringComparer.OrdinalIgnoreCase),
IsEnabled = true,
IsStale = false
});
}

/* Stale entries: in exclusion list but not present on the server. Show greyed, disabled, pre-checked. */
foreach (var name in existingExclusions
.Where(n => !liveSet.Contains(n))
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
{
_items.Add(new DatabaseExclusionItem
{
Name = name,
DisplayName = $"{name} (missing)",
IsExcluded = true,
IsEnabled = false,
IsStale = true
});
}

DatabasesItemsControl.ItemsSource = _items;
StatusText.Text = $"{liveDatabases.Count} database(s) on this server, {existingExclusions.Count} currently excluded.";
}
catch (Exception ex)
{
StatusText.Text = $"Failed to load: {ex.Message}";
MessageBox.Show(this,
$"Could not read database list from '{_server.DisplayNameWithIntent}':\n\n{ex.Message}",
"Load Failed",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}

private async void Save_Click(object sender, RoutedEventArgs e)
{
/* Collect every checked item (live + stale). Stale ones can't be unchecked, so they stay if they were excluded. */
var checkedNames = _items
.Where(i => i.IsExcluded)
.Select(i => i.Name)
.ToList();

Cursor = System.Windows.Input.Cursors.Wait;
try
{
await _serverManager.SaveCollectorDatabaseExclusionsAsync(_server, checkedNames);
ExclusionsModified = true;
DialogResult = true;
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Failed to save exclusions on '{_server.DisplayNameWithIntent}':\n\n{ex.Message}",
"Save Failed",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
finally
{
Cursor = null;
}
}

private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}

public class DatabaseExclusionItem : INotifyPropertyChanged
{
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
private bool _isExcluded;
public bool IsExcluded
{
get => _isExcluded;
set { _isExcluded = value; OnPropertyChanged(nameof(IsExcluded)); }
}
public bool IsEnabled { get; set; } = true;
public bool IsStale { get; set; }

public Brush ForegroundBrush => IsStale
? (Brush)Application.Current.FindResource("ForegroundMutedBrush")
: (Brush)Application.Current.FindResource("ForegroundBrush");

public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
4 changes: 3 additions & 1 deletion Dashboard/ManageServersWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Manage Servers"
Height="450" Width="780"
Height="450" Width="960"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResizeWithGrip"
Background="{DynamicResource BackgroundBrush}">
Expand All @@ -12,6 +12,7 @@
<MenuItem Header="Edit..." Click="EditServer_Click"/>
<MenuItem Header="Toggle Favorite" Click="ToggleFavorite_Click"/>
<MenuItem Header="Check Server Version" Click="CheckForUpdates_Click"/>
<MenuItem Header="Excluded Databases" Click="ExcludedDatabases_Click"/>
<MenuItem Header="Purge Now" Click="PurgeNow_Click"/>
<MenuItem Header="Remove" Click="RemoveServer_Click"
Foreground="{DynamicResource ErrorBrush}"/>
Expand Down Expand Up @@ -98,6 +99,7 @@
<Button Content="Edit..." Width="80" Height="30" Margin="0,0,8,0" Click="EditServer_Click"/>
<Button x:Name="ToggleFavoriteButton" Content="Toggle Favorite" Width="110" Height="30" Margin="0,0,8,0" Click="ToggleFavorite_Click"/>
<Button x:Name="CheckUpdatesButton" Content="Check Server Version" Width="140" Height="30" Margin="0,0,8,0" Click="CheckForUpdates_Click"/>
<Button Content="Excluded Databases" Width="140" Height="30" Margin="0,0,8,0" Click="ExcludedDatabases_Click"/>
<Button Content="Purge Now" Width="100" Height="30" Margin="0,0,8,0" Click="PurgeNow_Click"/>
<Button Content="Remove" Width="80" Height="30" Margin="0,0,8,0" Click="RemoveServer_Click"
Foreground="{DynamicResource ErrorBrush}"/>
Expand Down
19 changes: 19 additions & 0 deletions Dashboard/ManageServersWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,25 @@ string Normalize(string v)
}
}

private void ExcludedDatabases_Click(object sender, RoutedEventArgs e)
{
if (ServersDataGrid.SelectedItem is not ServerConnection server)
{
MessageBox.Show(
"Please select a server to configure excluded databases.",
"No Server Selected",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}

var dialog = new ExcludedDatabasesDialog(_serverManager, server) { Owner = this };
if (dialog.ShowDialog() == true && dialog.ExclusionsModified)
{
ServersModified = true;
}
}

private async void PurgeNow_Click(object sender, RoutedEventArgs e)
{
if (ServersDataGrid.SelectedItem is not ServerConnection server)
Expand Down
109 changes: 109 additions & 0 deletions Dashboard/Services/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,115 @@ FROM config.collection_log AS cl
return result;
}

/// <summary>
/// Returns user database names (excluding system DBs and PerformanceMonitor) on the target server,
/// for use in the Excluded Databases dialog.
/// </summary>
public async Task<List<string>> GetUserDatabasesAsync(ServerConnection server)
{
var connectionString = server.GetConnectionString(_credentialService);
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "master",
ConnectTimeout = 10
};

using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

using var cmd = new SqlCommand(@"
SELECT d.name
FROM sys.databases AS d
WHERE d.database_id > 4
AND d.state_desc = N'ONLINE'
AND d.name <> N'PerformanceMonitor'
AND d.database_id < 32761 /*exclude contained AG system databases*/
ORDER BY d.name;", connection);
cmd.CommandTimeout = 30;

var names = new List<string>();
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}

/// <summary>
/// Returns the current per-database exclusion list from config.collector_database_exclusions on the target.
/// </summary>
public async Task<List<string>> GetCollectorDatabaseExclusionsAsync(ServerConnection server)
{
var connectionString = server.GetConnectionString(_credentialService);
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "PerformanceMonitor",
ConnectTimeout = 10
};

using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

using var cmd = new SqlCommand(@"
SELECT e.database_name
FROM config.collector_database_exclusions AS e
ORDER BY e.database_name;", connection);
cmd.CommandTimeout = 30;

var names = new List<string>();
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}

/// <summary>
/// Replaces the contents of config.collector_database_exclusions with the supplied list, transactionally.
/// </summary>
public async Task SaveCollectorDatabaseExclusionsAsync(ServerConnection server, IEnumerable<string> databaseNames)
{
var connectionString = server.GetConnectionString(_credentialService);
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "PerformanceMonitor",
ConnectTimeout = 10
};

using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

using var transaction = connection.BeginTransaction();
try
{
using (var deleteCmd = new SqlCommand("DELETE FROM config.collector_database_exclusions;", connection, transaction))
{
deleteCmd.CommandTimeout = 30;
await deleteCmd.ExecuteNonQueryAsync();
}

foreach (var name in databaseNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
using var insertCmd = new SqlCommand(
"INSERT INTO config.collector_database_exclusions (database_name) VALUES (@name);",
connection, transaction);
insertCmd.CommandTimeout = 30;
insertCmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 128) { Value = name });
await insertCmd.ExecuteNonQueryAsync();
}

transaction.Commit();
Logger.Info($"Saved collector database exclusions on '{server.DisplayName}'");
}
catch
{
transaction.Rollback();
throw;
}
}

public void UpdateLastConnected(string id)
{
lock (_serversLock)
Expand Down
Loading
Loading