diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 2764dc9..b277089 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -1021,11 +1021,26 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) SetStatus(""); + // Check if wait stats are supported (SQL 2017+ / Azure) and capture is enabled + var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; + if (supportsWaitStats) + { + try + { + var connStr = _serverConnection!.GetConnectionString(_credentialService, _selectedDatabase!); + supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr); + } + catch + { + supportsWaitStats = false; + } + } + // Build database list from the current DatabaseBox var databases = DatabaseBox.Items.OfType().ToList(); var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, - _selectedDatabase!, databases); + _selectedDatabase!, databases, supportsWaitStats); grid.PlansSelected += OnQueryStorePlansSelected; var headerText = new TextBlock diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index 077c394..a977d9e 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -41,6 +41,7 @@ public partial class QueryStoreGridControl : UserControl private bool _initialOrderByLoaded; private bool _suppressRangeChanged; private string? _waitHighlightCategory; + private bool _waitStatsSupported; // false until version + capture mode confirmed private bool _waitStatsEnabled = true; private bool _waitPercentMode; @@ -50,12 +51,13 @@ public partial class QueryStoreGridControl : UserControl public string Database => _database; public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService, - string initialDatabase, List databases) + string initialDatabase, List databases, bool supportsWaitStats = false) { _serverConnection = serverConnection; _credentialService = credentialService; _database = initialDatabase; _connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase); + _waitStatsSupported = supportsWaitStats; _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays; InitializeComponent(); ResultsGrid.ItemsSource = _filteredRows; @@ -69,6 +71,19 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi WaitStatsProfile.CategoryDoubleClicked += OnWaitCategoryDoubleClicked; WaitStatsProfile.CollapsedChanged += OnWaitStatsCollapsedChanged; + if (!_waitStatsSupported) + { + // Hide wait stats panel and column when server doesn't support it + WaitStatsProfile.Collapse(); + WaitStatsChevronButton.IsVisible = false; + WaitStatsSplitter.IsVisible = false; + SlicerRow.ColumnDefinitions[2].Width = new GridLength(0); + var waitProfileCol = ResultsGrid.Columns + .FirstOrDefault(c => c.SortMemberPath == "WaitGrandTotalSort"); + if (waitProfileCol != null) + waitProfileCol.IsVisible = false; + } + // Auto-fetch with default settings on connect Avalonia.Threading.Dispatcher.UIThread.Post(() => { @@ -192,7 +207,7 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync() SelectToggleButton.Content = "Select None"; // Fetch wait stats in parallel (non-blocking for plan display) - if (_waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) _ = FetchWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); } catch (OperationCanceledException) @@ -460,7 +475,7 @@ private void OnWaitStatsCollapsedChanged(object? sender, bool collapsed) if (waitProfileCol != null) waitProfileCol.IsVisible = !collapsed; - if (!collapsed && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + if (!collapsed && _waitStatsSupported && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) { // Re-fetch wait stats when expanding — reuse the shared CTS var ct = _fetchCts?.Token ?? CancellationToken.None; diff --git a/src/PlanViewer.Core/Models/ServerMetadata.cs b/src/PlanViewer.Core/Models/ServerMetadata.cs index bee3395..5c2c12e 100644 --- a/src/PlanViewer.Core/Models/ServerMetadata.cs +++ b/src/PlanViewer.Core/Models/ServerMetadata.cs @@ -28,6 +28,12 @@ public class ServerMetadata /// public bool SupportsScopedConfigs => IsAzure || (int.TryParse(ProductVersion?.Split('.')[0], out var major) && major >= 13); + + /// + /// Whether sys.query_store_wait_stats is available (SQL 2017+ or Azure). + /// + public bool SupportsQueryStoreWaitStats => + IsAzure || (int.TryParse(ProductVersion?.Split('.')[0], out var major) && major >= 14); } public class DatabaseMetadata diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index aeb78f5..4b0d51e 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -427,6 +427,36 @@ GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0) // ── Wait stats ───────────────────────────────────────────────────────── + /// + /// Checks whether Query Store wait stats capture is enabled for the connected database. + /// Returns false on SQL Server 2016 (where the option doesn't exist) or when capture is OFF. + /// + public static async Task IsWaitStatsCaptureEnabledAsync( + string connectionString, CancellationToken ct = default) + { + const string sql = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM sys.database_query_store_options + WHERE wait_stats_capture_mode_desc = 'ON' + ) THEN 1 ELSE 0 END;"; + + try + { + await using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(ct); + await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 10 }; + var result = await cmd.ExecuteScalarAsync(ct); + return result is int i && i == 1; + } + catch + { + // Column doesn't exist on SQL 2016, or query store not enabled + return false; + } + } + // Excluded: 11 = Idle, 18 = User Wait private const string WaitCategoryExclusion = "AND ws.wait_category NOT IN (11, 18)";