diff --git a/Lite/Controls/ColumnFilterPopup.xaml b/Lite/Controls/ColumnFilterPopup.xaml
new file mode 100644
index 00000000..2ca878fd
--- /dev/null
+++ b/Lite/Controls/ColumnFilterPopup.xaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/ColumnFilterPopup.xaml.cs b/Lite/Controls/ColumnFilterPopup.xaml.cs
new file mode 100644
index 00000000..b431eb21
--- /dev/null
+++ b/Lite/Controls/ColumnFilterPopup.xaml.cs
@@ -0,0 +1,159 @@
+/*
+ * 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.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Controls;
+
+public partial class ColumnFilterPopup : UserControl
+{
+ private string _columnName = string.Empty;
+ private bool _suppressEvents = false;
+
+ public event EventHandler? FilterApplied;
+ public event EventHandler? FilterCleared;
+
+ public ColumnFilterPopup()
+ {
+ InitializeComponent();
+ PopulateOperatorComboBox();
+ }
+
+ private void PopulateOperatorComboBox()
+ {
+ OperatorComboBox.Items.Clear();
+
+ foreach (FilterOperator op in Enum.GetValues(typeof(FilterOperator)))
+ {
+ OperatorComboBox.Items.Add(new ComboBoxItem
+ {
+ Content = ColumnFilterState.GetOperatorDisplayName(op),
+ Tag = op
+ });
+ }
+
+ OperatorComboBox.SelectedIndex = 0;
+ }
+
+ public void Initialize(string columnName, ColumnFilterState? existingFilter)
+ {
+ _suppressEvents = true;
+ _columnName = columnName;
+ HeaderText.Text = $"Filter: {columnName}";
+
+ if (existingFilter != null && existingFilter.IsActive)
+ {
+ for (int i = 0; i < OperatorComboBox.Items.Count; i++)
+ {
+ if (OperatorComboBox.Items[i] is ComboBoxItem item && item.Tag is FilterOperator op)
+ {
+ if (op == existingFilter.Operator)
+ {
+ OperatorComboBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+
+ ValueTextBox.Text = existingFilter.Value;
+ }
+ else
+ {
+ OperatorComboBox.SelectedIndex = 0;
+ ValueTextBox.Text = string.Empty;
+ }
+
+ UpdateValueVisibility();
+ _suppressEvents = false;
+
+ ValueTextBox.Focus();
+ ValueTextBox.SelectAll();
+ }
+
+ private void OperatorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressEvents) return;
+ UpdateValueVisibility();
+ }
+
+ private void UpdateValueVisibility()
+ {
+ var selectedOp = GetSelectedOperator();
+
+ bool showValue = selectedOp != FilterOperator.IsEmpty &&
+ selectedOp != FilterOperator.IsNotEmpty;
+
+ ValueLabel.Visibility = showValue ? Visibility.Visible : Visibility.Collapsed;
+ ValueTextBox.Visibility = showValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private FilterOperator GetSelectedOperator()
+ {
+ if (OperatorComboBox.SelectedItem is ComboBoxItem item && item.Tag is FilterOperator op)
+ {
+ return op;
+ }
+ return FilterOperator.Contains;
+ }
+
+ private void ValueTextBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ ApplyFilter();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ }
+ }
+
+ private void ApplyButton_Click(object sender, RoutedEventArgs e)
+ {
+ ApplyFilter();
+ }
+
+ private void ApplyFilter()
+ {
+ var filterState = new ColumnFilterState
+ {
+ ColumnName = _columnName,
+ Operator = GetSelectedOperator(),
+ Value = ValueTextBox.Text.Trim()
+ };
+
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs { FilterState = filterState });
+ }
+
+ private void ClearButton_Click(object sender, RoutedEventArgs e)
+ {
+ ValueTextBox.Text = string.Empty;
+ OperatorComboBox.SelectedIndex = 0;
+
+ var filterState = new ColumnFilterState
+ {
+ ColumnName = _columnName,
+ Operator = FilterOperator.Contains,
+ Value = string.Empty
+ };
+
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs { FilterState = filterState });
+ }
+}
+
+public class FilterAppliedEventArgs : EventArgs
+{
+ public ColumnFilterState FilterState { get; set; } = new ColumnFilterState();
+}
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index f5cf4ea7..4924a5bb 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -1,703 +1,975 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index c7001e88..de694d8b 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -16,6 +16,7 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
+using System.Windows.Controls.Primitives;
using System.Windows.Threading;
using Microsoft.Win32;
using PerformanceMonitorLite.Database;
@@ -36,6 +37,26 @@ public partial class ServerTab : UserControl
private readonly Dictionary _legendPanels = new();
private List _waitTypeItems = new();
private List _perfmonCounterItems = new();
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string WaitType)> _waitStatsScatters = new();
+ private readonly System.Windows.Controls.Primitives.Popup _waitStatsHoverPopup;
+ private readonly System.Windows.Controls.TextBlock _waitStatsHoverText;
+ private DateTime _lastHoverUpdate;
+
+ /* Column filtering */
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+ private readonly Dictionary _filterManagers = new();
+ private DataGridFilterManager? _querySnapshotsFilterMgr;
+ private DataGridFilterManager? _queryStatsFilterMgr;
+ private DataGridFilterManager? _procStatsFilterMgr;
+ private DataGridFilterManager? _queryStoreFilterMgr;
+ private DataGridFilterManager? _blockedProcessFilterMgr;
+ private DataGridFilterManager? _deadlockFilterMgr;
+ private DataGridFilterManager? _runningJobsFilterMgr;
+ private DataGridFilterManager? _serverConfigFilterMgr;
+ private DataGridFilterManager? _databaseConfigFilterMgr;
+ private DataGridFilterManager? _dbScopedConfigFilterMgr;
+ private DataGridFilterManager? _traceFlagsFilterMgr;
private static readonly HashSet _defaultPerfmonCounters = new(StringComparer.OrdinalIgnoreCase)
{
@@ -98,6 +119,34 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
/* Initialize time picker ComboBoxes */
InitializeTimeComboBoxes();
+ /* Initialize column filter managers */
+ InitializeFilterManagers();
+
+ /* Wait stats hover tooltip */
+ _waitStatsHoverText = new System.Windows.Controls.TextBlock
+ {
+ Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 13
+ };
+ _waitStatsHoverPopup = new System.Windows.Controls.Primitives.Popup
+ {
+ PlacementTarget = WaitStatsChart,
+ Placement = System.Windows.Controls.Primitives.PlacementMode.Relative,
+ IsHitTestVisible = false,
+ AllowsTransparency = true,
+ Child = new System.Windows.Controls.Border
+ {
+ Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x33, 0x33, 0x33)),
+ BorderBrush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x55, 0x55, 0x55)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(3),
+ Padding = new Thickness(8, 4, 8, 4),
+ Child = _waitStatsHoverText
+ }
+ };
+ WaitStatsChart.MouseMove += WaitStatsChart_MouseMove;
+ WaitStatsChart.MouseLeave += WaitStatsChart_MouseLeave;
+
/* Initial load is triggered by MainWindow.ConnectToServer calling RefreshData()
after collectors finish - no Loaded handler needed */
}
@@ -443,18 +492,18 @@ await System.Threading.Tasks.Task.WhenAll(
AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}");
AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}");
- /* Update grids */
- QuerySnapshotsGrid.ItemsSource = snapshotsTask.Result;
- QueryStatsGrid.ItemsSource = queryStatsTask.Result;
- ProcedureStatsGrid.ItemsSource = procStatsTask.Result;
- BlockedProcessReportGrid.ItemsSource = blockedProcessTask.Result;
- DeadlockGrid.ItemsSource = DeadlockProcessDetail.ParseFromRows(deadlockTask.Result);
- QueryStoreGrid.ItemsSource = queryStoreTask.Result;
- ServerConfigGrid.ItemsSource = serverConfigTask.Result;
- DatabaseConfigGrid.ItemsSource = databaseConfigTask.Result;
- DatabaseScopedConfigGrid.ItemsSource = databaseScopedConfigTask.Result;
- TraceFlagsGrid.ItemsSource = traceFlagsTask.Result;
- RunningJobsGrid.ItemsSource = runningJobsTask.Result;
+ /* Update grids (via filter managers to preserve active filters) */
+ _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
+ _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
+ _procStatsFilterMgr!.UpdateData(procStatsTask.Result);
+ _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
+ _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
+ _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
+ _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
+ _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
+ _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
+ _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
+ _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
/* Update memory summary */
UpdateMemorySummary(memoryTask.Result);
@@ -1067,6 +1116,7 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
ClearChart(WaitStatsChart);
ApplyDarkTheme(WaitStatsChart);
+ _waitStatsScatters.Clear();
if (selected.Count == 0) { WaitStatsChart.Refresh(); return; }
@@ -1096,6 +1146,7 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
var plot = WaitStatsChart.Plot.Add.Scatter(times, waitTime);
plot.LegendText = selected[i].DisplayName;
plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]);
+ _waitStatsScatters.Add((plot, selected[i].DisplayName));
if (waitTime.Length > 0) globalMax = Math.Max(globalMax, waitTime.Max());
}
@@ -1125,6 +1176,62 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
}
}
+ /* ========== Wait Stats Hover Tooltip ========== */
+
+ private void WaitStatsChart_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
+ {
+ if (_waitStatsScatters.Count == 0) return;
+ var now = DateTime.UtcNow;
+ if ((now - _lastHoverUpdate).TotalMilliseconds < 50) return;
+ _lastHoverUpdate = now;
+
+ var pos = e.GetPosition(WaitStatsChart);
+ var pixel = new ScottPlot.Pixel(
+ (float)(pos.X * WaitStatsChart.DisplayScale),
+ (float)(pos.Y * WaitStatsChart.DisplayScale));
+ var mouseCoords = WaitStatsChart.Plot.GetCoordinates(pixel);
+
+ double bestDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestWaitType = "";
+
+ foreach (var (scatter, waitType) in _waitStatsScatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, WaitStatsChart.Plot.LastRender);
+ if (nearest.IsReal)
+ {
+ var nearestPixel = WaitStatsChart.Plot.GetPixel(new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = nearestPixel.X - pixel.X;
+ double dy = nearestPixel.Y - pixel.Y;
+ double dist = dx * dx + dy * dy;
+ if (dist < bestDistance)
+ {
+ bestDistance = dist;
+ bestPoint = nearest;
+ bestWaitType = waitType;
+ }
+ }
+ }
+
+ if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius
+ {
+ var time = DateTime.FromOADate(bestPoint.X);
+ _waitStatsHoverText.Text = $"{bestWaitType}\n{bestPoint.Y:N1} ms/sec\n{time:HH:mm:ss}";
+ _waitStatsHoverPopup.HorizontalOffset = pos.X + 15;
+ _waitStatsHoverPopup.VerticalOffset = pos.Y + 15;
+ _waitStatsHoverPopup.IsOpen = true;
+ }
+ else
+ {
+ _waitStatsHoverPopup.IsOpen = false;
+ }
+ }
+
+ private void WaitStatsChart_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e)
+ {
+ _waitStatsHoverPopup.IsOpen = false;
+ }
+
/* ========== Perfmon Picker ========== */
private bool _isUpdatingPerfmonSelection;
@@ -1773,4 +1880,105 @@ public void StopRefresh()
{
_refreshTimer.Stop();
}
+
+ /* ========== Column Filtering ========== */
+
+ private void InitializeFilterManagers()
+ {
+ _querySnapshotsFilterMgr = new DataGridFilterManager(QuerySnapshotsGrid);
+ _queryStatsFilterMgr = new DataGridFilterManager(QueryStatsGrid);
+ _procStatsFilterMgr = new DataGridFilterManager(ProcedureStatsGrid);
+ _queryStoreFilterMgr = new DataGridFilterManager(QueryStoreGrid);
+ _blockedProcessFilterMgr = new DataGridFilterManager(BlockedProcessReportGrid);
+ _deadlockFilterMgr = new DataGridFilterManager(DeadlockGrid);
+ _runningJobsFilterMgr = new DataGridFilterManager(RunningJobsGrid);
+ _serverConfigFilterMgr = new DataGridFilterManager(ServerConfigGrid);
+ _databaseConfigFilterMgr = new DataGridFilterManager(DatabaseConfigGrid);
+ _dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid);
+ _traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid);
+
+ _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr;
+ _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr;
+ _filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr;
+ _filterManagers[QueryStoreGrid] = _queryStoreFilterMgr;
+ _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr;
+ _filterManagers[DeadlockGrid] = _deadlockFilterMgr;
+ _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr;
+ _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr;
+ _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr;
+ _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr;
+ _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr;
+ }
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+ }
+
+ private DataGrid? _currentFilterGrid;
+
+ private void FilterButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnName) return;
+
+ /* Walk up visual tree to find the parent DataGrid */
+ var dataGrid = FindParentDataGridFromElement(button);
+ if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return;
+
+ _currentFilterGrid = dataGrid;
+
+ EnsureFilterPopup();
+
+ /* Rewire events to the current grid */
+ _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared;
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ /* Initialize with existing filter state */
+ manager.Filters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent.Initialize(columnName, existingFilter);
+
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+
+ if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager))
+ {
+ manager.SetFilter(e.FilterState);
+ }
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+ }
+
+ private static DataGrid? FindParentDataGridFromElement(DependencyObject element)
+ {
+ var current = element;
+ while (current != null)
+ {
+ if (current is DataGrid dg)
+ return dg;
+ current = VisualTreeHelper.GetParent(current);
+ }
+ return null;
+ }
}
diff --git a/Lite/Models/ColumnFilterState.cs b/Lite/Models/ColumnFilterState.cs
new file mode 100644
index 00000000..33574ff7
--- /dev/null
+++ b/Lite/Models/ColumnFilterState.cs
@@ -0,0 +1,66 @@
+/*
+ * 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.
+ */
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// Represents the filter state for a single DataGrid column.
+///
+public class ColumnFilterState
+{
+ public string ColumnName { get; set; } = string.Empty;
+ public FilterOperator Operator { get; set; } = FilterOperator.Contains;
+ public string Value { get; set; } = string.Empty;
+
+ public bool IsActive => !string.IsNullOrEmpty(Value) ||
+ Operator == FilterOperator.IsEmpty ||
+ Operator == FilterOperator.IsNotEmpty;
+
+ public string DisplayText
+ {
+ get
+ {
+ if (!IsActive) return string.Empty;
+
+ return Operator switch
+ {
+ FilterOperator.Contains => $"Contains '{Value}'",
+ FilterOperator.Equals => $"= '{Value}'",
+ FilterOperator.NotEquals => $"!= '{Value}'",
+ FilterOperator.GreaterThan => $"> {Value}",
+ FilterOperator.GreaterThanOrEqual => $">= {Value}",
+ FilterOperator.LessThan => $"< {Value}",
+ FilterOperator.LessThanOrEqual => $"<= {Value}",
+ FilterOperator.StartsWith => $"Starts with '{Value}'",
+ FilterOperator.EndsWith => $"Ends with '{Value}'",
+ FilterOperator.IsEmpty => "Is Empty",
+ FilterOperator.IsNotEmpty => "Is Not Empty",
+ _ => Value
+ };
+ }
+ }
+
+ public static string GetOperatorDisplayName(FilterOperator op)
+ {
+ return op switch
+ {
+ FilterOperator.Contains => "Contains",
+ FilterOperator.Equals => "Equals (=)",
+ FilterOperator.NotEquals => "Not Equals (!=)",
+ FilterOperator.GreaterThan => "Greater Than (>)",
+ FilterOperator.GreaterThanOrEqual => "Greater or Equal (>=)",
+ FilterOperator.LessThan => "Less Than (<)",
+ FilterOperator.LessThanOrEqual => "Less or Equal (<=)",
+ FilterOperator.StartsWith => "Starts With",
+ FilterOperator.EndsWith => "Ends With",
+ FilterOperator.IsEmpty => "Is Empty",
+ FilterOperator.IsNotEmpty => "Is Not Empty",
+ _ => op.ToString()
+ };
+ }
+}
diff --git a/Lite/Models/FilterOperator.cs b/Lite/Models/FilterOperator.cs
new file mode 100644
index 00000000..3e823132
--- /dev/null
+++ b/Lite/Models/FilterOperator.cs
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// Filter operators for column filtering in DataGrids.
+///
+public enum FilterOperator
+{
+ Contains,
+ Equals,
+ NotEquals,
+ GreaterThan,
+ GreaterThanOrEqual,
+ LessThan,
+ LessThanOrEqual,
+ StartsWith,
+ EndsWith,
+ IsEmpty,
+ IsNotEmpty
+}
diff --git a/Lite/Services/DataGridFilterManager.cs b/Lite/Services/DataGridFilterManager.cs
new file mode 100644
index 00000000..37026a3e
--- /dev/null
+++ b/Lite/Services/DataGridFilterManager.cs
@@ -0,0 +1,136 @@
+/*
+ * 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.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services;
+
+///
+/// Non-generic interface for looking up filter state from a shared dictionary.
+///
+public interface IDataGridFilterManager
+{
+ Dictionary Filters { get; }
+ void SetFilter(ColumnFilterState filterState);
+ void UpdateFilterButtonStyles();
+}
+
+///
+/// Manages column filter state, unfiltered data capture, and filter application
+/// for a single DataGrid. Eliminates per-grid boilerplate code.
+///
+public class DataGridFilterManager : IDataGridFilterManager
+{
+ private readonly DataGrid _dataGrid;
+ private readonly Dictionary _filters = new();
+ private List? _unfilteredData;
+
+ public DataGridFilterManager(DataGrid dataGrid)
+ {
+ _dataGrid = dataGrid;
+ }
+
+ public Dictionary Filters => _filters;
+
+ ///
+ /// Called when new data arrives (refresh cycle). Captures unfiltered data,
+ /// then re-applies any active filters.
+ ///
+ public void UpdateData(List newData)
+ {
+ _unfilteredData = newData;
+
+ if (!HasActiveFilters())
+ {
+ _dataGrid.ItemsSource = newData;
+ return;
+ }
+
+ ApplyFilters();
+ }
+
+ ///
+ /// Applies or removes a filter and re-filters the data.
+ ///
+ public void SetFilter(ColumnFilterState filterState)
+ {
+ if (filterState.IsActive)
+ _filters[filterState.ColumnName] = filterState;
+ else
+ _filters.Remove(filterState.ColumnName);
+
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private bool HasActiveFilters()
+ {
+ return _filters.Count > 0 && _filters.Values.Any(f => f.IsActive);
+ }
+
+ private void ApplyFilters()
+ {
+ if (_unfilteredData == null) return;
+
+ if (!HasActiveFilters())
+ {
+ _dataGrid.ItemsSource = _unfilteredData;
+ return;
+ }
+
+ var filteredData = _unfilteredData.Where(item =>
+ {
+ foreach (var filter in _filters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item!, filter))
+ return false;
+ }
+ return true;
+ }).ToList();
+
+ _dataGrid.ItemsSource = filteredData;
+ }
+
+ ///
+ /// Updates filter icon colors (gold when active, dim when inactive).
+ ///
+ public void UpdateFilterButtonStyles()
+ {
+ foreach (var column in _dataGrid.Columns)
+ {
+ if (column.Header is StackPanel headerPanel)
+ {
+ var filterButton = headerPanel.Children.OfType