diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dbc6413..e9a156bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [2.8.0] - TBD
+## [2.9.0] - TBD
+
+### Important
+
+- **Breaking change to `config.data_retention`** — the `@truncate_all` parameter has been removed. Pass `@retention_days = 0` for the same behavior. `@retention_days = NULL` (default) respects per-collector retention from `config.collection_schedule` with a 30-day fallback for unscheduled tables; `@retention_days = N > 0` overrides every table to N days. Any existing Agent jobs or scripts calling `data_retention @truncate_all = 1` need to be updated ([#900])
+- **New `config.collector_database_exclusions` table** for per-database collector exclusions. Eight per-database collectors filter against this table; system databases remain hard-skipped by the collectors themselves. Existing installs get the table on the next upgrade — `install/01_install_database.sql` and `config.ensure_config_tables` both create it under an `IF OBJECT_ID … IS NULL` guard ([#887])
+
+### Added
+
+- **Per-database collector exclusions** — exclude noisy or unimportant databases from per-database collectors. Dashboard side adds `config.collector_database_exclusions` and filters 8 collectors (`query_stats`, `query_store`, `procedure_stats`, `file_io_stats`, `waiting_tasks`, `database_configuration`, `database_size_stats`, `server_properties`). Lite side adds an `ExcludedDatabases` list per server in `servers.json` and filters 9 collectors ([#887])
+- **`Off` collection preset** — `EXECUTE config.apply_collection_preset @preset_name = N'Off'` disables every collector in one call. Pair with a second Agent job that applies a non-`Off` preset at the start of your active window for overnight / quiet-hours scoping. Non-`Off` presets now also set `enabled = 1` across the board so the switch from `Off → Balanced` reliably resumes collection ([#888])
+- **Purge Now action** in Manage Servers — confirm dialog with a mode picker (Use configured / 1 / 3 / 7 / Custom / All) drives `config.data_retention`; right-click menu on the Manage Servers grid mirrors every per-row action (Edit, Toggle Favorite, Check Server Version, Purge Now, Remove) ([#900])
+- **Total non-idle CPU on Lite Overview** — headline value shows total CPU with the SQL-only value alongside (e.g. `64% (SQL 60%)`); new `CpuAlertMode` dropdown in Settings → Alerts (Total / SqlOnly) drives both the alert evaluator and headline color; tray notifications and email alerts label the value as "Total CPU" or "SQL CPU" ([#899])
+- **Resume gap detection** — `query_stats`, `procedure_stats`, and `query_store` collectors skip the historical sweep on first run after an Off preset, Agent stoppage, or server reboot. When the last successful run is older than 5× the configured `frequency_minutes` (floored at 30 minutes), the cutoff clamps to `SYSDATETIME()` so only forward-going data is collected on resume — preventing the tempdb blowout that hit the original reporter ([#892])
+- **Right-click View Plan** on Dashboard Blocked Process Reports (View Blocked Plan + View Blocking Plan), Dashboard Deadlocks, and Lite Deadlocks grids. Plan lookup hits `sys.dm_exec_query_stats` + `sys.dm_exec_text_query_plan` on the monitored server, falling back to `executionStack/frame` entries when the process-level `sql_handle` is empty or evicted ([#880])
+- **Open Log Folder** sidebar button in Lite — opens `%LocalAppData%\PerformanceMonitorLite\logs\` in Explorer for grabbing historical logs to attach to bug reports. Sits below View Log, which retains its existing behavior of opening today's log file ([#873])
+- **Installed Version column** in the Manage Servers grid for both Dashboard and Lite. Dashboard shows the PerformanceMonitor database version on each server (probed in parallel via `GetInstalledVersionAsync`, with `Not installed` / `Unavailable` fallbacks). Lite shows the running app's own version on every row, mirroring Full's column header for consistency.
+- **Lite-style server card indicators in Full** — back-ported the Ellipse-with-DataTriggers status dot (Online/Offline/Warning/Unknown) and the right-aligned favorite star from Lite to the Full Dashboard's server list, matching Lite's visual treatment.
+- **Architecture overview** at `docs/how-collection-works.md` covering the minute loop, dispatcher, collector shape, `config.collection_schedule`, retention, and the Dashboard read path
+
+### Changed
+
+- **PlanIconMapper synced** with PerformanceStudio v1.9.0 improvements — columnstore storage type on scan/delete/insert/update/merge operators routes to `columnstore_index_*` icons (covers CCI and NCCI); `Parallelism` operator subtypes (Repartition Streams, Distribute Streams, Gather Streams) get their own icons
+- **`Microsoft.Data.SqlClient` 6.1.4 → 7.0.1** — major-version bump. Azure/Entra dependencies were split out of the core package in 7.0; `Microsoft.Data.SqlClient.Extensions.Azure 1.0.0` added to Dashboard, Lite, and Installer.Core for `ActiveDirectoryInteractive` connections
+- **`ModelContextProtocol` 0.7.0-preview.1 → 1.2.0** — off the preview tag and onto stable 1.x in Dashboard and Lite
+- **`DuckDB.NET` 1.5.0 → 1.5.2** in Lite — fixes unbounded row group growth on indexed tables under repeated load+insert cycles, memory leaks and race conditions in prepared statements, WAL checkpoint marking, and Windows UTF-8/UTF-16 handling
+- **`Microsoft.Extensions.*` 10.0.5 → 10.0.7**, **`System.Text.Json` 10.0.5 → 10.0.7**, **`ScottPlot.WPF` 5.1.57 → 5.1.58** — patch-level bumps with no expected behavioral change
+- **Theme polish** on grids and plan viewer in Dashboard and Lite — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) ([#889])
+
+### Fixed
+
+- **Install loop timeout** raised from 5 minutes to 1 hour. `install/98_validate_installation.sql` runs every enabled collector with `@debug = 1` in a single batch; on large databases (reporter had 7.2M rows in `collect.query_stats`, 4.4M in `collect.query_store_data`) this took ~9 minutes and was blowing the 5-minute timeout, failing the install or upgrade ([#884])
+
+[#873]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/873
+[#880]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/880
+[#884]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/884
+[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887
+[#888]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/888
+[#889]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/889
+[#892]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/892
+[#899]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/899
+[#900]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/900
+
+## [2.8.0] - 2026-04-22
### Important
diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml
index 43573d47..0d8d0ba7 100644
--- a/Dashboard/Controls/FinOpsContent.xaml
+++ b/Dashboard/Controls/FinOpsContent.xaml
@@ -111,6 +111,9 @@
+
+
+
@@ -416,6 +419,9 @@
+
+
+
diff --git a/Dashboard/Controls/PlanViewerControl.xaml b/Dashboard/Controls/PlanViewerControl.xaml
index d41669fc..2f720e50 100644
--- a/Dashboard/Controls/PlanViewerControl.xaml
+++ b/Dashboard/Controls/PlanViewerControl.xaml
@@ -77,15 +77,15 @@
+ Background="{DynamicResource PlanMissingIndexBgBrush}"
+ BorderBrush="{DynamicResource PlanMissingIndexBorderBrush}" BorderThickness="0,0,1,0">
+ Background="{DynamicResource PlanWaitStatsBgBrush}">
+ (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23));
+ private SolidColorBrush TooltipBorderBrush =>
+ (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45));
+ private SolidColorBrush TooltipFgBrush =>
+ (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB));
+ private SolidColorBrush MutedBrush =>
+ (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB));
+ private SolidColorBrush SectionHeaderBrush =>
+ (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF));
+ private SolidColorBrush PropSeparatorBrush =>
+ (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35));
+
// Current property section for collapsible groups
private StackPanel? _currentPropertySection;
@@ -55,6 +64,28 @@ public partial class PlanViewerControl : UserControl
public PlanViewerControl()
{
InitializeComponent();
+ Helpers.ThemeManager.ThemeChanged += OnThemeChanged;
+ Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged;
+ }
+
+ private void OnThemeChanged(string _)
+ {
+ if (_currentStatement == null) return;
+
+ var nodeToRestore = _selectedNode;
+ RenderStatement(_currentStatement);
+
+ if (nodeToRestore == null) return;
+
+ // Find the re-created border for the previously selected node and reopen properties
+ foreach (var child in PlanCanvas.Children)
+ {
+ if (child is Border b && b.Tag == nodeToRestore)
+ {
+ SelectNode(b, nodeToRestore);
+ break;
+ }
+ }
}
public void LoadPlan(string planXml, string label, string? queryText = null)
@@ -488,14 +519,14 @@ void AddRow(string label, string value)
var lbl = new TextBlock
{
Text = label,
- Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ Foreground = MutedBrush,
FontSize = 12,
Margin = new Thickness(0, 1, 12, 1)
};
var val = new TextBlock
{
Text = value,
- Foreground = new SolidColorBrush(Colors.White),
+ Foreground = TooltipFgBrush,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Right,
@@ -528,8 +559,8 @@ void AddRow(string label, string value)
return new Border
{
- Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
- BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ Background = TooltipBgBrush,
+ BorderBrush = TooltipBorderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 6, 10, 6),
CornerRadius = new CornerRadius(4),
@@ -571,6 +602,7 @@ private void SelectNode(Border border, PlanNode node)
_selectedNodeOriginalBorder = border.BorderBrush;
_selectedNodeOriginalThickness = border.BorderThickness;
_selectedNodeBorder = border;
+ _selectedNode = node;
border.BorderBrush = SelectionBrush;
border.BorderThickness = new Thickness(2);
@@ -1504,7 +1536,7 @@ private void AddPropertySection(string title)
Margin = new Thickness(0, 2, 0, 0),
Padding = new Thickness(0),
Foreground = SectionHeaderBrush,
- Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
+ Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
BorderBrush = PropSeparatorBrush,
BorderThickness = new Thickness(0, 0, 0, 1)
};
@@ -1568,6 +1600,7 @@ private void ClosePropertiesPanel()
_selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
_selectedNodeBorder = null;
}
+ _selectedNode = null;
}
#endregion
@@ -1780,7 +1813,7 @@ private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings =
return tip;
}
- private static void AddTooltipSection(StackPanel parent, string title)
+ private void AddTooltipSection(StackPanel parent, string title)
{
parent.Children.Add(new TextBlock
{
@@ -1792,7 +1825,7 @@ private static void AddTooltipSection(StackPanel parent, string title)
});
}
- private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
+ private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) };
row.Children.Add(new TextBlock
@@ -1835,19 +1868,19 @@ private void ShowMissingIndexes(List indexes)
{
Text = mi.Table,
FontWeight = FontWeights.SemiBold,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
FontSize = 12
});
headerRow.Children.Add(new TextBlock
{
Text = $" \u2014 Impact: ",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = MutedBrush,
FontSize = 12
});
headerRow.Children.Add(new TextBlock
{
Text = $"{mi.Impact:F1}%",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFB347")),
+ Foreground = OrangeBrush,
FontSize = 12
});
itemPanel.Children.Add(headerRow);
@@ -1859,7 +1892,7 @@ private void ShowMissingIndexes(List indexes)
Text = mi.CreateStatement,
FontFamily = new FontFamily("Consolas"),
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
IsReadOnly = true,
@@ -1932,7 +1965,7 @@ private void ShowWaitStats(List waits, bool isActualPlan)
{
Text = w.WaitType,
FontSize = 12,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 10, 2)
};
@@ -1958,7 +1991,7 @@ private void ShowWaitStats(List waits, bool isActualPlan)
{
Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)",
FontSize = 12,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 0, 2)
};
@@ -2014,8 +2047,8 @@ private void ShowRuntimeSummary(PlanStatement statement)
{
RuntimeSummaryContent.Children.Clear();
- var labelColor = "#E4E6EB";
- var valueColor = "#E4E6EB";
+ var labelBrush = MutedBrush;
+ var valueBrush = TooltipFgBrush;
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -2030,7 +2063,7 @@ void AddRow(string label, string value)
{
Text = label,
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(labelColor)),
+ Foreground = labelBrush,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 1, 8, 1)
};
@@ -2042,7 +2075,7 @@ void AddRow(string label, string value)
{
Text = value,
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(valueColor)),
+ Foreground = valueBrush,
Margin = new Thickness(0, 1, 0, 1)
};
Grid.SetRow(valueText, rowIndex);
diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml
index 235f8efd..aa033e13 100644
--- a/Dashboard/Controls/QueryPerformanceContent.xaml
+++ b/Dashboard/Controls/QueryPerformanceContent.xaml
@@ -879,6 +879,7 @@
+
@@ -887,19 +888,19 @@
-
+
-
+
-
+
-
+
@@ -1194,6 +1195,7 @@
+
@@ -1203,19 +1205,19 @@
-
+
-
+
-
+
-
+
@@ -1646,6 +1648,7 @@
+
@@ -1654,19 +1657,19 @@
-
+
-
+
-
+
-
+
diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj
index aff84b73..1d01ed09 100644
--- a/Dashboard/Dashboard.csproj
+++ b/Dashboard/Dashboard.csproj
@@ -7,10 +7,10 @@
PerformanceMonitorDashboard.Program
PerformanceMonitorDashboard
SQL Server Performance Monitor Dashboard
- 2.8.0
- 2.8.0.0
- 2.8.0.0
- 2.8.0
+ 2.9.0
+ 2.9.0.0
+ 2.9.0.0
+ 2.9.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
@@ -35,13 +35,14 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/Dashboard/ExcludedDatabasesDialog.xaml b/Dashboard/ExcludedDatabasesDialog.xaml
new file mode 100644
index 00000000..00789a61
--- /dev/null
+++ b/Dashboard/ExcludedDatabasesDialog.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/ExcludedDatabasesDialog.xaml.cs b/Dashboard/ExcludedDatabasesDialog.xaml.cs
new file mode 100644
index 00000000..6b6bf989
--- /dev/null
+++ b/Dashboard/ExcludedDatabasesDialog.xaml.cs
@@ -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 _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(liveDatabases, StringComparer.OrdinalIgnoreCase);
+
+ _items = new ObservableCollection();
+
+ /* 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));
+ }
+}
diff --git a/Dashboard/MainWindow.xaml b/Dashboard/MainWindow.xaml
index f279038a..c0282d32 100644
--- a/Dashboard/MainWindow.xaml
+++ b/Dashboard/MainWindow.xaml
@@ -88,47 +88,75 @@
BorderThickness="0">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ Margin="0,-1,0,0"
+ Visibility="{Binding HasBeenChecked, Converter={StaticResource BoolToVisibilityConverter}}"/>
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/Dashboard/ManageServersWindow.xaml b/Dashboard/ManageServersWindow.xaml
index 99d1650c..6804564b 100644
--- a/Dashboard/ManageServersWindow.xaml
+++ b/Dashboard/ManageServersWindow.xaml
@@ -2,13 +2,21 @@
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}">
+
+
+
+
+
+
+
@@ -66,6 +74,7 @@
+
+
+
+
+
@@ -619,7 +666,7 @@
@@ -813,7 +860,7 @@
diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs
index e793be13..cebd4e92 100644
--- a/Dashboard/ServerTab.xaml.cs
+++ b/Dashboard/ServerTab.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -12,6 +13,7 @@
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Threading;
+using Microsoft.Data.SqlClient;
using Microsoft.Win32;
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Interfaces;
@@ -2044,6 +2046,279 @@ private void DownloadDeadlockGraph_Click(object sender, RoutedEventArgs e)
}
}
+ // ── Blocked Process Report / Deadlock plan lookup ──
+
+ /* SQL Server writes this 42-byte all-zero handle into executionStack frames
+ for dynamic SQL / system contexts where no persistent sql_handle exists.
+ Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */
+ private static readonly string ZeroSqlHandle = "0x" + new string('0', 84);
+
+ private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e)
+ => await ShowBlockedProcessPlanAsync(sender, blockingSide: false);
+
+ private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e)
+ => await ShowBlockedProcessPlanAsync(sender, blockingSide: true);
+
+ private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide)
+ {
+ if (sender is not MenuItem menuItem) return;
+ if (menuItem.Parent is not ContextMenu cm) return;
+ var grid = FindDataGridFromContextMenu(cm);
+ if (grid?.SelectedItem is not BlockingEventItem row) return;
+
+ var sideLabel = blockingSide ? "Blocking" : "Blocked";
+ var label = $"Est Plan - {sideLabel} SPID {row.Spid}";
+
+ var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide);
+ if (frames.Count == 0)
+ {
+ MessageBox.Show(
+ $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " +
+ "This usually means the query ran as dynamic SQL or a system context — " +
+ "SQL Server records a zero handle in that case and the plan can't be recovered.",
+ "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ string? planXml = null;
+ try
+ {
+ var connStr = _serverConnection.GetConnectionString(_credentialService);
+ foreach (var f in frames)
+ {
+ planXml = await FetchPlanBySqlHandleAsync(
+ connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
+ if (!string.IsNullOrEmpty(planXml)) break;
+ }
+ }
+ catch { }
+
+ if (!string.IsNullOrEmpty(planXml))
+ {
+ OpenPlanTab(planXml, label, row.QueryText);
+ PlanViewerTabItem.IsSelected = true;
+ }
+ else
+ {
+ MessageBox.Show(
+ $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " +
+ "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.",
+ "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames(
+ string bprXml, bool blockingSide)
+ {
+ var empty = Array.Empty<(string, int, int)>();
+ if (string.IsNullOrWhiteSpace(bprXml)) return empty;
+ try
+ {
+ var doc = System.Xml.Linq.XElement.Parse(bprXml);
+ var processContainer = blockingSide
+ ? doc.Element("blocking-process")
+ : doc.Element("blocked-process");
+ var stack = processContainer?.Element("process")?.Element("executionStack");
+ if (stack == null) return empty;
+
+ var frames = new List<(string, int, int)>();
+ foreach (var frame in stack.Elements("frame"))
+ {
+ var handle = frame.Attribute("sqlhandle")?.Value;
+ if (string.IsNullOrWhiteSpace(handle)) continue;
+ if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;
+
+ int stmtStart = 0;
+ int stmtEnd = -1;
+ int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart);
+ if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se;
+
+ frames.Add((handle!, stmtStart, stmtEnd));
+ }
+ return frames;
+ }
+ catch
+ {
+ return empty;
+ }
+ }
+
+ /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the
+ node, with optional
+ children for the call stack. Match by SPID since Dashboard's row
+ model doesn't carry the process graph id. */
+ private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ if (menuItem.Parent is not ContextMenu cm) return;
+ var grid = FindDataGridFromContextMenu(cm);
+ if (grid?.SelectedItem is not DeadlockItem row) return;
+
+ var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType;
+ var label = $"Est Plan - {sideLabel} SPID {row.Spid}";
+
+ var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid);
+ if (frames.Count == 0)
+ {
+ MessageBox.Show(
+ "The process has no resolvable sql_handle in the deadlock graph. " +
+ "This usually means the query ran as dynamic SQL or a system context — " +
+ "SQL Server records a zero handle in that case and the plan can't be recovered.",
+ "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ string? planXml = null;
+ try
+ {
+ var connStr = _serverConnection.GetConnectionString(_credentialService);
+ foreach (var f in frames)
+ {
+ planXml = await FetchPlanBySqlHandleAsync(
+ connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
+ if (!string.IsNullOrEmpty(planXml)) break;
+ }
+ }
+ catch { }
+
+ if (!string.IsNullOrEmpty(planXml))
+ {
+ OpenPlanTab(planXml, label, row.Query);
+ PlanViewerTabItem.IsSelected = true;
+ }
+ else
+ {
+ MessageBox.Show(
+ $"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " +
+ "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.",
+ "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames(
+ string graphXml, short? spid)
+ {
+ var empty = Array.Empty<(string, int, int)>();
+ if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty;
+ try
+ {
+ var doc = System.Xml.Linq.XElement.Parse(graphXml);
+ var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture);
+ var process = doc.Descendants("process")
+ .FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal));
+ if (process == null) return empty;
+
+ var frames = new List<(string, int, int)>();
+
+ var procHandle = process.Attribute("sqlhandle")?.Value;
+ if (!string.IsNullOrWhiteSpace(procHandle) &&
+ !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase))
+ {
+ int ps = 0, pe = -1;
+ int.TryParse(process.Attribute("stmtstart")?.Value, out ps);
+ if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed;
+ frames.Add((procHandle!, ps, pe));
+ }
+
+ var stack = process.Element("executionStack");
+ if (stack != null)
+ {
+ foreach (var frame in stack.Elements("frame"))
+ {
+ var handle = frame.Attribute("sqlhandle")?.Value;
+ if (string.IsNullOrWhiteSpace(handle)) continue;
+ if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;
+
+ int fs = 0, fe = -1;
+ int.TryParse(frame.Attribute("stmtstart")?.Value, out fs);
+ if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed;
+ frames.Add((handle!, fs, fe));
+ }
+ }
+
+ return frames;
+ }
+ catch
+ {
+ return empty;
+ }
+ }
+
+ private static async Task FetchPlanBySqlHandleAsync(
+ string connectionString,
+ string databaseName,
+ string sqlHandleHex,
+ int statementStartOffset,
+ int statementEndOffset)
+ {
+ if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null;
+ var handleBytes = HexStringToBytes(sqlHandleHex);
+ if (handleBytes == null || handleBytes.Length == 0) return null;
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+
+ /* Database context is only used to route the execution; sys.dm_exec_query_stats
+ is server-scoped, so if the supplied name isn't valid we fall back to master. */
+ var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]";
+
+ var query = $@"
+EXECUTE {quotedDbName}.sys.sp_executesql
+ N'
+SELECT TOP (1)
+ query_plan_text = tqp.query_plan
+FROM sys.dm_exec_query_stats AS qs
+OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp
+WHERE qs.sql_handle = @h
+AND qs.statement_start_offset = @stmt_start
+AND qs.statement_end_offset = @stmt_end
+AND tqp.query_plan IS NOT NULL
+ORDER BY
+ qs.last_execution_time DESC
+OPTION(RECOMPILE);',
+ N'@h varbinary(64), @stmt_start int, @stmt_end int',
+ @h, @stmt_start, @stmt_end;";
+
+ using var command = new SqlCommand(query, connection) { CommandTimeout = 30 };
+ command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes });
+ command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset });
+ command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset });
+ var result = await command.ExecuteScalarAsync();
+ return result as string;
+ }
+
+ private static byte[]? HexStringToBytes(string hex)
+ {
+ var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0;
+ var len = hex.Length - start;
+ if (len <= 0 || (len % 2) != 0) return null;
+ var bytes = new byte[len / 2];
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ if (!byte.TryParse(hex.AsSpan(start + i * 2, 2),
+ NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture,
+ out bytes[i]))
+ {
+ return null;
+ }
+ }
+ return bytes;
+ }
+
+ /* Only accept names that are syntactically plain identifiers so we can safely
+ interpolate into the EXEC statement. Unknown / invalid names fall back to master. */
+ private static string? QuoteDatabaseName(string? dbName)
+ {
+ if (string.IsNullOrWhiteSpace(dbName)) return null;
+ foreach (var c in dbName)
+ {
+ if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' '))
+ return null;
+ }
+ return "[" + dbName.Replace("]", "]]") + "]";
+ }
+
private void LoadUserPreferences()
{
var prefs = _preferencesService.GetPreferences();
diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs
index 6ed411e3..c83e8b05 100644
--- a/Dashboard/Services/PlanIconMapper.cs
+++ b/Dashboard/Services/PlanIconMapper.cs
@@ -175,8 +175,44 @@ public static class PlanIconMapper
["Language Construct Catch All"] = "language_construct_catch_all",
};
- public static string GetIconName(string physicalOp)
+ public static string GetIconName(string physicalOp, string? storageType = null, string? logicalOp = null)
{
+ // Parallelism subtypes: PhysicalOp="Parallelism" + LogicalOp identifies which.
+ if (string.Equals(physicalOp, "Parallelism", StringComparison.Ordinal) && logicalOp != null)
+ {
+ switch (logicalOp)
+ {
+ case "Repartition Streams": return "parallelism_repartition_streams";
+ case "Distribute Streams": return "parallelism_distribute_streams";
+ case "Gather Streams": return "parallelism_gather_streams";
+ }
+ }
+
+ // Columnstore scans surface as PhysicalOp="Clustered Index Scan" / "Index Scan"
+ // with Storage="ColumnStore" on the Object element. Route to the columnstore icon.
+ if (string.Equals(storageType, "ColumnStore", StringComparison.OrdinalIgnoreCase))
+ {
+ switch (physicalOp)
+ {
+ case "Clustered Index Scan":
+ case "Index Scan":
+ case "Columnstore Index Scan":
+ return "columnstore_index_scan";
+ case "Clustered Index Delete":
+ case "Index Delete":
+ return "columnstore_index_delete";
+ case "Clustered Index Insert":
+ case "Index Insert":
+ return "columnstore_index_insert";
+ case "Clustered Index Update":
+ case "Index Update":
+ return "columnstore_index_update";
+ case "Clustered Index Merge":
+ case "Index Merge":
+ return "columnstore_index_merge";
+ }
+ }
+
if (IconMap.TryGetValue(physicalOp, out var iconName))
return iconName;
diff --git a/Dashboard/Services/ServerManager.cs b/Dashboard/Services/ServerManager.cs
index d3686645..433a4fde 100644
--- a/Dashboard/Services/ServerManager.cs
+++ b/Dashboard/Services/ServerManager.cs
@@ -196,6 +196,203 @@ IF DB_ID('PerformanceMonitor') IS NOT NULL
Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'");
}
+ public class PurgeResult
+ {
+ public int RowsDeleted { get; set; }
+ public int TableCount { get; set; }
+ public int DurationMs { get; set; }
+ public string Status { get; set; } = "";
+ public string? Message { get; set; }
+ }
+
+ ///
+ /// Runs config.data_retention against the PerformanceMonitor database on the given server.
+ ///
+ ///
+ /// null = use per-collector retention from config.collection_schedule.
+ /// 0 = TRUNCATE every collect.* table.
+ /// N > 0 = override every table's cutoff to N days.
+ ///
+ public async Task RunDataRetentionAsync(
+ ServerConnection server,
+ int? retentionDaysOverride)
+ {
+ 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("config.data_retention", connection))
+ {
+ cmd.CommandType = System.Data.CommandType.StoredProcedure;
+ cmd.CommandTimeout = 600;
+
+ if (retentionDaysOverride.HasValue)
+ {
+ cmd.Parameters.Add(new SqlParameter("@retention_days", System.Data.SqlDbType.Int) { Value = retentionDaysOverride.Value });
+ }
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ using var readCmd = new SqlCommand(@"
+SELECT TOP (1)
+ cl.collection_status,
+ cl.rows_collected,
+ cl.duration_ms,
+ cl.error_message
+FROM config.collection_log AS cl
+WHERE cl.collector_name = N'data_retention'
+ORDER BY cl.collection_time DESC;", connection);
+ readCmd.CommandTimeout = 30;
+
+ using var reader = await readCmd.ExecuteReaderAsync();
+ var result = new PurgeResult();
+
+ if (await reader.ReadAsync())
+ {
+ result.Status = reader.IsDBNull(0) ? "" : reader.GetString(0);
+ result.RowsDeleted = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
+ result.DurationMs = reader.IsDBNull(2) ? 0 : reader.GetInt32(2);
+ result.Message = reader.IsDBNull(3) ? null : reader.GetString(3);
+
+ if (result.Message is not null && result.Message.StartsWith("Cleaned ", StringComparison.Ordinal))
+ {
+ int spaceIdx = result.Message.IndexOf(' ', 8);
+ if (spaceIdx > 8 && int.TryParse(result.Message.AsSpan(8, spaceIdx - 8), out int tableCount))
+ {
+ result.TableCount = tableCount;
+ }
+ }
+ else if (result.Message is not null && result.Message.StartsWith("TRUNCATE all: ", StringComparison.Ordinal))
+ {
+ int spaceIdx = result.Message.IndexOf(' ', 14);
+ if (spaceIdx > 14 && int.TryParse(result.Message.AsSpan(14, spaceIdx - 14), out int tableCount))
+ {
+ result.TableCount = tableCount;
+ }
+ }
+ }
+
+ Logger.Info($"Ran data_retention on '{server.DisplayName}': status={result.Status}, rowsDeleted={result.RowsDeleted}, tables={result.TableCount}, durationMs={result.DurationMs}");
+
+ return result;
+ }
+
+ ///
+ /// Returns user database names (excluding system DBs and PerformanceMonitor) on the target server,
+ /// for use in the Excluded Databases dialog.
+ ///
+ public async Task> 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();
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ names.Add(reader.GetString(0));
+ }
+ return names;
+ }
+
+ ///
+ /// Returns the current per-database exclusion list from config.collector_database_exclusions on the target.
+ ///
+ public async Task> 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();
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ names.Add(reader.GetString(0));
+ }
+ return names;
+ }
+
+ ///
+ /// Replaces the contents of config.collector_database_exclusions with the supplied list, transactionally.
+ ///
+ public async Task SaveCollectorDatabaseExclusionsAsync(ServerConnection server, IEnumerable 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)
diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs
index d99793ec..5d56c894 100644
--- a/Dashboard/Services/ShowPlanParser.cs
+++ b/Dashboard/Services/ShowPlanParser.cs
@@ -665,8 +665,10 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.PhysicalOp = "Lazy " + node.PhysicalOp;
}
- // Map to icon
- node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp);
+ // Icon mapping is deferred until after StorageType and LogicalOp are
+ // parsed below, so columnstore scans (Clustered/Index Scan with
+ // Storage="ColumnStore") and Parallelism subtypes route to their
+ // specific icons.
// Handle operator-specific element
var physicalOpEl = GetOperatorElement(relOpEl);
@@ -1365,6 +1367,11 @@ private static PlanNode ParseRelOp(XElement relOpEl)
}
}
+ // Map to icon — done here so columnstore scans (Clustered/Index Scan
+ // with Storage="ColumnStore") and Parallelism subtypes (which depend on
+ // LogicalOp) can be routed to their specific icons.
+ node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp, node.StorageType, node.LogicalOp);
+
// Recurse into child RelOps
foreach (var childRelOp in FindChildRelOps(relOpEl))
{
diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml
index 36437cbc..a40c3af5 100644
--- a/Dashboard/Themes/CoolBreezeTheme.xaml
+++ b/Dashboard/Themes/CoolBreezeTheme.xaml
@@ -626,7 +626,7 @@
-
+
@@ -678,6 +678,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -785,7 +810,7 @@
-
@@ -807,7 +832,7 @@
-
+
@@ -817,6 +842,7 @@
+
@@ -1305,6 +1331,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml
index 20abf9bb..6f98ba5b 100644
--- a/Dashboard/Themes/DarkTheme.xaml
+++ b/Dashboard/Themes/DarkTheme.xaml
@@ -677,6 +677,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -784,7 +809,7 @@
-
@@ -816,6 +841,7 @@
+
@@ -1304,4 +1330,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Themes/LightTheme.xaml b/Dashboard/Themes/LightTheme.xaml
index 4e22f4f3..2bee5d50 100644
--- a/Dashboard/Themes/LightTheme.xaml
+++ b/Dashboard/Themes/LightTheme.xaml
@@ -626,7 +626,7 @@
-
+
@@ -678,6 +678,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -785,7 +810,7 @@
-
@@ -807,7 +832,7 @@
-
+
@@ -817,6 +842,7 @@
+
@@ -1305,4 +1331,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs
index e26f99f5..c675a8e9 100644
--- a/Installer.Core/InstallationService.cs
+++ b/Installer.Core/InstallationService.cs
@@ -432,7 +432,7 @@ Files execute without transaction wrapping because many contain DDL.
batchNumber++;
using var command = new SqlCommand(trimmedBatch, connection);
- command.CommandTimeout = StandardTimeoutSeconds;
+ command.CommandTimeout = UpgradeTimeoutSeconds;
try
{
diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj
index c5d9b0c0..e86a1521 100644
--- a/Installer.Core/Installer.Core.csproj
+++ b/Installer.Core/Installer.Core.csproj
@@ -7,10 +7,10 @@
Installer.Core
Installer.Core
SQL Server Performance Monitor Installer Core
- 2.8.0
- 2.8.0.0
- 2.8.0.0
- 2.8.0
+ 2.9.0
+ 2.9.0.0
+ 2.9.0.0
+ 2.9.0
Darling Data, LLC
Copyright (c) 2026 Darling Data, LLC
true
@@ -19,7 +19,8 @@
-
+
+
diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj
index 18a446f8..d605a241 100644
--- a/Installer.Tests/Installer.Tests.csproj
+++ b/Installer.Tests/Installer.Tests.csproj
@@ -10,13 +10,13 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj
index 83b6b38e..97d153d8 100644
--- a/Installer/PerformanceMonitorInstaller.csproj
+++ b/Installer/PerformanceMonitorInstaller.csproj
@@ -20,10 +20,10 @@
PerformanceMonitorInstaller
SQL Server Performance Monitor Installer
- 2.8.0
- 2.8.0.0
- 2.8.0.0
- 2.8.0
+ 2.9.0
+ 2.9.0.0
+ 2.9.0.0
+ 2.9.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025
@@ -31,7 +31,7 @@
-
+
diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj
index 61d0342e..6e6a58ef 100644
--- a/InstallerGui/InstallerGui.csproj
+++ b/InstallerGui/InstallerGui.csproj
@@ -27,7 +27,8 @@
-
+
+
diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj
index 874fd31f..a1c95836 100644
--- a/Lite.Tests/Lite.Tests.csproj
+++ b/Lite.Tests/Lite.Tests.csproj
@@ -9,7 +9,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index 8583a087..0164df15 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -17,6 +17,14 @@
namespace PerformanceMonitorLite;
+public enum CpuAlertMode
+{
+ /// sql_server_cpu + other_process_cpu — matches OS user+system, "is the box in trouble".
+ Total,
+ /// SQL Server scheduler ProcessUtilization only.
+ SqlOnly
+}
+
public partial class App : Application
{
[DllImport("shell32.dll", SetLastError = true)]
@@ -71,6 +79,8 @@ public partial class App : Application
public static bool NotifyConnectionChanges { get; set; } = true;
public static bool AlertCpuEnabled { get; set; } = true;
public static int AlertCpuThreshold { get; set; } = 80;
+ /// Which CPU metric the alert evaluates against. Total = sql_server_cpu + other_process_cpu (matches OS user+system). SqlOnly = SQL Server scheduler %.
+ public static CpuAlertMode AlertCpuMode { get; set; } = CpuAlertMode.Total;
public static bool AlertBlockingEnabled { get; set; } = true;
public static int AlertBlockingThreshold { get; set; } = 1;
public static bool AlertDeadlockEnabled { get; set; } = true;
@@ -323,6 +333,8 @@ public static void LoadAlertSettings()
if (root.TryGetProperty("notify_connection_changes", out v)) NotifyConnectionChanges = v.GetBoolean();
if (root.TryGetProperty("alert_cpu_enabled", out v)) AlertCpuEnabled = v.GetBoolean();
if (root.TryGetProperty("alert_cpu_threshold", out v)) AlertCpuThreshold = v.GetInt32();
+ if (root.TryGetProperty("alert_cpu_mode", out v) && Enum.TryParse(v.GetString(), out var mode))
+ AlertCpuMode = mode;
if (root.TryGetProperty("alert_blocking_enabled", out v)) AlertBlockingEnabled = v.GetBoolean();
if (root.TryGetProperty("alert_blocking_threshold", out v)) AlertBlockingThreshold = v.GetInt32();
if (root.TryGetProperty("alert_deadlock_enabled", out v)) AlertDeadlockEnabled = v.GetBoolean();
diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml
index c841008a..27c07ff4 100644
--- a/Lite/Controls/FinOpsTab.xaml
+++ b/Lite/Controls/FinOpsTab.xaml
@@ -97,6 +97,9 @@
+
+
+
@@ -375,6 +378,9 @@
+
+
+
diff --git a/Lite/Controls/PlanViewerControl.xaml b/Lite/Controls/PlanViewerControl.xaml
index 8a752579..aa44384f 100644
--- a/Lite/Controls/PlanViewerControl.xaml
+++ b/Lite/Controls/PlanViewerControl.xaml
@@ -80,15 +80,15 @@
+ Background="{DynamicResource PlanMissingIndexBgBrush}"
+ BorderBrush="{DynamicResource PlanMissingIndexBorderBrush}" BorderThickness="0,0,1,0">
+ Background="{DynamicResource PlanWaitStatsBgBrush}">
+ (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23));
+ private SolidColorBrush TooltipBorderBrush =>
+ (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45));
+ private SolidColorBrush TooltipFgBrush =>
+ (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB));
+ private SolidColorBrush MutedBrush =>
+ (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB));
+ private SolidColorBrush SectionHeaderBrush =>
+ (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF));
+ private SolidColorBrush PropSeparatorBrush =>
+ (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35));
+
// Current property section for collapsible groups
private StackPanel? _currentPropertySection;
@@ -55,6 +64,28 @@ public partial class PlanViewerControl : UserControl
public PlanViewerControl()
{
InitializeComponent();
+ Helpers.ThemeManager.ThemeChanged += OnThemeChanged;
+ Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged;
+ }
+
+ private void OnThemeChanged(string _)
+ {
+ if (_currentStatement == null) return;
+
+ var nodeToRestore = _selectedNode;
+ RenderStatement(_currentStatement);
+
+ if (nodeToRestore == null) return;
+
+ // Find the re-created border for the previously selected node and reopen properties
+ foreach (var child in PlanCanvas.Children)
+ {
+ if (child is Border b && b.Tag == nodeToRestore)
+ {
+ SelectNode(b, nodeToRestore);
+ break;
+ }
+ }
}
public void LoadPlan(string planXml, string label, string? queryText = null)
@@ -498,6 +529,10 @@ private Border BuildEdgeTooltipContent(PlanNode child)
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
int row = 0;
+ var bgBrush = TooltipBgBrush;
+ var borderBrush = TooltipBorderBrush;
+ var mutedBrush = MutedBrush;
+ var fgBrush = TooltipFgBrush;
void AddRow(string label, string value)
{
@@ -505,14 +540,14 @@ void AddRow(string label, string value)
var lbl = new TextBlock
{
Text = label,
- Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ Foreground = mutedBrush,
FontSize = 12,
Margin = new Thickness(0, 1, 12, 1)
};
var val = new TextBlock
{
Text = value,
- Foreground = new SolidColorBrush(Colors.White),
+ Foreground = fgBrush,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Right,
@@ -545,8 +580,8 @@ void AddRow(string label, string value)
return new Border
{
- Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
- BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ Background = bgBrush,
+ BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 6, 10, 6),
CornerRadius = new CornerRadius(4),
@@ -588,6 +623,7 @@ private void SelectNode(Border border, PlanNode node)
_selectedNodeOriginalBorder = border.BorderBrush;
_selectedNodeOriginalThickness = border.BorderThickness;
_selectedNodeBorder = border;
+ _selectedNode = node;
border.BorderBrush = SelectionBrush;
border.BorderThickness = new Thickness(2);
@@ -1521,7 +1557,7 @@ private void AddPropertySection(string title)
Margin = new Thickness(0, 2, 0, 0),
Padding = new Thickness(0),
Foreground = SectionHeaderBrush,
- Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
+ Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
BorderBrush = PropSeparatorBrush,
BorderThickness = new Thickness(0, 0, 0, 1)
};
@@ -1580,6 +1616,7 @@ private void ClosePropertiesPanel()
_selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
_selectedNodeBorder = null;
}
+ _selectedNode = null;
}
#endregion
@@ -1792,7 +1829,7 @@ private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings =
return tip;
}
- private static void AddTooltipSection(StackPanel parent, string title)
+ private void AddTooltipSection(StackPanel parent, string title)
{
parent.Children.Add(new TextBlock
{
@@ -1804,7 +1841,7 @@ private static void AddTooltipSection(StackPanel parent, string title)
});
}
- private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
+ private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) };
row.Children.Add(new TextBlock
@@ -1847,19 +1884,19 @@ private void ShowMissingIndexes(List indexes)
{
Text = mi.Table,
FontWeight = FontWeights.SemiBold,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
FontSize = 12
});
headerRow.Children.Add(new TextBlock
{
Text = $" \u2014 Impact: ",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = MutedBrush,
FontSize = 12
});
headerRow.Children.Add(new TextBlock
{
Text = $"{mi.Impact:F1}%",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFB347")),
+ Foreground = OrangeBrush,
FontSize = 12
});
itemPanel.Children.Add(headerRow);
@@ -1871,7 +1908,7 @@ private void ShowMissingIndexes(List indexes)
Text = mi.CreateStatement,
FontFamily = new FontFamily("Consolas"),
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
IsReadOnly = true,
@@ -1944,7 +1981,7 @@ private void ShowWaitStats(List waits, bool isActualPlan)
{
Text = w.WaitType,
FontSize = 12,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 10, 2)
};
@@ -1970,7 +2007,7 @@ private void ShowWaitStats(List waits, bool isActualPlan)
{
Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)",
FontSize = 12,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ Foreground = TooltipFgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 0, 2)
};
@@ -2026,8 +2063,8 @@ private void ShowRuntimeSummary(PlanStatement statement)
{
RuntimeSummaryContent.Children.Clear();
- var labelColor = "#E4E6EB";
- var valueColor = "#E4E6EB";
+ var labelBrush = MutedBrush;
+ var valueBrush = TooltipFgBrush;
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -2042,7 +2079,7 @@ void AddRow(string label, string value)
{
Text = label,
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(labelColor)),
+ Foreground = labelBrush,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 1, 8, 1)
};
@@ -2054,7 +2091,7 @@ void AddRow(string label, string value)
{
Text = value,
FontSize = 11,
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(valueColor)),
+ Foreground = valueBrush,
Margin = new Thickness(0, 1, 0, 1)
};
Grid.SetRow(valueText, rowIndex);
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 6ee7b1cb..26b018c7 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -57,6 +57,30 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -71,6 +95,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -758,7 +780,7 @@
-
+
@@ -768,6 +790,7 @@
+
@@ -1242,6 +1265,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml
index 869af3de..1702d76a 100644
--- a/Lite/Themes/DarkTheme.xaml
+++ b/Lite/Themes/DarkTheme.xaml
@@ -735,8 +735,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -768,6 +790,7 @@
+
@@ -1252,4 +1275,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Themes/LightTheme.xaml b/Lite/Themes/LightTheme.xaml
index e9908300..ce564069 100644
--- a/Lite/Themes/LightTheme.xaml
+++ b/Lite/Themes/LightTheme.xaml
@@ -735,8 +735,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -758,7 +780,7 @@
-
+
@@ -768,6 +790,7 @@
+
@@ -1242,4 +1265,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/ExcludedDatabasesDialog.xaml b/Lite/Windows/ExcludedDatabasesDialog.xaml
new file mode 100644
index 00000000..7fbbae24
--- /dev/null
+++ b/Lite/Windows/ExcludedDatabasesDialog.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/ExcludedDatabasesDialog.xaml.cs b/Lite/Windows/ExcludedDatabasesDialog.xaml.cs
new file mode 100644
index 00000000..733fa090
--- /dev/null
+++ b/Lite/Windows/ExcludedDatabasesDialog.xaml.cs
@@ -0,0 +1,174 @@
+/*
+ * 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.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Media;
+using Microsoft.Data.SqlClient;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class ExcludedDatabasesDialog : Window
+{
+ private readonly ServerManager _serverManager;
+ private readonly ServerConnection _server;
+ private ObservableCollection _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 Task LoadAsync()
+ {
+ StatusText.Text = "Loading databases…";
+ DatabasesItemsControl.ItemsSource = null;
+
+ try
+ {
+ var liveDatabases = await GetUserDatabasesAsync();
+ var existingExclusions = _server.ExcludedDatabases ?? new List();
+
+ var liveSet = new HashSet(liveDatabases, StringComparer.OrdinalIgnoreCase);
+
+ _items = new ObservableCollection();
+
+ 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. 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 Task> GetUserDatabasesAsync()
+ {
+ var connectionString = _server.GetConnectionString(_serverManager.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.database_id < 32761 /*exclude contained AG system databases*/
+ORDER BY d.name;", connection);
+ cmd.CommandTimeout = 30;
+
+ var names = new List();
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ names.Add(reader.GetString(0));
+ }
+ return names;
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ _server.ExcludedDatabases = _items
+ .Where(i => i.IsExcluded)
+ .Select(i => i.Name)
+ .ToList();
+
+ _serverManager.UpdateServer(_server);
+ ExclusionsModified = true;
+ DialogResult = true;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this,
+ $"Failed to save exclusions:\n\n{ex.Message}",
+ "Save Failed",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ 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));
+}
diff --git a/Lite/Windows/ManageServersWindow.xaml b/Lite/Windows/ManageServersWindow.xaml
index 4fd1156e..2cbf2271 100644
--- a/Lite/Windows/ManageServersWindow.xaml
+++ b/Lite/Windows/ManageServersWindow.xaml
@@ -9,6 +9,11 @@
+
+
+
+
@@ -50,6 +55,7 @@
+
@@ -66,6 +72,7 @@
+
diff --git a/Lite/Windows/ManageServersWindow.xaml.cs b/Lite/Windows/ManageServersWindow.xaml.cs
index 03b6b8e8..ffb128c5 100644
--- a/Lite/Windows/ManageServersWindow.xaml.cs
+++ b/Lite/Windows/ManageServersWindow.xaml.cs
@@ -6,6 +6,7 @@
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/
+using System.Reflection;
using System.Windows;
using System.Windows.Input;
using PerformanceMonitorLite.Models;
@@ -32,7 +33,27 @@ public ManageServersWindow(ServerManager serverManager)
private void RefreshGrid()
{
ServersGrid.ItemsSource = null;
- ServersGrid.ItemsSource = _serverManager.GetAllServers();
+ var servers = _serverManager.GetAllServers();
+ string appVersion = GetAppVersion();
+ foreach (var s in servers)
+ {
+ s.InstalledVersion = appVersion;
+ }
+ ServersGrid.ItemsSource = servers;
+ }
+
+ private static string GetAppVersion()
+ {
+ string raw = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.InformationalVersion
+ ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
+ ?? "0.0.0";
+
+ int plusIndex = raw.IndexOf('+');
+ string trimmed = plusIndex >= 0 ? raw[..plusIndex] : raw;
+ return System.Version.TryParse(trimmed, out var v)
+ ? new System.Version(v.Major, v.Minor, v.Build).ToString()
+ : trimmed;
}
private void AddButton_Click(object sender, RoutedEventArgs e)
@@ -70,6 +91,25 @@ private void EditSelected()
}
}
+ private void EditMenuItem_Click(object sender, RoutedEventArgs e) => EditSelected();
+
+ private void DeleteMenuItem_Click(object sender, RoutedEventArgs e) => DeleteButton_Click(sender, e);
+
+ private void ExcludedDatabases_Click(object sender, RoutedEventArgs e)
+ {
+ if (ServersGrid.SelectedItem is not ServerConnection selected)
+ {
+ return;
+ }
+
+ var dialog = new ExcludedDatabasesDialog(_serverManager, selected) { Owner = this };
+ if (dialog.ShowDialog() == true && dialog.ExclusionsModified)
+ {
+ ServersChanged = true;
+ RefreshGrid();
+ }
+ }
+
private void DeleteButton_Click(object sender, RoutedEventArgs e)
{
if (ServersGrid.SelectedItem is not ServerConnection selected)
diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml
index f10c76e8..47fe448c 100644
--- a/Lite/Windows/SettingsWindow.xaml
+++ b/Lite/Windows/SettingsWindow.xaml
@@ -161,9 +161,12 @@
-
+
+
+
+
+
0 && cpu <= 100)
App.AlertCpuThreshold = cpu;
+ App.AlertCpuMode = AlertCpuModeBox.SelectedIndex == 1 ? CpuAlertMode.SqlOnly : CpuAlertMode.Total;
App.AlertBlockingEnabled = AlertBlockingCheckBox.IsChecked == true;
if (int.TryParse(AlertBlockingThresholdBox.Text, out var blocking) && blocking > 0)
App.AlertBlockingThreshold = blocking;
@@ -675,6 +677,7 @@ private bool SaveAlertSettings()
root["notify_connection_changes"] = App.NotifyConnectionChanges;
root["alert_cpu_enabled"] = App.AlertCpuEnabled;
root["alert_cpu_threshold"] = App.AlertCpuThreshold;
+ root["alert_cpu_mode"] = App.AlertCpuMode.ToString();
root["alert_blocking_enabled"] = App.AlertBlockingEnabled;
root["alert_blocking_threshold"] = App.AlertBlockingThreshold;
root["alert_deadlock_enabled"] = App.AlertDeadlockEnabled;
@@ -728,6 +731,7 @@ private void AlertsEnabledCheckBox_Changed(object sender, RoutedEventArgs e)
private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
{
AlertCpuThresholdBox.Text = "80";
+ AlertCpuModeBox.SelectedIndex = 0; // Total
AlertBlockingThresholdBox.Text = "1";
AlertDeadlockThresholdBox.Text = "1";
AlertPoisonWaitThresholdBox.Text = "500";
@@ -753,7 +757,10 @@ private void UpdateAlertPreviewText()
var parts = new System.Collections.Generic.List();
if (AlertCpuCheckBox.IsChecked == true)
- parts.Add($"CPU > {AlertCpuThresholdBox.Text}%");
+ {
+ string cpuLabel = AlertCpuModeBox.SelectedIndex == 1 ? "SQL CPU" : "Total CPU";
+ parts.Add($"{cpuLabel} > {AlertCpuThresholdBox.Text}%");
+ }
if (AlertBlockingCheckBox.IsChecked == true)
parts.Add($"blocking >= {AlertBlockingThresholdBox.Text}");
if (AlertDeadlockCheckBox.IsChecked == true)
@@ -778,6 +785,7 @@ private void UpdateAlertControlStates()
NotifyConnectionCheckBox.IsEnabled = enabled;
AlertCpuCheckBox.IsEnabled = enabled;
AlertCpuThresholdBox.IsEnabled = enabled;
+ AlertCpuModeBox.IsEnabled = enabled;
AlertBlockingCheckBox.IsEnabled = enabled;
AlertBlockingThresholdBox.IsEnabled = enabled;
AlertDeadlockCheckBox.IsEnabled = enabled;
diff --git a/README.md b/README.md
index a47b61a1..cc65b836 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ All release binaries are digitally signed via [SignPath](https://signpath.io)
## What You Get
-🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments.
+🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Off, Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. Switch presets with a pair of SQL Agent jobs to get quiet-hours / overnight windows without writing any code.
🚨 **Real-time alerts** for blocking, deadlocks, and high CPU — system tray notifications, styled HTML emails with full XML attachments, and webhook notifications for external integrations
@@ -136,7 +136,7 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser
All data is stored in `%LOCALAPPDATA%\PerformanceMonitorLite\` — separate from the executable, so auto-updates don't affect your data.
-- **Hot data** in DuckDB 1.5.0 — non-blocking checkpoints, free block reuse, stable file size without periodic resets
+- **Hot data** in DuckDB 1.5.2 — non-blocking checkpoints, free block reuse, stable file size without periodic resets
- **Archive** to Parquet with ZSTD compression (~10x reduction) — automatic monthly compaction keeps file count low (~75 files vs thousands)
- **Retention**: 3-month calendar-month rolling window
- Typical size: ~50–200 MB per server per week
diff --git a/docs/how-collection-works.md b/docs/how-collection-works.md
new file mode 100644
index 00000000..d153e635
--- /dev/null
+++ b/docs/how-collection-works.md
@@ -0,0 +1,207 @@
+# How Collection Works
+
+A tour of the collection pipeline for people who know SQL but don't know this codebase. Read this, then read three SQL files, and you'll understand 80% of what Performance Monitor is doing on your server.
+
+This doc covers both editions. Full Edition first (SQL Agent → `PerformanceMonitor` database → Dashboard reads), Lite Edition second (WPF app → DuckDB file → same app reads). The shapes are similar; the surface area is different.
+
+---
+
+## Full Edition
+
+### The minute loop
+
+Everything happens inside one SQL Agent job:
+
+| Job | What it runs |
+| --- | --- |
+| `PerformanceMonitor - Collection` | `EXEC collect.scheduled_master_collector @debug = 0;` on a 1-minute schedule (`Every 1 Minute`) |
+| `PerformanceMonitor - Data Retention` | `EXEC config.data_retention @debug = 1;` once a day |
+| `PerformanceMonitor - Hung Job Monitor` | Kills the Collection job if it's been stuck past its max duration |
+
+When the Collection job fires, it calls the **scheduled master collector** — the dispatcher. The dispatcher is the heartbeat of the whole system. Every minute it wakes up, figures out which collectors are due, and runs them one at a time.
+
+### The dispatcher
+
+**File**: [`install/42_scheduled_master_collector.sql`](../install/42_scheduled_master_collector.sql)
+
+At the core of the dispatcher is a cursor over `config.collection_schedule` that picks up anything due:
+
+```sql
+SELECT
+ cs.schedule_id,
+ cs.collector_name,
+ cs.frequency_minutes,
+ cs.max_duration_minutes
+FROM config.collection_schedule AS cs
+WHERE cs.enabled = 1
+AND (
+ @force_run_all = 1
+ OR cs.next_run_time <= SYSDATETIME()
+ OR cs.next_run_time IS NULL
+ )
+ORDER BY
+ cs.next_run_time;
+```
+
+For each row, the dispatcher has a big `IF/ELSE IF` block that maps `collector_name` to a specific stored procedure:
+
+```sql
+ELSE IF @collector_name = N'default_trace_collector'
+BEGIN
+ EXECUTE collect.default_trace_collector @debug = @debug;
+END;
+ELSE IF @collector_name = N'blocking_deadlock_analyzer'
+BEGIN
+ EXECUTE collect.blocking_deadlock_analyzer @debug = @debug;
+END;
+-- ...etc
+```
+
+Each collector runs inside its own `BEGIN TRY / BEGIN CATCH` block — a failure in one doesn't stop the rest of the cycle. After each run (success or failure), the dispatcher bumps `last_run_time` and `next_run_time = last_run_time + frequency_minutes` so the next tick knows when that collector is eligible again.
+
+Before any of this, the dispatcher also does two self-heal steps:
+
+- **Ensures config tables exist** (`config.ensure_config_tables`) — lets you recover from an accidentally-dropped table without reinstalling.
+- **Detects server restarts** — if `sqlserver_start_time` has changed since last run, it captures a fresh snapshot of server properties. Config values only change across restarts, so this is the efficient moment to grab them.
+
+### What a collector looks like
+
+Pick any `install/NN_collect_*.sql` file — they all follow the same shape. A minimal example:
+
+**File**: [`install/29_collect_default_trace.sql`](../install/29_collect_default_trace.sql)
+
+```sql
+ALTER PROCEDURE
+ collect.default_trace_collector
+(
+ @hours_back integer = 2,
+ @include_memory_events bit = 1,
+ @include_autogrow_events bit = 1,
+ @include_object_events bit = 1,
+ -- ...more flags
+ @debug bit = 0
+)
+AS
+BEGIN
+ BEGIN TRY
+ -- 1. Validate parameters
+ IF @hours_back <= 0 OR @hours_back > 168
+ BEGIN
+ RAISERROR(N'@hours_back must be between 1 and 168 hours', 16, 1);
+ RETURN;
+ END;
+
+ -- 2. Detect first run (empty target table, no prior success in config.collection_log)
+ IF NOT EXISTS (SELECT 1/0 FROM collect.default_trace_events)
+ AND NOT EXISTS (SELECT 1/0 FROM config.collection_log WHERE collector_name = N'default_trace_collector' AND collection_status = N'SUCCESS')
+ BEGIN
+ SET @cutoff_time = CONVERT(datetime2(7), '19000101'); -- grab everything on first run
+ END;
+
+ -- 3. Query the DMV / system view
+ INSERT INTO collect.default_trace_events (...)
+ SELECT ...
+ FROM sys.fn_trace_gettable(@trace_path, @max_files) AS ft
+ WHERE ft.StartTime >= @cutoff_time
+ AND
+ AND NOT EXISTS ();
+
+ -- 4. Log success to config.collection_log
+ INSERT INTO config.collection_log (...) VALUES (..., 'SUCCESS', @rows_collected, ...);
+ END TRY
+ BEGIN CATCH
+ -- 5. Log failure with error message
+ INSERT INTO config.collection_log (...) VALUES (..., 'ERROR', 0, @error_message);
+ THROW;
+ END CATCH;
+END;
+```
+
+Every collector does exactly these five things: **validate, detect first-run, pull from DMV, insert with dedupe, log**. Once you've read one, you've read all thirty. The differences are the source DMV, the filter conditions, and the shape of the destination table.
+
+### The schedule table
+
+**File**: [`install/03_create_config_tables.sql`](../install/03_create_config_tables.sql) (table definition)
+
+`config.collection_schedule` is the single source of truth for *what runs and when*. It has one row per collector:
+
+| Column | Meaning |
+| --- | --- |
+| `collector_name` | The name the dispatcher's `IF/ELSE` block matches on |
+| `enabled` | Bit flag — off means the dispatcher skips this row entirely |
+| `frequency_minutes` | How often to run. `0` means "on connect / daily / special" (see below) |
+| `last_run_time` | When the collector last started — updated by the dispatcher |
+| `next_run_time` | When the collector is next eligible — `last_run_time + frequency_minutes` |
+| `max_duration_minutes` | Kill switch for the hung-job monitor |
+| `retention_days` | How long to keep data in the target `collect.*` table |
+
+You can edit this table directly, but **don't**. The supported knobs are:
+
+- **`config.apply_collection_preset`** — bulk-sets `frequency_minutes` for all collectors at once (presets: `Aggressive`, `Balanced`, `Low-Impact`).
+- **Individual `UPDATE` statements on `enabled`** — turn specific collectors on or off.
+
+**File**: [`install/41_schedule_management.sql`](../install/41_schedule_management.sql) has the preset procedure and some helper procs for listing / resetting the schedule.
+
+### Where does the data go?
+
+Each collector writes to a table in the `collect` schema — `collect.query_stats`, `collect.default_trace_events`, `collect.wait_stats`, etc. Same shape each time: a `collection_time datetime2` column, plus whatever the DMV gave us, plus whatever we computed.
+
+Some tables use `COMPRESS()` on large text/XML columns (query text, plan XML) — stored as `varbinary(max)` and wrapped in `DECOMPRESS()` on read. That's why query text looks like gibberish if you `SELECT * FROM collect.query_stats` directly — read through `v_query_stats` instead, which handles the decompression.
+
+### The Dashboard read path
+
+The Dashboard is a WPF app. It connects to the `PerformanceMonitor` database and issues SELECT queries. No collection happens in the app — the Dashboard is purely a reader. Every time you pick a time range, change a tab, or hit refresh, the app runs a SQL query against `collect.*` tables or `v_*` views, pulls rows into a `List`, and binds that list to a WPF DataGrid or a ScottPlot chart.
+
+The query layer lives in `Dashboard/Services/DatabaseService.*.cs` — split by concern (`DatabaseService.QueryPerformance.cs`, `DatabaseService.SystemEvents.cs`, etc.). Each file is just SQL in C# strings. If the Dashboard is showing you something, there's a method somewhere in that folder returning it.
+
+### Retention
+
+**File**: [`install/45_create_agent_jobs.sql`](../install/45_create_agent_jobs.sql) (job definition) and wherever `config.data_retention` lives.
+
+Once a day, the `PerformanceMonitor - Data Retention` job runs a `DELETE` loop per `collect.*` table, respecting each row's `retention_days` from `config.collection_schedule`. Targeted batched deletes, not a truncate — history older than the retention window disappears; recent data is untouched.
+
+---
+
+## Lite Edition
+
+### What's different
+
+Lite is a standalone WPF app — **no SQL Agent involved, no PerformanceMonitor database**. The app itself is the collector, and the storage is a local DuckDB file (`%LocalAppData%\PerformanceMonitorLite\pm_lite.duckdb`).
+
+The shape still mirrors Full: a dispatcher picks collectors, each collector pulls from DMVs and writes to a destination table, and a reader service hands data to the UI.
+
+### The two services
+
+**Writer**: [`Lite/Services/RemoteCollectorService.cs`](../Lite/Services/RemoteCollectorService.cs) plus one `RemoteCollectorService..cs` partial per collector (19 of them). The service opens a `SqlConnection` to the monitored server, runs DMV queries, and bulk-inserts results into DuckDB.
+
+**Reader**: [`Lite/Services/LocalDataService.*.cs`](../Lite/Services/) — queries DuckDB and returns results to the UI.
+
+Only one connection writes at a time. DuckDB is single-writer, so within a given server the collectors run **sequentially** (not in parallel). Multi-server parallelism still works — each monitored server runs its own serialized collector chain.
+
+### The schedule
+
+**File**: [`Lite/config/collection_schedule.json`](../Lite/config/collection_schedule.json)
+
+A JSON file, not a table. User-editable. The Lite app reads it at startup and at each wake-up tick. Same shape as the Full Edition schedule (name, enabled, frequency_minutes, retention_days) with one convention: `frequency_minutes: 0` means "run once at connect time" — used for server config, database config, trace flags, etc. that don't change between restarts.
+
+### Data retention
+
+Lite runs retention inline as part of each collection cycle — no separate job. Each collector checks its `retention_days` against the max timestamp in its target table and deletes older rows. DuckDB checkpoints after each cycle to flush the WAL.
+
+---
+
+## Where to look next
+
+If you want to **understand a specific feature**, find the code from the UI outward:
+1. Find the grid/chart in the app.
+2. Find its XAML file (`Dashboard/*.xaml` or `Lite/Controls/*.xaml`).
+3. Follow the `Click` handler or `ItemsSource` binding to the `*.xaml.cs` file.
+4. Follow the service call (`_databaseService.GetXxxAsync(...)` in Full, `LocalDataService.GetXxxAsync(...)` in Lite) to the query.
+
+If you want to **understand a specific collector**, read:
+1. `install/NN_collect_.sql` for Full Edition, or
+2. `Lite/Services/RemoteCollectorService..cs` for Lite.
+
+If you want to **add a collector or a new data source**, the dispatcher file in Full (`42_scheduled_master_collector.sql`) or `RemoteCollectorService.cs` in Lite is where you wire it up — those are the files that know about every collector.
+
+If something feels genuinely undocumented rather than "read the code," open an issue. Gaps get prioritized based on what comes up.
diff --git a/install/01_install_database.sql b/install/01_install_database.sql
index 0559e3ac..5b4b293c 100644
--- a/install/01_install_database.sql
+++ b/install/01_install_database.sql
@@ -734,6 +734,32 @@ BEGIN
END;
GO
+/*
+Create per-database exclusions table
+User-configurable list of databases to skip in per-database collectors
+(query_store, file_io_stats, database_size_stats, etc.). System databases
+are always skipped by the collectors themselves and are not represented here.
+*/
+IF OBJECT_ID(N'config.collector_database_exclusions', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ config.collector_database_exclusions
+ (
+ database_name sysname NOT NULL,
+ excluded_at datetime2(7) NOT NULL DEFAULT SYSDATETIME(),
+ excluded_by sysname NULL DEFAULT SUSER_SNAME(),
+ CONSTRAINT
+ PK_collector_database_exclusions
+ PRIMARY KEY CLUSTERED
+ (database_name)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created config.collector_database_exclusions table';
+END;
+GO
+
/*
Create installation history table
*/
diff --git a/install/03_create_config_tables.sql b/install/03_create_config_tables.sql
index f81960ec..b0f65dd7 100644
--- a/install/03_create_config_tables.sql
+++ b/install/03_create_config_tables.sql
@@ -629,6 +629,45 @@ BEGIN
);
END;
+ /*
+ Create config.collector_database_exclusions
+ User-configurable list of databases to skip in per-database collectors.
+ System databases (master/tempdb/model/msdb) are filtered by the collectors
+ themselves and aren't represented here.
+ */
+ IF OBJECT_ID(N'config.collector_database_exclusions', N'U') IS NULL
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Creating config.collector_database_exclusions table', 0, 1) WITH NOWAIT;
+ END;
+
+ CREATE TABLE
+ config.collector_database_exclusions
+ (
+ database_name sysname NOT NULL,
+ excluded_at datetime2(7) NOT NULL DEFAULT SYSDATETIME(),
+ excluded_by sysname NULL DEFAULT SUSER_SNAME(),
+ CONSTRAINT PK_collector_database_exclusions PRIMARY KEY CLUSTERED (database_name) WITH (DATA_COMPRESSION = PAGE)
+ );
+
+ SET @tables_created = @tables_created + 1;
+
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ error_message
+ )
+ VALUES
+ (
+ N'ensure_config_tables',
+ N'TABLE_CREATED',
+ N'Created config.collector_database_exclusions table'
+ );
+ END;
+
/*
Create config.installation_history
*/
diff --git a/install/08_collect_query_stats.sql b/install/08_collect_query_stats.sql
index f866749b..f01d6cba 100644
--- a/install/08_collect_query_stats.sql
+++ b/install/08_collect_query_stats.sql
@@ -158,6 +158,42 @@ BEGIN
@last_collection_time,
DATEADD(MINUTE, -ISNULL(@frequency_minutes, 15), SYSDATETIME())
);
+
+ /*
+ Resume detection: if this collector hasn't successfully run in a long time
+ (Off preset, Agent stoppage, server reboot, manual disable), skip the
+ historical sweep so we don't dump the entire plan cache into our deltas.
+ Threshold: 5x the configured frequency, floored at 30 minutes.
+ */
+ DECLARE
+ @last_successful_run_time datetime2(7),
+ @resume_threshold_minutes integer;
+
+ SELECT
+ @last_successful_run_time = MAX(cl.collection_time)
+ FROM config.collection_log AS cl
+ WHERE cl.collector_name = N'query_stats_collector'
+ AND cl.collection_status = N'SUCCESS';
+
+ SET @resume_threshold_minutes =
+ CASE
+ WHEN ISNULL(@frequency_minutes, 0) <= 0 THEN 30
+ WHEN @frequency_minutes * 5 > 30 THEN @frequency_minutes * 5
+ ELSE 30
+ END;
+
+ IF @last_successful_run_time IS NOT NULL
+ AND DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME()) > @resume_threshold_minutes
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ DECLARE @gap_minutes integer = DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME());
+ RAISERROR(N'Resume detected: %d-minute gap exceeds %d-minute threshold. Skipping historical sweep.', 0, 1,
+ @gap_minutes, @resume_threshold_minutes) WITH NOWAIT;
+ END;
+
+ SET @cutoff_time = SYSDATETIME();
+ END;
END;
IF @debug = 1
@@ -370,6 +406,13 @@ BEGIN
DB_ID(N'PerformanceMonitor')
)
AND pa.dbid < 32761 /*exclude contained AG system databases*/
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
OPTION(RECOMPILE);
/*
diff --git a/install/09_collect_query_store.sql b/install/09_collect_query_store.sql
index df1643d8..1b471d90 100644
--- a/install/09_collect_query_store.sql
+++ b/install/09_collect_query_store.sql
@@ -192,6 +192,44 @@ BEGIN
),
0
);
+
+ /*
+ Resume detection: if this collector hasn't successfully run in a long time
+ (Off preset, Agent stoppage, server reboot, manual disable), skip the
+ historical sweep so we don't dump the entire Query Store window into our deltas.
+ Note: @last_collection_time above tracks the latest captured query execution time,
+ not the collector's run time, so we need a separate lookup against config.collection_log.
+ Threshold: 5x the configured frequency, floored at 30 minutes.
+ */
+ DECLARE
+ @last_successful_run_time datetime2(7),
+ @resume_threshold_minutes integer;
+
+ SELECT
+ @last_successful_run_time = MAX(cl.collection_time)
+ FROM config.collection_log AS cl
+ WHERE cl.collector_name = N'query_store_collector'
+ AND cl.collection_status = N'SUCCESS';
+
+ SET @resume_threshold_minutes =
+ CASE
+ WHEN ISNULL(@collection_interval_minutes, 0) <= 0 THEN 30
+ WHEN @collection_interval_minutes * 5 > 30 THEN @collection_interval_minutes * 5
+ ELSE 30
+ END;
+
+ IF @last_successful_run_time IS NOT NULL
+ AND DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME()) > @resume_threshold_minutes
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ DECLARE @gap_minutes integer = DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME());
+ RAISERROR(N'Resume detected: %d-minute gap exceeds %d-minute threshold. Skipping historical sweep.', 0, 1,
+ @gap_minutes, @resume_threshold_minutes) WITH NOWAIT;
+ END;
+
+ SET @cutoff_time = TODATETIMEOFFSET(SYSUTCDATETIME(), 0);
+ END;
END;
IF @debug = 1
@@ -313,6 +351,13 @@ BEGIN
drs.database_id IS NULL /*not in any AG*/
OR drs.is_primary_replica = 1 /*primary replica*/
)
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
OPTION(RECOMPILE);
OPEN @db_check_cursor;
diff --git a/install/10_collect_procedure_stats.sql b/install/10_collect_procedure_stats.sql
index ad36e2f5..5d99ad33 100644
--- a/install/10_collect_procedure_stats.sql
+++ b/install/10_collect_procedure_stats.sql
@@ -157,6 +157,42 @@ BEGIN
SELECT
@cutoff_time = ISNULL(@last_collection_time,
DATEADD(MINUTE, -ISNULL(@frequency_minutes, 15), SYSDATETIME()));
+
+ /*
+ Resume detection: if this collector hasn't successfully run in a long time
+ (Off preset, Agent stoppage, server reboot, manual disable), skip the
+ historical sweep so we don't dump cumulative procedure stats into our deltas.
+ Threshold: 5x the configured frequency, floored at 30 minutes.
+ */
+ DECLARE
+ @last_successful_run_time datetime2(7),
+ @resume_threshold_minutes integer;
+
+ SELECT
+ @last_successful_run_time = MAX(cl.collection_time)
+ FROM config.collection_log AS cl
+ WHERE cl.collector_name = N'procedure_stats_collector'
+ AND cl.collection_status = N'SUCCESS';
+
+ SET @resume_threshold_minutes =
+ CASE
+ WHEN ISNULL(@frequency_minutes, 0) <= 0 THEN 30
+ WHEN @frequency_minutes * 5 > 30 THEN @frequency_minutes * 5
+ ELSE 30
+ END;
+
+ IF @last_successful_run_time IS NOT NULL
+ AND DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME()) > @resume_threshold_minutes
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ DECLARE @gap_minutes integer = DATEDIFF(MINUTE, @last_successful_run_time, SYSDATETIME());
+ RAISERROR(N'Resume detected: %d-minute gap exceeds %d-minute threshold. Skipping historical sweep.', 0, 1,
+ @gap_minutes, @resume_threshold_minutes) WITH NOWAIT;
+ END;
+
+ SET @cutoff_time = SYSDATETIME();
+ END;
END;
IF @debug = 1
@@ -303,6 +339,13 @@ BEGIN
DB_ID(N'PerformanceMonitor')
)
AND pa.dbid < 32761 /*exclude contained AG system databases*/
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
UNION ALL
@@ -472,6 +515,13 @@ BEGIN
DB_ID(N'PerformanceMonitor')
)
AND pa.dbid < 32761 /*exclude contained AG system databases*/
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
UNION ALL
SELECT
diff --git a/install/20_collect_file_io_stats.sql b/install/20_collect_file_io_stats.sql
index 44b71021..7d0545fa 100644
--- a/install/20_collect_file_io_stats.sql
+++ b/install/20_collect_file_io_stats.sql
@@ -176,6 +176,13 @@ BEGIN
DB_ID(N'msdb') /*4*/
)
AND vfs.database_id < 32761 /*exclude resource database and contained AG system databases*/
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
OPTION(RECOMPILE);
SET @rows_collected = ROWCOUNT_BIG();
diff --git a/install/37_collect_waiting_tasks.sql b/install/37_collect_waiting_tasks.sql
index e7564d89..1db92c4f 100644
--- a/install/37_collect_waiting_tasks.sql
+++ b/install/37_collect_waiting_tasks.sql
@@ -183,6 +183,13 @@ BEGIN
WHERE iwt.wait_type = wt.wait_type
AND iwt.is_enabled = 1
)
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
OPTION(RECOMPILE);
SET @rows_collected = ROWCOUNT_BIG();
diff --git a/install/39_collect_database_configuration.sql b/install/39_collect_database_configuration.sql
index f6a4202c..8927287a 100644
--- a/install/39_collect_database_configuration.sql
+++ b/install/39_collect_database_configuration.sql
@@ -144,6 +144,13 @@ BEGIN
AND d.name != DB_NAME()
AND d.state_desc = N'ONLINE'
AND d.database_id < 32761 /*exclude contained AG system databases*/
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
OPTION (RECOMPILE);
IF @debug = 1
@@ -173,6 +180,13 @@ BEGIN
drs.database_id IS NULL /*not in any AG*/
OR drs.is_primary_replica = 1 /*primary replica*/
)
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
ORDER BY
d.name
OPTION (RECOMPILE);
diff --git a/install/41_schedule_management.sql b/install/41_schedule_management.sql
index 46c93b8a..e9944b45 100644
--- a/install/41_schedule_management.sql
+++ b/install/41_schedule_management.sql
@@ -142,10 +142,18 @@ GO
/*
Apply a named collection preset
-Changes all scheduled collector frequencies in one operation.
-Does not modify enabled/disabled state or daily/on-load collectors.
-Valid preset names: Aggressive, Balanced, Low-Impact
+Valid preset names: Off, Aggressive, Balanced, Low-Impact
+
+Off sets enabled = 0 on every row in config.collection_schedule and changes
+nothing else — no frequency edits. The other three set enabled = 1 on every
+row (so switching back from Off reliably reactivates collection) and update
+frequency_minutes for the collectors they list. Daily/on-load collectors
+that aren't in the preset keep their existing frequency.
+
+Heads up: applying a non-Off preset overrides any manual
+UPDATE config.collection_schedule SET enabled = 0 on a specific collector.
+If that matters in your environment, re-disable it after switching presets.
*/
IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL
BEGIN
@@ -169,9 +177,33 @@ BEGIN
@rows_updated bigint = 0;
BEGIN TRY
- IF @preset_name NOT IN (N'Aggressive', N'Balanced', N'Low-Impact')
+ IF @preset_name NOT IN (N'Off', N'Aggressive', N'Balanced', N'Low-Impact')
BEGIN
- RAISERROR(N'Invalid preset name "%s". Valid presets: Aggressive, Balanced, Low-Impact', 16, 1, @preset_name);
+ RAISERROR(N'Invalid preset name "%s". Valid presets: Off, Aggressive, Balanced, Low-Impact', 16, 1, @preset_name);
+ RETURN;
+ END;
+
+ /*
+ Off disables every collector and exits. No frequency table needed.
+ Pair with a second Agent job that applies a non-Off preset at the
+ start of your active window to resume collection.
+ */
+ IF @preset_name = N'Off'
+ BEGIN
+ UPDATE
+ config.collection_schedule
+ SET
+ enabled = 0,
+ modified_date = SYSDATETIME();
+
+ SET @rows_updated = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Applied "Off" preset — %I64d collectors disabled', 0, 1, @rows_updated) WITH NOWAIT;
+ END;
+
+ PRINT 'Applied "Off" collection preset (' + CONVERT(varchar(10), @rows_updated) + ' collectors disabled)';
RETURN;
END;
@@ -303,20 +335,27 @@ BEGIN
END;
/*
- Apply the preset to all matching collectors.
- Only updates frequency - does not change enabled/disabled state.
- Skips daily/on-load collectors not in the preset.
+ Re-enable every collector first so a switch from Off → named preset
+ reliably resumes collection, including daily/on-load collectors that
+ aren't in the preset frequency table.
+ */
+ UPDATE
+ config.collection_schedule
+ SET
+ enabled = 1,
+ modified_date = SYSDATETIME()
+ WHERE
+ enabled = 0;
+
+ /*
+ Apply the preset frequencies to the collectors it lists.
+ Daily/on-load collectors not in the preset keep their existing frequency.
*/
UPDATE
cs
SET
cs.frequency_minutes = p.frequency_minutes,
- cs.next_run_time =
- CASE
- WHEN cs.enabled = 1
- THEN SYSDATETIME()
- ELSE cs.next_run_time
- END,
+ cs.next_run_time = SYSDATETIME(),
cs.modified_date = SYSDATETIME()
FROM config.collection_schedule AS cs
JOIN @preset AS p
@@ -416,11 +455,12 @@ PRINT '';
PRINT 'Available procedures:';
PRINT '- config.update_collector_frequency - Change frequency for specific collector';
PRINT '- config.set_collector_enabled - Enable/disable specific collector';
-PRINT '- config.apply_collection_preset - Apply a named preset (Aggressive, Balanced, Low-Impact)';
+PRINT '- config.apply_collection_preset - Apply a named preset (Off, Aggressive, Balanced, Low-Impact)';
PRINT '- config.show_collection_schedule - Display current schedule';
PRINT '';
PRINT 'Examples:';
PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Aggressive'', @debug = 1;';
PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Balanced'';';
PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Low-Impact'';';
+PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Off''; -- disables all collectors';
GO
diff --git a/install/43_data_retention.sql b/install/43_data_retention.sql
index 09fa8666..96749569 100644
--- a/install/43_data_retention.sql
+++ b/install/43_data_retention.sql
@@ -35,7 +35,7 @@ GO
ALTER PROCEDURE
config.data_retention
(
- @retention_days integer = 30, /*Fallback retention for tables without a collection_schedule entry*/
+ @retention_days integer = NULL, /*NULL = use per-collector retention from config.collection_schedule (30-day fallback). 0 = TRUNCATE every collect.* table. N > 0 = override every table's cutoff to N days.*/
@batch_size integer = 10000, /*Number of rows to delete per batch to avoid blocking*/
@debug bit = 0 /*Print debugging information*/
)
@@ -45,8 +45,16 @@ BEGIN
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ /*
+ NULL @retention_days means "respect per-collector schedule" with a 30-day fallback for unscheduled tables.
+ Non-NULL means "use this value as the cutoff everywhere" — schedule overrides are skipped.
+ */
DECLARE
- @retention_date datetime2(7) = DATEADD(DAY, -@retention_days, SYSDATETIME()),
+ @apply_schedule_override bit = CASE WHEN @retention_days IS NULL THEN 1 ELSE 0 END,
+ @effective_retention_days integer = ISNULL(@retention_days, 30);
+
+ DECLARE
+ @retention_date datetime2(7) = DATEADD(DAY, -@effective_retention_days, SYSDATETIME()),
@total_deleted bigint = 0,
@start_time datetime2(7) = SYSDATETIME(),
@table_name sysname,
@@ -60,9 +68,9 @@ BEGIN
/*
Parameter validation
*/
- IF @retention_days < 1
+ IF @retention_days IS NOT NULL AND @retention_days < 0
BEGIN
- RAISERROR(N'@retention_days must be at least 1', 16, 1);
+ RAISERROR(N'@retention_days must be 0 or greater', 16, 1);
RETURN;
END;
@@ -72,6 +80,130 @@ BEGIN
RETURN;
END;
+ /*
+ Purge-all branch: TRUNCATE every collect.* table when @retention_days = 0.
+ No FKs, schema-bound views, or indexed views reference collect.* so this is safe.
+ Config tables (including config.collection_log) are intentionally left alone.
+ */
+ IF @retention_days = 0
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Starting purge-all: TRUNCATE every collect.* table', 0, 1) WITH NOWAIT;
+ END;
+
+ DECLARE
+ @truncate_table_name sysname,
+ @truncate_sql nvarchar(max),
+ @truncate_table_count integer = 0,
+ @truncate_error_count integer = 0,
+ @truncate_rows_before bigint = 0,
+ @truncate_cursor cursor;
+
+ /*
+ Snapshot total row count across collect.* before truncating so we can report
+ how many rows the user actually wiped. TRUNCATE doesn't return a count.
+ */
+ SELECT
+ @truncate_rows_before = ISNULL(SUM(p.rows), 0)
+ FROM sys.tables AS t
+ JOIN sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ JOIN sys.partitions AS p
+ ON p.object_id = t.object_id
+ AND p.index_id IN (0, 1)
+ WHERE s.name = N'collect'
+ AND t.is_ms_shipped = 0;
+
+ SET @truncate_cursor = CURSOR LOCAL FAST_FORWARD FOR
+ SELECT
+ t.name
+ FROM sys.tables AS t
+ JOIN sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ WHERE s.name = N'collect'
+ AND t.is_ms_shipped = 0
+ ORDER BY
+ t.name;
+
+ OPEN @truncate_cursor;
+ FETCH NEXT FROM @truncate_cursor INTO @truncate_table_name;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ SET @truncate_sql = N'TRUNCATE TABLE collect.' + QUOTENAME(@truncate_table_name) + N';';
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N' %s', 0, 1, @truncate_sql) WITH NOWAIT;
+ END;
+
+ EXECUTE sys.sp_executesql @truncate_sql;
+ SET @truncate_table_count = @truncate_table_count + 1;
+ END TRY
+ BEGIN CATCH
+ SET @truncate_error_count = @truncate_error_count + 1;
+ SET @message = N'TRUNCATE failed for collect.' + QUOTENAME(@truncate_table_name) + N': ' + ERROR_MESSAGE();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(@message, 0, 1) WITH NOWAIT;
+ END;
+
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ error_message
+ )
+ VALUES
+ (
+ N'data_retention',
+ N'ERROR',
+ @message
+ );
+ END CATCH;
+
+ FETCH NEXT FROM @truncate_cursor INTO @truncate_table_name;
+ END;
+
+ CLOSE @truncate_cursor;
+
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'data_retention',
+ CASE WHEN @truncate_error_count = 0 THEN N'SUCCESS' ELSE N'WARNING' END,
+ /*rows_collected is INT; clamp the bigint snapshot to int range*/
+ CONVERT(integer, CASE WHEN @truncate_rows_before > 2147483647 THEN 2147483647 ELSE @truncate_rows_before END),
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'TRUNCATE all: ' + CONVERT(nvarchar(10), @truncate_table_count) + N' tables truncated, '
+ + CONVERT(nvarchar(20), @truncate_rows_before) + N' rows wiped'
+ + CASE WHEN @truncate_error_count > 0
+ THEN N', ' + CONVERT(nvarchar(10), @truncate_error_count) + N' errors'
+ ELSE N''
+ END
+ );
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Purge-all completed: %d tables truncated, %I64d rows wiped, %d errors', 0, 1,
+ @truncate_table_count, @truncate_rows_before, @truncate_error_count) WITH NOWAIT;
+ END;
+
+ RETURN;
+ END;
+
IF @debug = 1
BEGIN
DECLARE @retention_date_string nvarchar(30) = CONVERT(nvarchar(30), @retention_date, 120);
@@ -247,42 +379,48 @@ BEGIN
/*
Override retention_date per-collector from config.collection_schedule.
- Direct match: strip _collector/_analyzer suffix and match table name prefix.
+ Skipped when @retention_days was supplied — caller wants a flat cutoff across every table.
*/
- UPDATE ttc
- SET ttc.retention_date = DATEADD(DAY, -cs.retention_days, SYSDATETIME())
- FROM #tables_to_clean AS ttc
- JOIN config.collection_schedule AS cs
- ON ttc.table_name LIKE REPLACE(REPLACE(cs.collector_name, N'_collector', N''), N'_analyzer', N'') + N'%';
+ IF @apply_schedule_override = 1
+ BEGIN
+ /*
+ Direct match: strip _collector/_analyzer suffix and match table name prefix.
+ */
+ UPDATE ttc
+ SET ttc.retention_date = DATEADD(DAY, -cs.retention_days, SYSDATETIME())
+ FROM #tables_to_clean AS ttc
+ JOIN config.collection_schedule AS cs
+ ON ttc.table_name LIKE REPLACE(REPLACE(cs.collector_name, N'_collector', N''), N'_analyzer', N'') + N'%';
- /*
- Special mappings for tables whose names don't match their collector:
- - HealthParser_* tables -> system_health_collector
- - blocking_BlockedProcessReport -> process_blocked_process_xml
- - deadlocks (sp_BlitzLock output) -> process_deadlock_xml
- */
- UPDATE ttc
- SET ttc.retention_date = DATEADD(DAY, -cs.retention_days, SYSDATETIME())
- FROM #tables_to_clean AS ttc
- CROSS JOIN config.collection_schedule AS cs
- WHERE
- (
- ttc.table_name LIKE N'HealthParser%'
- AND cs.collector_name = N'system_health_collector'
- )
- OR
- (
- ttc.table_name = N'blocking_BlockedProcessReport'
- AND cs.collector_name = N'process_blocked_process_xml'
- )
- OR
- (
- ttc.table_name = N'deadlocks'
- AND cs.collector_name = N'process_deadlock_xml'
- );
+ /*
+ Special mappings for tables whose names don't match their collector:
+ - HealthParser_* tables -> system_health_collector
+ - blocking_BlockedProcessReport -> process_blocked_process_xml
+ - deadlocks (sp_BlitzLock output) -> process_deadlock_xml
+ */
+ UPDATE ttc
+ SET ttc.retention_date = DATEADD(DAY, -cs.retention_days, SYSDATETIME())
+ FROM #tables_to_clean AS ttc
+ CROSS JOIN config.collection_schedule AS cs
+ WHERE
+ (
+ ttc.table_name LIKE N'HealthParser%'
+ AND cs.collector_name = N'system_health_collector'
+ )
+ OR
+ (
+ ttc.table_name = N'blocking_BlockedProcessReport'
+ AND cs.collector_name = N'process_blocked_process_xml'
+ )
+ OR
+ (
+ ttc.table_name = N'deadlocks'
+ AND cs.collector_name = N'process_deadlock_xml'
+ );
+ END;
/*
- Special handling for config.collection_log - keep 2x longer
+ Special handling for config.collection_log - keep 2x longer than the effective retention
*/
INSERT INTO
#tables_to_clean
@@ -296,7 +434,7 @@ BEGIN
schema_name = N'config',
table_name = N'collection_log',
time_column_name = N'collection_time',
- retention_date = DATEADD(DAY, -(@retention_days * 2), SYSDATETIME())
+ retention_date = DATEADD(DAY, -(@effective_retention_days * 2), SYSDATETIME())
WHERE EXISTS
(
SELECT
@@ -486,13 +624,19 @@ GO
PRINT 'Data retention procedure created successfully (DYNAMIC VERSION)';
PRINT 'Use config.data_retention to automatically purge old monitoring data';
-PRINT 'Uses per-collector retention from config.collection_schedule when available';
-PRINT 'Falls back to @retention_days parameter for unmatched tables';
+PRINT '';
+PRINT ' @retention_days = NULL (default): respect per-collector retention in config.collection_schedule';
+PRINT ' (30-day fallback for unscheduled tables)';
+PRINT ' @retention_days = 0 : TRUNCATE every collect.* table';
+PRINT ' @retention_days = N (N > 0) : override every table''s cutoff to N days';
PRINT '';
PRINT 'Examples:';
PRINT ' -- Use per-collector retention (default)';
PRINT ' EXECUTE config.data_retention @debug = 1;';
PRINT '';
-PRINT ' -- Override fallback to 90 days for tables without schedule entries';
-PRINT ' EXECUTE config.data_retention @retention_days = 90;';
+PRINT ' -- Override every table to 7-day retention';
+PRINT ' EXECUTE config.data_retention @retention_days = 7;';
+PRINT '';
+PRINT ' -- Purge all collected data (TRUNCATE every collect.* table)';
+PRINT ' EXECUTE config.data_retention @retention_days = 0;';
GO
diff --git a/install/52_collect_database_size_stats.sql b/install/52_collect_database_size_stats.sql
index 11e71e08..57c6f14c 100644
--- a/install/52_collect_database_size_stats.sql
+++ b/install/52_collect_database_size_stats.sql
@@ -195,6 +195,13 @@ BEGIN
WHERE d.state = 0 /*ONLINE only — skip RESTORING databases (mirroring/AG secondary)*/
AND d.database_id > 0
AND HAS_DBACCESS(d.name) = 1
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
ORDER BY
d.database_id;
diff --git a/install/53_collect_server_properties.sql b/install/53_collect_server_properties.sql
index fb1b1c21..c77ceafd 100644
--- a/install/53_collect_server_properties.sql
+++ b/install/53_collect_server_properties.sql
@@ -136,6 +136,13 @@ BEGIN
WHERE d.state = 0 /*ONLINE only — skip RESTORING databases (mirroring/AG secondary)*/
AND d.database_id > 4 /*Skip system databases*/
AND HAS_DBACCESS(d.name) = 1
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM config.collector_database_exclusions AS e
+ WHERE e.database_name = d.name
+ )
ORDER BY
d.database_id;