diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index d5a1e16..758b21f 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -109,8 +109,9 @@ - - + + + + + + + + + + diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index a977d9e..595acd6 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -184,15 +184,24 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync() FetchButton.IsEnabled = false; LoadButton.IsEnabled = false; StatusText.Text = "Fetching plans..."; + GridLoadingOverlay.IsVisible = true; + GridLoadingText.Text = "Fetching plans..."; _rows.Clear(); _filteredRows.Clear(); + // Start global + ribbon wait stats early (they don't depend on plan results) + System.Threading.Tasks.Task? globalWaitTask = null; + if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + globalWaitTask = FetchGlobalWaitStatsOnlyAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + try { var plans = await QueryStoreService.FetchTopPlansAsync( _connectionString, topN, orderBy, ct: ct, startUtc: _slicerStartUtc, endUtc: _slicerEndUtc); + GridLoadingOverlay.IsVisible = false; + if (plans.Count == 0) { StatusText.Text = "No Query Store data found for the selected range."; @@ -206,9 +215,9 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync() LoadButton.IsEnabled = true; SelectToggleButton.Content = "Select None"; - // Fetch wait stats in parallel (non-blocking for plan display) + // Fetch per-plan wait stats after grid is populated (needs plan IDs) if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue) - _ = FetchWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); + _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct); } catch (OperationCanceledException) { @@ -220,6 +229,7 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync() } finally { + GridLoadingOverlay.IsVisible = false; FetchButton.IsEnabled = true; } } @@ -384,18 +394,21 @@ private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs // ── Wait stats ───────────────────────────────────────────────────────── - private async System.Threading.Tasks.Task FetchWaitStatsAsync( + /// + /// Fetches global bar + ribbon wait stats (independent of grid plan IDs). + /// Shows loading indicator on the wait stats panel. + /// + private async System.Threading.Tasks.Task FetchGlobalWaitStatsOnlyAsync( DateTime startUtc, DateTime endUtc, CancellationToken ct) { + WaitStatsProfile.SetLoading(true); try { // Global (bar) var globalWaits = await QueryStoreService.FetchGlobalWaitStatsAsync( _connectionString, startUtc, endUtc, ct); - foreach (var w in globalWaits) if (ct.IsCancellationRequested) { return; } var globalProfile = QueryStoreService.BuildWaitProfile(globalWaits); - foreach (var s in globalProfile.Segments) WaitStatsProfile.SetBarProfile(globalProfile); // Global (ribbon) — fetched lazily, data ready for toggle @@ -403,10 +416,25 @@ private async System.Threading.Tasks.Task FetchWaitStatsAsync( _connectionString, startUtc, endUtc, ct); if (ct.IsCancellationRequested) { return; } WaitStatsProfile.SetRibbonData(ribbonData); + } + catch (Exception ex) { Debug.WriteLine($"[WAITSTATS] FetchGlobalWaitStatsOnlyAsync EXCEPTION: {ex}"); } + finally + { + WaitStatsProfile.SetLoading(false); + } + } - // Per-plan + /// + /// Fetches per-plan wait stats for the plan IDs currently in the grid. + /// + private async System.Threading.Tasks.Task FetchPerPlanWaitStatsAsync( + DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + try + { + var visiblePlanIds = _rows.Select(r => r.PlanId).ToList(); var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync( - _connectionString, startUtc, endUtc, ct); + _connectionString, startUtc, endUtc, visiblePlanIds, ct); if (ct.IsCancellationRequested) { return; } var byPlan = planWaits @@ -422,7 +450,17 @@ private async System.Threading.Tasks.Task FetchWaitStatsAsync( } UpdateWaitBarMode(); } - catch (Exception ex) { Debug.WriteLine($"[WAITSTATS] FetchWaitStatsAsync EXCEPTION: {ex}"); } + catch (Exception ex) { Debug.WriteLine($"[WAITSTATS] FetchPerPlanWaitStatsAsync EXCEPTION: {ex}"); } + } + + /// + /// Full wait stats fetch (global + ribbon + per-plan). Used when re-expanding the wait stats panel. + /// + private async System.Threading.Tasks.Task FetchWaitStatsAsync( + DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + await FetchGlobalWaitStatsOnlyAsync(startUtc, endUtc, ct); + await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct); } private void OnWaitCategoryClicked(object? sender, string category) diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index 2b4912c..29bec9a 100644 --- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -532,6 +532,31 @@ public void Redraw() Canvas.SetTop(dot, dotY - DotR); SlicerCanvas.Children.Add(dot); } + + // ── Handle hit zones ────────────────────────────────────────────── + // Drawn last so they sit above per-bucket tooltip rectangles and + // receive pointer events in the handle areas without interference. + var leftHitZone = new Rectangle + { + Width = HandleGripWidthPx * 2, + Height = h, + Fill = Brushes.Transparent, + Cursor = CursorSizeWE, + }; + Canvas.SetLeft(leftHitZone, selLeft - HandleGripWidthPx); + Canvas.SetTop(leftHitZone, 0); + SlicerCanvas.Children.Add(leftHitZone); + + var rightHitZone = new Rectangle + { + Width = HandleGripWidthPx * 2, + Height = h, + Fill = Brushes.Transparent, + Cursor = CursorSizeWE, + }; + Canvas.SetLeft(rightHitZone, selRight - HandleGripWidthPx); + Canvas.SetTop(rightHitZone, 0); + SlicerCanvas.Children.Add(rightHitZone); } private void DrawHandle(double x, double canvasHeight, IBrush brush) diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml index 0ff8f94..66bd23b 100644 --- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml +++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml @@ -43,6 +43,17 @@ + + + + + + + diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs index b36dbee..f25a39c 100644 --- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs +++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs @@ -74,6 +74,11 @@ public void Collapse() CollapsedChanged?.Invoke(this, true); } + public void SetLoading(bool isLoading) + { + WaitLoadingOverlay.IsVisible = isLoading; + } + private void ToggleChart_Click(object? sender, RoutedEventArgs e) { // Cycle: Bar -> Ribbon -> Bar (skip table; table has its own button) diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 4b0d51e..ceda6e7 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -195,13 +195,10 @@ CASE WHEN pa.total_executions > 0 ROW_NUMBER() OVER (PARTITION BY p.query_id ORDER BY {orderClause} DESC) AS rn FROM plan_agg pa JOIN sys.query_store_plan p ON pa.plan_id = p.plan_id - WHERE p.query_plan IS NOT NULL ) SELECT TOP ({topN}) r.query_id, r.plan_id, - qt.query_sql_text, - CAST(p.query_plan AS nvarchar(max)) AS query_plan, r.avg_cpu_us, r.avg_duration_us, r.avg_reads, @@ -209,27 +206,59 @@ SELECT TOP ({topN}) r.avg_physical_reads, r.avg_memory_pages, r.total_executions, - CAST(r.total_cpu_us AS bigint), - CAST(r.total_duration_us AS bigint), - CAST(r.total_reads AS bigint), - CAST(r.total_writes AS bigint), - CAST(r.total_physical_reads AS bigint), - CAST(r.total_memory_pages AS bigint), + CAST(r.total_cpu_us AS bigint) total_cpu_us, + CAST(r.total_duration_us AS bigint) total_duration_us, + CAST(r.total_reads AS bigint) total_reads, + CAST(r.total_writes AS bigint) total_writes, + CAST(r.total_physical_reads AS bigint) total_physical_reads, + CAST(r.total_memory_pages AS bigint) total_memory_pages, r.last_execution_time, - CONVERT(varchar(18), q.query_hash, 1), - CONVERT(varchar(18), p.query_plan_hash, 1), + CONVERT(varchar(18), q.query_hash, 1) query_hash, + CONVERT(varchar(18), p.query_plan_hash, 1) query_plan_hash, CASE WHEN q.object_id <> 0 THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) ELSE N'' - END + END procname +INTO #top_plans FROM ranked r -JOIN sys.query_store_plan p ON r.plan_id = p.plan_id +LEFT OUTER JOIN sys.query_store_plan p ON r.plan_id = p.plan_id +LEFT OUTER JOIN sys.query_store_query q ON p.query_id = q.query_id +LEFT OUTER JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id +WHERE 1 = 1 {rnClause}{filterSql} +ORDER BY {outerOrder} DESC; + +SELECT + topplans.query_id, + topplans.plan_id, + qt.query_sql_text, + CAST(p.query_plan AS nvarchar(max)) AS query_plan, + topplans.avg_cpu_us, + topplans.avg_duration_us, + topplans.avg_reads, + topplans.avg_writes, + topplans.avg_physical_reads, + topplans.avg_memory_pages, + topplans.total_executions, + topplans.total_cpu_us, + topplans.total_duration_us, + topplans.total_reads, + topplans.total_writes, + topplans.total_physical_reads, + topplans.total_memory_pages, + topplans.last_execution_time, + CONVERT(varchar(18), q.query_hash, 1) query_hash, + CONVERT(varchar(18), p.query_plan_hash, 1) query_plan_hash, + CASE + WHEN q.object_id <> 0 + THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) + ELSE N'' + END objectname +FROM #top_plans topplans +JOIN sys.query_store_plan p ON topplans.plan_id = p.plan_id JOIN sys.query_store_query q ON p.query_id = q.query_id JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id -WHERE 1 = 1 {rnClause}{filterSql} -ORDER BY {outerOrder} DESC -OPTION (LOOP JOIN);"; +"; var plans = new List(); @@ -508,12 +537,44 @@ JOIN sys.query_store_runtime_stats_interval rsi /// WaitRatio = SUM(total_query_wait_time_ms) / SUM(avg_duration * count_executions). /// This differs from the global/hourly WTR (which divides by wall-clock interval) because /// at plan level we measure what fraction of actual execution time was spent waiting. + /// When is provided, only those plan IDs are queried (via temp table). /// public static async Task> FetchPlanWaitStatsAsync( string connectionString, DateTime startUtc, DateTime endUtc, + IEnumerable? planIds = null, CancellationToken ct = default) { - const string sql = @" + var rows = new List<(long, WaitCategoryTotal)>(); + await using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(ct); + + // When plan IDs are supplied, load them into a temp table for an efficient JOIN filter. + var planIdFilter = ""; + if (planIds != null) + { + var ids = planIds.Distinct().ToList(); + if (ids.Count == 0) + return rows; + + const string createTmp = @" +CREATE TABLE #plan_ids (plan_id bigint NOT NULL PRIMARY KEY);"; + await using (var createCmd = new SqlCommand(createTmp, conn)) + await createCmd.ExecuteNonQueryAsync(ct); + + // Bulk-insert in batches of 1000 using VALUES rows + for (int i = 0; i < ids.Count; i += 1000) + { + var batch = ids.Skip(i).Take(1000); + var valuesSql = "INSERT INTO #plan_ids (plan_id) VALUES " + + string.Join(",", batch.Select(id => $"({id})")) + ";"; + await using var insertCmd = new SqlCommand(valuesSql, conn); + await insertCmd.ExecuteNonQueryAsync(ct); + } + + planIdFilter = "\n AND EXISTS (SELECT 1 FROM #plan_ids pid WHERE pid.plan_id = ws.plan_id)"; + } + + var sql = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT ws.plan_id, @@ -527,13 +588,10 @@ JOIN sys.query_store_runtime_stats_interval rsi JOIN sys.query_store_runtime_stats rs ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id and rs.plan_id=ws.plan_id WHERE rsi.start_time >= @start AND rsi.start_time < @end AND ws.execution_type = 0 -" + WaitCategoryExclusion + @" +" + WaitCategoryExclusion + planIdFilter + @" GROUP BY ws.plan_id, ws.wait_category, ws.wait_category_desc ORDER BY ws.plan_id, wait_ratio DESC;"; - var rows = new List<(long, WaitCategoryTotal)>(); - await using var conn = new SqlConnection(connectionString); - await conn.OpenAsync(ct); await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 }; cmd.Parameters.Add(new SqlParameter("@start", startUtc)); cmd.Parameters.Add(new SqlParameter("@end", endUtc));