diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs new file mode 100644 index 0000000..2179cb3 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreGridControl : UserControl +{ + private async void Fetch_Click(object? sender, RoutedEventArgs e) + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu"; + _lastFetchedOrderBy = orderBy; + + FetchButton.IsEnabled = false; + LoadButton.IsEnabled = false; + StatusText.Text = "Loading time slicer..."; + _rows.Clear(); + _filteredRows.Clear(); + + try + { + // Load slicer data, preserving the current selection if one exists. + // Without this, LoadData defaults to last 24h and the user's range is lost. + await LoadTimeSlicerDataAsync(orderBy, ct, _slicerStartUtc, _slicerEndUtc); + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + FetchButton.IsEnabled = true; + } + } + + private async System.Threading.Tasks.Task FetchPlansForRangeAsync() + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + var topN = (int)(TopNBox.Value ?? 25); + var orderBy = _lastFetchedOrderBy; + var filter = BuildSearchFilter(); + + FetchButton.IsEnabled = false; + LoadButton.IsEnabled = false; + StatusText.Text = "Fetching plans..."; + GridLoadingOverlay.IsVisible = true; + GridLoadingText.Text = "Fetching plans..."; + GridEmptyMessage.IsVisible = false; + _rows.Clear(); + _filteredRows.Clear(); + _groupedRootRows.Clear(); + + // Start global + ribbon wait stats early (they don't depend on plan results) + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchGlobalWaitStatsOnlyAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + + try + { + if (_groupByMode == QueryStoreGroupBy.None) + { + await FetchFlatPlansAsync(topN, orderBy, filter, ct); + } + else + { + await FetchGroupedPlansAsync(topN, orderBy, filter, ct); + } + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + GridLoadingOverlay.IsVisible = false; + FetchButton.IsEnabled = true; + } + } + + private async System.Threading.Tasks.Task FetchFlatPlansAsync( + int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct) + { + var plans = await QueryStoreService.FetchTopPlansAsync( + _connectionString, topN, orderBy, filter: filter, ct: ct, + startUtc: _slicerStartUtc, endUtc: _slicerEndUtc); + + GridLoadingOverlay.IsVisible = false; + + if (plans.Count == 0) + { + StatusText.Text = "No Query Store data found for the selected range."; + return; + } + + foreach (var plan in plans) + _rows.Add(new QueryStoreRow(plan)); + + ApplyFilters(); + LoadButton.IsEnabled = true; + SelectToggleButton.Content = "Select All"; + + // Fetch per-plan wait stats after grid is populated (needs plan IDs) + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + } + + private async System.Threading.Tasks.Task FetchGroupedPlansAsync( + int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct) + { + QueryStoreGroupedResult grouped; + if (_groupByMode == QueryStoreGroupBy.QueryHash) + { + grouped = await QueryStoreService.FetchGroupedByQueryHashAsync( + _connectionString, topN, orderBy, filter, ct, + _slicerStartUtc, _slicerEndUtc); + } + else // Module + { + grouped = await QueryStoreService.FetchGroupedByModuleAsync( + _connectionString, topN, orderBy, filter, ct, + _slicerStartUtc, _slicerEndUtc); + } + + GridLoadingOverlay.IsVisible = false; + GridEmptyMessage.IsVisible = false; + + if (grouped.IntermediateRows.Count == 0) + { + if (_groupByMode == QueryStoreGroupBy.Module) + { + GridEmptyMessageText.Text = "No module found in the selected period"; + GridEmptyMessage.IsVisible = true; + } + else + { + StatusText.Text = "No Query Store data found for the selected range."; + } + return; + } + + var rootRows = BuildGroupedRows(grouped); + + // Sort root rows by consolidated metric descending + var metricAccessor = GetMetricAccessor(orderBy); + rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList(); + _groupedRootRows = rootRows; + + // Flatten to _rows (all levels) and show only top-level in _filteredRows + foreach (var root in rootRows) + { + _rows.Add(root); + foreach (var mid in root.Children) + { + _rows.Add(mid); + foreach (var leaf in mid.Children) + _rows.Add(leaf); + } + } + + // Show only root-level rows initially (collapsed) + _filteredRows.Clear(); + foreach (var root in rootRows) + _filteredRows.Add(root); + + LoadButton.IsEnabled = true; + SelectToggleButton.Content = "Select All"; + + UpdateStatusText(); + UpdateBarRatios(); + + // Fetch per-plan wait stats for leaf rows, then consolidate upward + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + } + + /// + /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy, + /// assigns them to leaf rows, then consolidates upward to intermediate and root rows. + /// + private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync( + DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + try + { + // Collect all real plan IDs from rows that have a real PlanId + var allPlanIds = _rows + .Where(r => r.PlanId > 0) + .Select(r => r.PlanId) + .Distinct() + .ToList(); + + if (allPlanIds.Count == 0) return; + + var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync( + _connectionString, startUtc, endUtc, allPlanIds, ct); + if (ct.IsCancellationRequested) return; + + // Build lookup: plan_id → list of WaitCategoryTotal + var byPlan = planWaits + .GroupBy(x => x.PlanId) + .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList()); + + // 1. Assign raw waits + profiles to rows with a real PlanId + foreach (var row in _rows) + { + if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits)) + { + row.RawWaitCategories = waits; + row.WaitProfile = QueryStoreService.BuildWaitProfile(waits); + } + } + + // 2. Consolidate upward through the hierarchy + foreach (var root in _groupedRootRows) + ConsolidateWaitProfileUpward(root); + + UpdateWaitBarMode(); + } + catch (OperationCanceledException) { } + catch (Exception) { } + } + + /// + /// Recursively consolidates wait profiles from children into their parent. + /// For each parent: merges all children's RawWaitCategories by summing WaitRatio + /// per category, then builds a new WaitProfile from the merged totals. + /// + private static void ConsolidateWaitProfileUpward(QueryStoreRow parent) + { + if (parent.Children.Count == 0) return; + + // Recurse first so children are consolidated before we merge them + foreach (var child in parent.Children) + ConsolidateWaitProfileUpward(child); + + // Merge all children's raw wait categories by summing WaitRatio per category + var merged = parent.Children + .SelectMany(c => c.RawWaitCategories) + .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc }) + .Select(g => new WaitCategoryTotal + { + WaitCategory = g.Key.WaitCategory, + WaitCategoryDesc = g.Key.WaitCategoryDesc, + WaitRatio = g.Sum(w => w.WaitRatio), + }) + .ToList(); + + if (merged.Count > 0) + { + parent.RawWaitCategories = merged; + parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged); + } + } + + /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow. + private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch + { + "cpu" => r => r.TotalCpuSort, + "avg-cpu" => r => r.AvgCpuSort, + "duration" => r => r.TotalDurSort, + "avg-duration" => r => r.AvgDurSort, + "reads" => r => r.TotalReadsSort, + "avg-reads" => r => r.AvgReadsSort, + "writes" => r => r.TotalWritesSort, + "avg-writes" => r => r.AvgWritesSort, + "physical-reads" => r => r.TotalPhysReadsSort, + "avg-physical-reads" => r => r.AvgPhysReadsSort, + "memory" => r => r.TotalMemSort, + "avg-memory" => r => r.AvgMemSort, + "executions" => r => r.ExecsSort, + _ => r => r.TotalCpuSort, + }; + + private List BuildGroupedRows(QueryStoreGroupedResult grouped) + { + var roots = new List(); + var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy); + + if (_groupByMode == QueryStoreGroupBy.QueryHash) + { + // Level 0: QueryHash groups + var queryHashGroups = grouped.IntermediateRows + .GroupBy(r => r.QueryHash) + .ToList(); + + foreach (var qhGroup in queryHashGroups) + { + var qhKey = qhGroup.Key; + var intermediateRows = qhGroup.ToList(); + + // Build level-1 children (PlanHash) + var midChildren = new List(); + foreach (var mid in intermediateRows) + { + // Build level-2 children (QueryId/PlanId) + var leafChildren = new List(); + var leaves = grouped.LeafRows + .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash) + .ToList(); + foreach (var leaf in leaves) + { + var leafPlan = GroupedRowToPlan(leaf); + leafChildren.Add(new QueryStoreRow(leafPlan, 2, + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + } + + // Sort leaf children by metric descending + leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + var midPlan = GroupedRowToPlan(mid); + // Populate QueryText from the top representative leaf for this plan hash + var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); + if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) + midPlan.QueryText = topLeafForMid.QueryText; + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren)); + } + + // Sort mid children by metric descending + midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + // Aggregate metrics at QueryHash level + var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? ""); + // Populate QueryText from the top representative leaf across all leaves in this query hash group + var topLeafForRoot = grouped.LeafRows + .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText)) + .FirstOrDefault() + ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText)); + if (topLeafForRoot != null) + aggPlan.QueryText = topLeafForRoot.QueryText; + roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren)); + } + } + else // Module + { + // Level 0: Module groups + var moduleGroups = grouped.IntermediateRows + .GroupBy(r => r.ModuleName) + .ToList(); + + foreach (var modGroup in moduleGroups) + { + var modKey = modGroup.Key; + var intermediateRows = modGroup.ToList(); + + // Build level-1 children (QueryHash) + var midChildren = new List(); + foreach (var mid in intermediateRows) + { + // Build level-2 children (QueryId/PlanId) + var leafChildren = new List(); + var leaves = grouped.LeafRows + .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash) + .ToList(); + foreach (var leaf in leaves) + { + var leafPlan = GroupedRowToPlan(leaf); + leafChildren.Add(new QueryStoreRow(leafPlan, 2, + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + } + + // Sort leaf children by metric descending + leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + var midPlan = GroupedRowToPlan(mid); + // Populate QueryText from the top representative leaf for this query hash + var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); + if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) + midPlan.QueryText = topLeafForMid.QueryText; + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren)); + } + + // Sort mid children by metric descending + midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList(); + + // Aggregate metrics at Module level + var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey); + // Populate QueryText from the top representative leaf across all leaves in this module group + var topLeafForRoot = grouped.LeafRows + .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText)) + .FirstOrDefault() + ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText)); + if (topLeafForRoot != null) + aggPlan.QueryText = topLeafForRoot.QueryText; + roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren)); + } + } + + return roots; + } + + private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row) + { + var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1; + return new QueryStorePlan + { + QueryId = row.QueryId, + PlanId = row.PlanId, + QueryHash = row.QueryHash, + QueryPlanHash = row.QueryPlanHash, + ModuleName = row.ModuleName, + QueryText = row.QueryText, + PlanXml = row.PlanXml, + CountExecutions = row.CountExecutions, + ExecutionTypeDesc = row.ExecutionTypeDesc, + TotalCpuTimeUs = row.TotalCpuTimeUs, + TotalDurationUs = row.TotalDurationUs, + TotalLogicalIoReads = row.TotalLogicalIoReads, + TotalLogicalIoWrites = row.TotalLogicalIoWrites, + TotalPhysicalIoReads = row.TotalPhysicalIoReads, + TotalMemoryGrantPages = row.TotalMemoryGrantPages, + AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs, + AvgDurationUs = (double)row.TotalDurationUs / totalExecs, + AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs, + AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs, + AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs, + AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs, + LastExecutedUtc = row.LastExecutedUtc, + }; + } + + private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName) + { + var totalExecs = rows.Sum(r => r.CountExecutions); + var safeExecs = totalExecs > 0 ? totalExecs : 1; + var totalCpu = rows.Sum(r => r.TotalCpuTimeUs); + var totalDur = rows.Sum(r => r.TotalDurationUs); + var totalReads = rows.Sum(r => r.TotalLogicalIoReads); + var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites); + var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads); + var totalMem = rows.Sum(r => r.TotalMemoryGrantPages); + var lastExec = rows.Max(r => r.LastExecutedUtc); + + return new QueryStorePlan + { + QueryHash = queryHash, + ModuleName = moduleName, + CountExecutions = totalExecs, + TotalCpuTimeUs = totalCpu, + TotalDurationUs = totalDur, + TotalLogicalIoReads = totalReads, + TotalLogicalIoWrites = totalWrites, + TotalPhysicalIoReads = totalPhysReads, + TotalMemoryGrantPages = totalMem, + AvgCpuTimeUs = (double)totalCpu / safeExecs, + AvgDurationUs = (double)totalDur / safeExecs, + AvgLogicalIoReads = (double)totalReads / safeExecs, + AvgLogicalIoWrites = (double)totalWrites / safeExecs, + AvgPhysicalIoReads = (double)totalPhysReads / safeExecs, + AvgMemoryGrantPages = (double)totalMem / safeExecs, + LastExecutedUtc = lastExec, + ExecutionTypeDesc = rows.FirstOrDefault()?.ExecutionTypeDesc ?? "", + }; + } +} diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs new file mode 100644 index 0000000..0275553 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Filters.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.App.Dialogs; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreGridControl : UserControl +{ + private void SearchType_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (SearchValuePanel is null || ExecutionTypePanel is null) + return; + + var tag = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + var isExecutionType = tag == "execution-type"; + SearchValuePanel.IsVisible = !isExecutionType; + ExecutionTypePanel.IsVisible = isExecutionType; + } + + private QueryStoreFilter? BuildSearchFilter() + { + var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + + if (string.IsNullOrEmpty(searchType)) + return null; + + if (searchType == "execution-type") + { + var tag = (ExecutionTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); + // "any" tag (first item) means no filter + if (string.IsNullOrEmpty(tag) || tag == "any") + return null; + // "Failed" bundles Aborted + Exception into an IN predicate + if (tag == "Failed") + return new QueryStoreFilter { ExecutionTypeDescs = ["Aborted", "Exception"] }; + return new QueryStoreFilter { ExecutionTypeDescs = [tag] }; + } + + var searchValue = SearchValueBox.Text?.Trim(); + if (string.IsNullOrEmpty(searchValue)) + return null; + + var filter = new QueryStoreFilter(); + + switch (searchType) + { + case "query-id" when long.TryParse(searchValue, out var qid): + filter.QueryId = qid; + break; + case "query-id": + StatusText.Text = "Invalid Query ID"; + return null; + case "plan-id" when long.TryParse(searchValue, out var pid): + filter.PlanId = pid; + break; + case "plan-id": + StatusText.Text = "Invalid Plan ID"; + return null; + case "query-hash": + filter.QueryHash = searchValue; + break; + case "plan-hash": + filter.QueryPlanHash = searchValue; + break; + case "module": + // Default to dbo schema if no schema specified, following sp_QuickieStore pattern + filter.ModuleName = searchValue.Contains('.') ? searchValue : $"dbo.{searchValue}"; + break; + default: + return null; + } + + return filter; + } + + private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e) + { + if (e.Key == Avalonia.Input.Key.Enter) + { + Fetch_Click(sender, e); + e.Handled = true; + } + } + + private void ClearSearch_Click(object? sender, RoutedEventArgs e) + { + SearchTypeBox.SelectedIndex = 0; + SearchValueBox.Text = ""; + // Resetting SearchTypeBox triggers SearchType_SelectionChanged which hides ExecutionTypePanel. + ExecutionTypeBox.SelectedIndex = 0; + } + + private void SetupColumnHeaders() + { + var cols = ResultsGrid.Columns; + // cols[0] = Expand column, cols[1] = Checkbox + SetColumnFilterButton(cols[2], "QueryId", "Query ID"); + SetColumnFilterButton(cols[3], "PlanId", "Plan ID"); + SetColumnFilterButton(cols[4], "QueryHash", "Query Hash"); + SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash"); + SetColumnFilterButton(cols[6], "ModuleName", "Module"); + // cols[7] = WaitProfile (no filter button) + SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)"); + SetColumnFilterButton(cols[9], "Executions", "Executions"); + SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)"); + SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)"); + SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)"); + SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)"); + SetColumnFilterButton(cols[14], "TotalReads", "Total Reads"); + SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads"); + SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes"); + SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes"); + SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads"); + SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads"); + SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)"); + SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)"); + SetColumnFilterButton(cols[22], "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); + ApplySortAndFilters(); + 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