Skip to content
Merged
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
83 changes: 56 additions & 27 deletions src/PlanViewer.Core/Services/QueryStoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,33 +56,33 @@ public static async Task<List<QueryStorePlan>> FetchTopPlansAsync(
// avg- variants still rank by total CPU (most impactful plan).
var orderClause = key switch
{
"cpu" => "ps.total_cpu_us",
"duration" => "ps.total_duration_us",
"reads" => "ps.total_reads",
"writes" => "ps.total_writes",
"physical-reads" => "ps.total_physical_reads",
"memory" => "ps.total_memory_pages",
"executions" => "ps.total_executions",
_ => "ps.total_cpu_us"
"cpu" => "total_cpu_us",
"duration" => "total_duration_us",
"reads" => "total_reads",
"writes" => "total_writes",
"physical-reads" => "total_physical_reads",
"memory" => "total_memory_pages",
"executions" => "total_executions",
_ => "total_cpu_us"
};

// Final ORDER BY — either a total or avg column from ranked CTE.
var outerOrder = key switch
{
"cpu" => "r.total_cpu_us",
"duration" => "r.total_duration_us",
"reads" => "r.total_reads",
"writes" => "r.total_writes",
"physical-reads" => "r.total_physical_reads",
"memory" => "r.total_memory_pages",
"executions" => "r.total_executions",
"avg-cpu" => "r.avg_cpu_us",
"avg-duration" => "r.avg_duration_us",
"avg-reads" => "r.avg_reads",
"avg-writes" => "r.avg_writes",
"avg-physical-reads" => "r.avg_physical_reads",
"avg-memory" => "r.avg_memory_pages",
_ => "r.total_cpu_us"
"cpu" => "total_cpu_us",
"duration" => "total_duration_us",
"reads" => "total_reads",
"writes" => "total_writes",
"physical-reads" => "total_physical_reads",
"memory" => "total_memory_pages",
"executions" => "total_executions",
"avg-cpu" => "avg_cpu_us",
"avg-duration" => "avg_duration_us",
"avg-reads" => "avg_reads",
"avg-writes" => "avg_writes",
"avg-physical-reads" => "avg_physical_reads",
"avg-memory" => "avg_memory_pages",
_ => "total_cpu_us"
};

// Build optional WHERE clauses from filter (parameterized for safety).
Expand Down Expand Up @@ -575,12 +575,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 = "\nAND 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 @@ -594,13 +626,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
Loading