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));