|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.Collections.ObjectModel; |
| 4 | +using System.ComponentModel; |
| 5 | +using System.Linq; |
| 6 | +using System.Runtime.CompilerServices; |
| 7 | +using System.Threading; |
| 8 | +using System.Threading.Tasks; |
| 9 | +using Avalonia; |
| 10 | +using Avalonia.Controls; |
| 11 | +using Avalonia.Controls.Primitives; |
| 12 | +using Avalonia.Input; |
| 13 | +using Avalonia.Interactivity; |
| 14 | +using Avalonia.Layout; |
| 15 | +using Avalonia.Media; |
| 16 | +using PlanViewer.App.Dialogs; |
| 17 | +using PlanViewer.App.Services; |
| 18 | +using PlanViewer.Core.Interfaces; |
| 19 | +using PlanViewer.Core.Models; |
| 20 | +using PlanViewer.Core.Services; |
| 21 | + |
| 22 | +namespace PlanViewer.App.Controls; |
| 23 | + |
| 24 | +public partial class QueryStoreGridControl : UserControl |
| 25 | +{ |
| 26 | + private QueryStoreFilter? BuildSearchFilter() |
| 27 | + { |
| 28 | + var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString(); |
| 29 | + var searchValue = SearchValueBox.Text?.Trim(); |
| 30 | + |
| 31 | + if (string.IsNullOrEmpty(searchType) || string.IsNullOrEmpty(searchValue)) |
| 32 | + return null; |
| 33 | + |
| 34 | + var filter = new QueryStoreFilter(); |
| 35 | + |
| 36 | + switch (searchType) |
| 37 | + { |
| 38 | + case "query-id" when long.TryParse(searchValue, out var qid): |
| 39 | + filter.QueryId = qid; |
| 40 | + break; |
| 41 | + case "query-id": |
| 42 | + StatusText.Text = "Invalid Query ID"; |
| 43 | + return null; |
| 44 | + case "plan-id" when long.TryParse(searchValue, out var pid): |
| 45 | + filter.PlanId = pid; |
| 46 | + break; |
| 47 | + case "plan-id": |
| 48 | + StatusText.Text = "Invalid Plan ID"; |
| 49 | + return null; |
| 50 | + case "query-hash": |
| 51 | + filter.QueryHash = searchValue; |
| 52 | + break; |
| 53 | + case "plan-hash": |
| 54 | + filter.QueryPlanHash = searchValue; |
| 55 | + break; |
| 56 | + case "module": |
| 57 | + // Default to dbo schema if no schema specified, following sp_QuickieStore pattern |
| 58 | + filter.ModuleName = searchValue.Contains('.') ? searchValue : $"dbo.{searchValue}"; |
| 59 | + break; |
| 60 | + default: |
| 61 | + return null; |
| 62 | + } |
| 63 | + |
| 64 | + return filter; |
| 65 | + } |
| 66 | + |
| 67 | + private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e) |
| 68 | + { |
| 69 | + if (e.Key == Avalonia.Input.Key.Enter) |
| 70 | + { |
| 71 | + Fetch_Click(sender, e); |
| 72 | + e.Handled = true; |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + private void ClearSearch_Click(object? sender, RoutedEventArgs e) |
| 77 | + { |
| 78 | + SearchTypeBox.SelectedIndex = 0; |
| 79 | + SearchValueBox.Text = ""; |
| 80 | + } |
| 81 | + |
| 82 | + private void SetupColumnHeaders() |
| 83 | + { |
| 84 | + var cols = ResultsGrid.Columns; |
| 85 | + // cols[0] = Expand column, cols[1] = Checkbox |
| 86 | + SetColumnFilterButton(cols[2], "QueryId", "Query ID"); |
| 87 | + SetColumnFilterButton(cols[3], "PlanId", "Plan ID"); |
| 88 | + SetColumnFilterButton(cols[4], "QueryHash", "Query Hash"); |
| 89 | + SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash"); |
| 90 | + SetColumnFilterButton(cols[6], "ModuleName", "Module"); |
| 91 | + // cols[7] = WaitProfile (no filter button) |
| 92 | + SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)"); |
| 93 | + SetColumnFilterButton(cols[9], "Executions", "Executions"); |
| 94 | + SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)"); |
| 95 | + SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)"); |
| 96 | + SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)"); |
| 97 | + SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)"); |
| 98 | + SetColumnFilterButton(cols[14], "TotalReads", "Total Reads"); |
| 99 | + SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads"); |
| 100 | + SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes"); |
| 101 | + SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes"); |
| 102 | + SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads"); |
| 103 | + SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads"); |
| 104 | + SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)"); |
| 105 | + SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)"); |
| 106 | + SetColumnFilterButton(cols[22], "QueryText", "Query Text"); |
| 107 | + } |
| 108 | + |
| 109 | + private void SetColumnFilterButton(DataGridColumn col, string columnId, string label) |
| 110 | + { |
| 111 | + var icon = new TextBlock |
| 112 | + { |
| 113 | + Text = "▽", |
| 114 | + FontSize = 12, |
| 115 | + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, |
| 116 | + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, |
| 117 | + }; |
| 118 | + var btn = new Button |
| 119 | + { |
| 120 | + Content = icon, |
| 121 | + Tag = columnId, |
| 122 | + Width = 16, |
| 123 | + Height = 16, |
| 124 | + Padding = new Avalonia.Thickness(0), |
| 125 | + Background = Brushes.Transparent, |
| 126 | + BorderThickness = new Avalonia.Thickness(0), |
| 127 | + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, |
| 128 | + }; |
| 129 | + btn.Click += ColumnFilter_Click; |
| 130 | + ToolTip.SetTip(btn, "Click to filter"); |
| 131 | + |
| 132 | + var text = new TextBlock |
| 133 | + { |
| 134 | + Text = label, |
| 135 | + FontWeight = FontWeight.Bold, |
| 136 | + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, |
| 137 | + Margin = new Avalonia.Thickness(4, 0, 0, 0), |
| 138 | + }; |
| 139 | + |
| 140 | + var header = new StackPanel |
| 141 | + { |
| 142 | + Orientation = Avalonia.Layout.Orientation.Horizontal, |
| 143 | + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, |
| 144 | + }; |
| 145 | + header.Children.Add(btn); |
| 146 | + header.Children.Add(text); |
| 147 | + col.Header = header; |
| 148 | + } |
| 149 | + |
| 150 | + private void EnsureFilterPopup() |
| 151 | + { |
| 152 | + if (_filterPopup != null) return; |
| 153 | + _filterPopupContent = new ColumnFilterPopup(); |
| 154 | + _filterPopup = new Popup |
| 155 | + { |
| 156 | + Child = _filterPopupContent, |
| 157 | + IsLightDismissEnabled = true, |
| 158 | + Placement = PlacementMode.Bottom, |
| 159 | + }; |
| 160 | + // Add to visual tree so DynamicResources resolve inside the popup |
| 161 | + ((Grid)Content!).Children.Add(_filterPopup); |
| 162 | + _filterPopupContent.FilterApplied += OnFilterApplied; |
| 163 | + _filterPopupContent.FilterCleared += OnFilterCleared; |
| 164 | + } |
| 165 | + |
| 166 | + private void ColumnFilter_Click(object? sender, RoutedEventArgs e) |
| 167 | + { |
| 168 | + if (sender is not Button button || button.Tag is not string columnId) return; |
| 169 | + EnsureFilterPopup(); |
| 170 | + _activeFilters.TryGetValue(columnId, out var existing); |
| 171 | + _filterPopupContent!.Initialize(columnId, existing); |
| 172 | + _filterPopup!.PlacementTarget = button; |
| 173 | + _filterPopup.IsOpen = true; |
| 174 | + } |
| 175 | + |
| 176 | + private void OnFilterApplied(object? sender, FilterAppliedEventArgs e) |
| 177 | + { |
| 178 | + _filterPopup!.IsOpen = false; |
| 179 | + if (e.FilterState.IsActive) |
| 180 | + _activeFilters[e.FilterState.ColumnName] = e.FilterState; |
| 181 | + else |
| 182 | + _activeFilters.Remove(e.FilterState.ColumnName); |
| 183 | + ApplySortAndFilters(); |
| 184 | + UpdateFilterButtonStyles(); |
| 185 | + } |
| 186 | + |
| 187 | + private void OnFilterCleared(object? sender, EventArgs e) |
| 188 | + { |
| 189 | + _filterPopup!.IsOpen = false; |
| 190 | + } |
| 191 | + |
| 192 | + private void UpdateFilterButtonStyles() |
| 193 | + { |
| 194 | + foreach (var col in ResultsGrid.Columns) |
| 195 | + { |
| 196 | + if (col.Header is not StackPanel sp) continue; |
| 197 | + var btn = sp.Children.OfType<Button>().FirstOrDefault(); |
| 198 | + if (btn?.Tag is not string colId) continue; |
| 199 | + if (btn.Content is not TextBlock tb) continue; |
| 200 | + |
| 201 | + bool hasFilter = _activeFilters.TryGetValue(colId, out var f) && f.IsActive; |
| 202 | + tb.Text = hasFilter ? "▼" : "▽"; |
| 203 | + if (hasFilter) |
| 204 | + tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00)); |
| 205 | + else |
| 206 | + tb.ClearValue(TextBlock.ForegroundProperty); |
| 207 | + |
| 208 | + ToolTip.SetTip(btn, hasFilter |
| 209 | + ? $"Filter: {f!.DisplayText} (click to modify)" |
| 210 | + : "Click to filter"); |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + private void ApplyFilters() |
| 215 | + { |
| 216 | + ApplySortAndFilters(); |
| 217 | + } |
| 218 | + |
| 219 | + private bool RowMatchesAllFilters(QueryStoreRow row) |
| 220 | + { |
| 221 | + foreach (var (colId, state) in _activeFilters) |
| 222 | + { |
| 223 | + if (!state.IsActive) continue; |
| 224 | + if (TextAccessors.TryGetValue(colId, out var textAcc)) |
| 225 | + { |
| 226 | + if (!MatchText(textAcc(row), state.Operator, state.Value)) return false; |
| 227 | + } |
| 228 | + else if (NumericAccessors.TryGetValue(colId, out var numAcc)) |
| 229 | + { |
| 230 | + var isTextOp = state.Operator is FilterOperator.Contains or FilterOperator.StartsWith |
| 231 | + or FilterOperator.EndsWith or FilterOperator.IsEmpty or FilterOperator.IsNotEmpty; |
| 232 | + if (isTextOp) |
| 233 | + { |
| 234 | + if (!MatchText(numAcc(row).ToString("G"), state.Operator, state.Value)) return false; |
| 235 | + } |
| 236 | + else |
| 237 | + { |
| 238 | + if (!double.TryParse(state.Value, out var numVal)) continue; |
| 239 | + if (!MatchNumeric(numAcc(row), state.Operator, numVal)) return false; |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | + return true; |
| 244 | + } |
| 245 | + |
| 246 | + private static bool MatchText(string data, FilterOperator op, string val) => op switch |
| 247 | + { |
| 248 | + FilterOperator.Contains => data.Contains(val, StringComparison.OrdinalIgnoreCase), |
| 249 | + FilterOperator.Equals => data.Equals(val, StringComparison.OrdinalIgnoreCase), |
| 250 | + FilterOperator.NotEquals => !data.Equals(val, StringComparison.OrdinalIgnoreCase), |
| 251 | + FilterOperator.StartsWith => data.StartsWith(val, StringComparison.OrdinalIgnoreCase), |
| 252 | + FilterOperator.EndsWith => data.EndsWith(val, StringComparison.OrdinalIgnoreCase), |
| 253 | + FilterOperator.IsEmpty => string.IsNullOrEmpty(data), |
| 254 | + FilterOperator.IsNotEmpty => !string.IsNullOrEmpty(data), |
| 255 | + _ => true, |
| 256 | + }; |
| 257 | + |
| 258 | + private static bool MatchNumeric(double data, FilterOperator op, double val) => op switch |
| 259 | + { |
| 260 | + FilterOperator.Equals => Math.Abs(data - val) < 1e-9, |
| 261 | + FilterOperator.NotEquals => Math.Abs(data - val) >= 1e-9, |
| 262 | + FilterOperator.GreaterThan => data > val, |
| 263 | + FilterOperator.GreaterThanOrEqual => data >= val, |
| 264 | + FilterOperator.LessThan => data < val, |
| 265 | + FilterOperator.LessThanOrEqual => data <= val, |
| 266 | + _ => true, |
| 267 | + }; |
| 268 | +} |
0 commit comments