diff --git a/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml
new file mode 100644
index 0000000..5a54155
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs
new file mode 100644
index 0000000..b2b7f32
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs
@@ -0,0 +1,113 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace PlanViewer.App.Controls;
+
+public partial class ColumnFilterPopup : UserControl
+{
+ public event EventHandler? FilterApplied;
+ public event EventHandler? FilterCleared;
+
+ private string _currentColumnName = "";
+
+ private static readonly (string Display, FilterOperator Op)[] Operators =
+ [
+ ("Contains", FilterOperator.Contains),
+ ("Equals (=)", FilterOperator.Equals),
+ ("Not Equals (!=)", FilterOperator.NotEquals),
+ ("Starts With", FilterOperator.StartsWith),
+ ("Ends With", FilterOperator.EndsWith),
+ ("Greater Than (>)", FilterOperator.GreaterThan),
+ ("Greater or Equal (>=)", FilterOperator.GreaterThanOrEqual),
+ ("Less Than (<)", FilterOperator.LessThan),
+ ("Less or Equal (<=)", FilterOperator.LessThanOrEqual),
+ ("Is Empty", FilterOperator.IsEmpty),
+ ("Is Not Empty", FilterOperator.IsNotEmpty),
+ ];
+
+ public ColumnFilterPopup()
+ {
+ InitializeComponent();
+ foreach (var (display, _) in Operators)
+ OperatorComboBox.Items.Add(display);
+ OperatorComboBox.SelectedIndex = 0;
+ }
+
+ public void Initialize(string columnName, ColumnFilterState? existingFilter)
+ {
+ _currentColumnName = columnName;
+ HeaderText.Text = $"Filter: {columnName}";
+
+ if (existingFilter?.IsActive == true)
+ {
+ var idx = Array.FindIndex(Operators, o => o.Op == existingFilter.Operator);
+ OperatorComboBox.SelectedIndex = idx >= 0 ? idx : 0;
+ ValueTextBox.Text = existingFilter.Value;
+ }
+ else
+ {
+ OperatorComboBox.SelectedIndex = 0;
+ ValueTextBox.Text = "";
+ }
+
+ UpdateValueVisibility();
+ ValueTextBox.Focus();
+ }
+
+ private void UpdateValueVisibility()
+ {
+ var idx = OperatorComboBox.SelectedIndex;
+ var op = (idx >= 0 && idx < Operators.Length) ? Operators[idx].Op : FilterOperator.Contains;
+ var showValue = op != FilterOperator.IsEmpty && op != FilterOperator.IsNotEmpty;
+ ValueLabel.IsVisible = showValue;
+ ValueTextBox.IsVisible = showValue;
+ }
+
+ private void OperatorComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ UpdateValueVisibility();
+ }
+
+ private void ApplyFilter()
+ {
+ var idx = OperatorComboBox.SelectedIndex;
+ if (idx < 0 || idx >= Operators.Length) return;
+
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs
+ {
+ FilterState = new ColumnFilterState
+ {
+ ColumnName = _currentColumnName,
+ Operator = Operators[idx].Op,
+ Value = ValueTextBox.Text ?? "",
+ }
+ });
+ }
+
+ private void ApplyButton_Click(object? sender, RoutedEventArgs e) => ApplyFilter();
+
+ private void ClearButton_Click(object? sender, RoutedEventArgs e)
+ {
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs
+ {
+ FilterState = new ColumnFilterState { ColumnName = _currentColumnName }
+ });
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Controls/ColumnFilterState.cs b/src/PlanViewer.App/Controls/ColumnFilterState.cs
new file mode 100644
index 0000000..1cc8176
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterState.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace PlanViewer.App.Controls;
+
+public enum FilterOperator
+{
+ Contains,
+ Equals,
+ NotEquals,
+ StartsWith,
+ EndsWith,
+ GreaterThan,
+ GreaterThanOrEqual,
+ LessThan,
+ LessThanOrEqual,
+ IsEmpty,
+ IsNotEmpty,
+}
+
+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 class FilterAppliedEventArgs : EventArgs
+{
+ public ColumnFilterState FilterState { get; set; } = new ColumnFilterState();
+}
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 74e346d..6fdc840 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -70,7 +70,7 @@
+ BorderThickness="0"
+ ScrollViewer.HorizontalScrollBarVisibility="Auto">
@@ -95,20 +96,20 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_rows = new();
+ private ObservableCollection _filteredRows = new();
+ private readonly Dictionary _activeFilters = new();
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
public event EventHandler>? PlansSelected;
@@ -26,7 +33,9 @@ public QueryStoreGridControl(string connectionString, string database)
_connectionString = connectionString;
_database = database;
InitializeComponent();
- ResultsGrid.ItemsSource = _rows;
+ ResultsGrid.ItemsSource = _filteredRows;
+ EnsureFilterPopup();
+ SetupColumnHeaders();
}
private async void Fetch_Click(object? sender, RoutedEventArgs e)
@@ -43,6 +52,7 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
LoadButton.IsEnabled = false;
StatusText.Text = "Fetching...";
_rows.Clear();
+ _filteredRows.Clear();
try
{
@@ -58,7 +68,7 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
foreach (var plan in plans)
_rows.Add(new QueryStoreRow(plan));
- StatusText.Text = $"{plans.Count} plans";
+ ApplyFilters();
LoadButton.IsEnabled = true;
}
catch (OperationCanceledException)
@@ -77,19 +87,19 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
private void SelectAll_Click(object? sender, RoutedEventArgs e)
{
- foreach (var row in _rows)
+ foreach (var row in _filteredRows)
row.IsSelected = true;
}
private void SelectNone_Click(object? sender, RoutedEventArgs e)
{
- foreach (var row in _rows)
+ foreach (var row in _filteredRows)
row.IsSelected = false;
}
private void LoadSelected_Click(object? sender, RoutedEventArgs e)
{
- var selected = _rows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ var selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
if (selected.Count > 0)
PlansSelected?.Invoke(this, selected);
}
@@ -99,6 +109,229 @@ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
if (ResultsGrid.SelectedItem is QueryStoreRow row)
PlansSelected?.Invoke(this, new List { row.Plan });
}
+
+ // ── Column filter infrastructure ───────────────────────────────────────
+
+ private static readonly Dictionary> TextAccessors = new()
+ {
+ ["LastExecuted"] = r => r.LastExecutedLocal,
+ ["QueryText"] = r => r.FullQueryText,
+ };
+
+ private static readonly Dictionary> NumericAccessors = new()
+ {
+ ["QueryId"] = r => r.QueryId,
+ ["PlanId"] = r => r.PlanId,
+ ["Executions"] = r => r.ExecsSort,
+ ["TotalCpu"] = r => r.TotalCpuSort / 1000.0, // µs → ms (matches display)
+ ["AvgCpu"] = r => r.AvgCpuSort / 1000.0, // µs → ms
+ ["TotalDuration"] = r => r.TotalDurSort / 1000.0, // µs → ms
+ ["AvgDuration"] = r => r.AvgDurSort / 1000.0, // µs → ms
+ ["TotalReads"] = r => r.TotalReadsSort,
+ ["AvgReads"] = r => r.AvgReadsSort,
+ ["TotalWrites"] = r => r.TotalWritesSort,
+ ["AvgWrites"] = r => r.AvgWritesSort,
+ ["TotalPhysReads"] = r => r.TotalPhysReadsSort,
+ ["AvgPhysReads"] = r => r.AvgPhysReadsSort,
+ ["TotalMemory"] = r => r.TotalMemSort * 8.0 / 1024.0, // pages → MB (matches display)
+ ["AvgMemory"] = r => r.AvgMemSort * 8.0 / 1024.0, // pages → MB
+ };
+
+ private void SetupColumnHeaders()
+ {
+ var cols = ResultsGrid.Columns;
+ SetColumnFilterButton(cols[1], "QueryId", "Query ID");
+ SetColumnFilterButton(cols[2], "PlanId", "Plan ID");
+ SetColumnFilterButton(cols[3], "LastExecuted", "Last Executed (Local)");
+ SetColumnFilterButton(cols[4], "Executions", "Executions");
+ SetColumnFilterButton(cols[5], "TotalCpu", "Total CPU (ms)");
+ SetColumnFilterButton(cols[6], "AvgCpu", "Avg CPU (ms)");
+ SetColumnFilterButton(cols[7], "TotalDuration", "Total Duration (ms)");
+ SetColumnFilterButton(cols[8], "AvgDuration", "Avg Duration (ms)");
+ SetColumnFilterButton(cols[9], "TotalReads", "Total Reads");
+ SetColumnFilterButton(cols[10], "AvgReads", "Avg Reads");
+ SetColumnFilterButton(cols[11], "TotalWrites", "Total Writes");
+ SetColumnFilterButton(cols[12], "AvgWrites", "Avg Writes");
+ SetColumnFilterButton(cols[13], "TotalPhysReads", "Total Physical Reads");
+ SetColumnFilterButton(cols[14], "AvgPhysReads", "Avg Physical Reads");
+ SetColumnFilterButton(cols[15], "TotalMemory", "Total Memory (MB)");
+ SetColumnFilterButton(cols[16], "AvgMemory", "Avg Memory (MB)");
+ SetColumnFilterButton(cols[17], "QueryText", "Query Text");
+ }
+
+ private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
+ {
+ var icon = new TextBlock
+ {
+ Text = "▽",
+ FontSize = 12,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ };
+ var btn = new Button
+ {
+ Content = icon,
+ Tag = columnId,
+ Width = 16,
+ Height = 16,
+ Padding = new Avalonia.Thickness(0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ };
+ btn.Click += ColumnFilter_Click;
+ ToolTip.SetTip(btn, "Click to filter");
+
+ var text = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Margin = new Avalonia.Thickness(4, 0, 0, 0),
+ };
+
+ var header = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ };
+ header.Children.Add(btn);
+ header.Children.Add(text);
+ col.Header = header;
+ }
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup != null) return;
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ IsLightDismissEnabled = true,
+ Placement = PlacementMode.Bottom,
+ };
+ // Add to visual tree so DynamicResources resolve inside the popup
+ ((Grid)Content!).Children.Add(_filterPopup);
+ _filterPopupContent.FilterApplied += OnFilterApplied;
+ _filterPopupContent.FilterCleared += OnFilterCleared;
+ }
+
+ private void ColumnFilter_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnId) return;
+ EnsureFilterPopup();
+ _activeFilters.TryGetValue(columnId, out var existing);
+ _filterPopupContent!.Initialize(columnId, existing);
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void OnFilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ if (e.FilterState.IsActive)
+ _activeFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _activeFilters.Remove(e.FilterState.ColumnName);
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private void OnFilterCleared(object? sender, EventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ }
+
+ private void UpdateFilterButtonStyles()
+ {
+ foreach (var col in ResultsGrid.Columns)
+ {
+ if (col.Header is not StackPanel sp) continue;
+ var btn = sp.Children.OfType