Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@
</StackPanel>
</Border>

<!-- DataGrid -->
<DataGrid Grid.Row="2" x:Name="ResultsGrid"
<!-- DataGrid + loading overlay -->
<Grid Grid.Row="2">
<DataGrid x:Name="ResultsGrid"
AutoGenerateColumns="False"
CanUserSortColumns="True"
CanUserReorderColumns="True"
Expand Down Expand Up @@ -318,5 +319,17 @@
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Loading overlay -->
<Border x:Name="GridLoadingOverlay" IsVisible="False"
Background="#80000000" CornerRadius="0"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="200" Height="4"/>
<TextBlock x:Name="GridLoadingText" Text="Fetching plans..."
FontSize="12" HorizontalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>
54 changes: 46 additions & 8 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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)
{
Expand All @@ -220,6 +229,7 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
}
finally
{
GridLoadingOverlay.IsVisible = false;
FetchButton.IsEnabled = true;
}
}
Expand Down Expand Up @@ -384,29 +394,47 @@ private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs

// ── Wait stats ─────────────────────────────────────────────────────────

private async System.Threading.Tasks.Task FetchWaitStatsAsync(
/// <summary>
/// Fetches global bar + ribbon wait stats (independent of grid plan IDs).
/// Shows loading indicator on the wait stats panel.
/// </summary>
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
var ribbonData = await QueryStoreService.FetchGlobalWaitStatsRibbonAsync(
_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
/// <summary>
/// Fetches per-plan wait stats for the plan IDs currently in the grid.
/// </summary>
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
Expand All @@ -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}"); }
}

/// <summary>
/// Full wait stats fetch (global + ribbon + per-plan). Used when re-expanding the wait stats panel.
/// </summary>
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)
Expand Down
25 changes: 25 additions & 0 deletions src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@
<DataGridTextColumn Header="% of Total" Binding="{ReflectionBinding RatioText}" Width="Auto"/>
</DataGrid.Columns>
</DataGrid>
<!-- Loading overlay -->
<Border x:Name="WaitLoadingOverlay" IsVisible="False"
Background="#80000000" CornerRadius="0"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="6">
<ProgressBar IsIndeterminate="True" Width="140" Height="3"/>
<TextBlock Text="Loading wait stats..."
FontSize="11" HorizontalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>
5 changes: 5 additions & 0 deletions src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
100 changes: 79 additions & 21 deletions src/PlanViewer.Core/Services/QueryStoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,41 +195,70 @@ 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,
r.avg_writes,
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<QueryStorePlan>();

Expand Down Expand Up @@ -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 <paramref name="planIds"/> is provided, only those plan IDs are queried (via temp table).
/// </summary>
public static async Task<List<(long PlanId, WaitCategoryTotal Wait)>> FetchPlanWaitStatsAsync(
string connectionString, DateTime startUtc, DateTime endUtc,
IEnumerable<long>? 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,
Expand All @@ -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));
Expand Down