From cd7153a6afa8160b09d936e09572d8b228e2fdd0 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:44:55 +0200
Subject: [PATCH 1/8] First viable feature : improvments needed but working
---
.../Controls/QueryStoreGridControl.axaml | 31 +
.../Controls/QueryStoreGridControl.axaml.cs | 646 +++++++++++++++---
.../Models/QueryStoreGroupBy.cs | 65 ++
.../Services/QueryStoreService.cs | 529 ++++++++++++++
4 files changed, 1172 insertions(+), 99 deletions(-)
create mode 100644 src/PlanViewer.Core/Models/QueryStoreGroupBy.cs
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 758b21f..d276f73 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -67,6 +67,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 08f3ee1..cf0ae5b 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -1,10 +1,11 @@
using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
@@ -44,6 +45,8 @@ public partial class QueryStoreGridControl : UserControl
private bool _waitStatsSupported; // false until version + capture mode confirmed
private bool _waitStatsEnabled = true;
private bool _waitPercentMode;
+ private QueryStoreGroupBy _groupByMode = QueryStoreGroupBy.None;
+ private List _groupedRootRows = new(); // top-level rows for grouped mode
public event EventHandler>? PlansSelected;
public event EventHandler? DatabaseChanged;
@@ -187,6 +190,7 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
GridLoadingText.Text = "Fetching plans...";
_rows.Clear();
_filteredRows.Clear();
+ _groupedRootRows.Clear();
// Start global + ribbon wait stats early (they don't depend on plan results)
if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
@@ -194,28 +198,14 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
try
{
- var plans = await QueryStoreService.FetchTopPlansAsync(
- _connectionString, topN, orderBy, filter: filter, ct: ct,
- startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
-
- GridLoadingOverlay.IsVisible = false;
-
- if (plans.Count == 0)
+ if (_groupByMode == QueryStoreGroupBy.None)
{
- StatusText.Text = "No Query Store data found for the selected range.";
- return;
+ await FetchFlatPlansAsync(topN, orderBy, filter, ct);
+ }
+ else
+ {
+ await FetchGroupedPlansAsync(topN, orderBy, filter, ct);
}
-
- foreach (var plan in plans)
- _rows.Add(new QueryStoreRow(plan));
-
- ApplyFilters();
- LoadButton.IsEnabled = true;
- SelectToggleButton.Content = "Select All";
-
- // Fetch per-plan wait stats after grid is populated (needs plan IDs)
- if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
}
catch (OperationCanceledException)
{
@@ -232,6 +222,264 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
}
}
+ private async System.Threading.Tasks.Task FetchFlatPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ var plans = await QueryStoreService.FetchTopPlansAsync(
+ _connectionString, topN, orderBy, filter: filter, ct: ct,
+ startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
+
+ GridLoadingOverlay.IsVisible = false;
+
+ if (plans.Count == 0)
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ return;
+ }
+
+ foreach (var plan in plans)
+ _rows.Add(new QueryStoreRow(plan));
+
+ ApplyFilters();
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+
+ // Fetch per-plan wait stats after grid is populated (needs plan IDs)
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ QueryStoreGroupedResult grouped;
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ grouped = await QueryStoreService.FetchGroupedByQueryHashAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+ else // Module
+ {
+ grouped = await QueryStoreService.FetchGroupedByModuleAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+
+ GridLoadingOverlay.IsVisible = false;
+
+ if (grouped.IntermediateRows.Count == 0)
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ return;
+ }
+
+ var rootRows = BuildGroupedRows(grouped);
+
+ // Sort root rows by consolidated metric descending
+ var metricAccessor = GetMetricAccessor(orderBy);
+ rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList();
+ _groupedRootRows = rootRows;
+
+ // Flatten to _rows (all levels) and show only top-level in _filteredRows
+ foreach (var root in rootRows)
+ {
+ _rows.Add(root);
+ foreach (var mid in root.Children)
+ {
+ _rows.Add(mid);
+ foreach (var leaf in mid.Children)
+ _rows.Add(leaf);
+ }
+ }
+
+ // Show only root-level rows initially (collapsed)
+ _filteredRows.Clear();
+ foreach (var root in rootRows)
+ _filteredRows.Add(root);
+
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow.
+ private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch
+ {
+ "cpu" => r => r.TotalCpuSort,
+ "avg-cpu" => r => r.AvgCpuSort,
+ "duration" => r => r.TotalDurSort,
+ "avg-duration" => r => r.AvgDurSort,
+ "reads" => r => r.TotalReadsSort,
+ "avg-reads" => r => r.AvgReadsSort,
+ "writes" => r => r.TotalWritesSort,
+ "avg-writes" => r => r.AvgWritesSort,
+ "physical-reads" => r => r.TotalPhysReadsSort,
+ "avg-physical-reads" => r => r.AvgPhysReadsSort,
+ "memory" => r => r.TotalMemSort,
+ "avg-memory" => r => r.AvgMemSort,
+ "executions" => r => r.ExecsSort,
+ _ => r => r.TotalCpuSort,
+ };
+
+ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
+ {
+ var roots = new List();
+ var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy);
+
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Level 0: QueryHash groups
+ var queryHashGroups = grouped.IntermediateRows
+ .GroupBy(r => r.QueryHash)
+ .ToList();
+
+ foreach (var qhGroup in queryHashGroups)
+ {
+ var qhKey = qhGroup.Key;
+ var intermediateRows = qhGroup.ToList();
+
+ // Build level-1 children (PlanHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at QueryHash level
+ var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? "");
+ roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
+ }
+ }
+ else // Module
+ {
+ // Level 0: Module groups
+ var moduleGroups = grouped.IntermediateRows
+ .GroupBy(r => r.ModuleName)
+ .ToList();
+
+ foreach (var modGroup in moduleGroups)
+ {
+ var modKey = modGroup.Key;
+ var intermediateRows = modGroup.ToList();
+
+ // Build level-1 children (QueryHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at Module level
+ var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey);
+ roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
+ }
+ }
+
+ return roots;
+ }
+
+ private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row)
+ {
+ var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1;
+ return new QueryStorePlan
+ {
+ QueryId = row.QueryId,
+ PlanId = row.PlanId,
+ QueryHash = row.QueryHash,
+ QueryPlanHash = row.QueryPlanHash,
+ ModuleName = row.ModuleName,
+ QueryText = row.QueryText,
+ PlanXml = row.PlanXml,
+ CountExecutions = row.CountExecutions,
+ TotalCpuTimeUs = row.TotalCpuTimeUs,
+ TotalDurationUs = row.TotalDurationUs,
+ TotalLogicalIoReads = row.TotalLogicalIoReads,
+ TotalLogicalIoWrites = row.TotalLogicalIoWrites,
+ TotalPhysicalIoReads = row.TotalPhysicalIoReads,
+ TotalMemoryGrantPages = row.TotalMemoryGrantPages,
+ AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs,
+ AvgDurationUs = (double)row.TotalDurationUs / totalExecs,
+ AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs,
+ AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs,
+ AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs,
+ AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs,
+ LastExecutedUtc = row.LastExecutedUtc,
+ };
+ }
+
+ private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName)
+ {
+ var totalExecs = rows.Sum(r => r.CountExecutions);
+ var safeExecs = totalExecs > 0 ? totalExecs : 1;
+ var totalCpu = rows.Sum(r => r.TotalCpuTimeUs);
+ var totalDur = rows.Sum(r => r.TotalDurationUs);
+ var totalReads = rows.Sum(r => r.TotalLogicalIoReads);
+ var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites);
+ var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads);
+ var totalMem = rows.Sum(r => r.TotalMemoryGrantPages);
+ var lastExec = rows.Max(r => r.LastExecutedUtc);
+
+ return new QueryStorePlan
+ {
+ QueryHash = queryHash,
+ ModuleName = moduleName,
+ CountExecutions = totalExecs,
+ TotalCpuTimeUs = totalCpu,
+ TotalDurationUs = totalDur,
+ TotalLogicalIoReads = totalReads,
+ TotalLogicalIoWrites = totalWrites,
+ TotalPhysicalIoReads = totalPhysReads,
+ TotalMemoryGrantPages = totalMem,
+ AvgCpuTimeUs = (double)totalCpu / safeExecs,
+ AvgDurationUs = (double)totalDur / safeExecs,
+ AvgLogicalIoReads = (double)totalReads / safeExecs,
+ AvgLogicalIoWrites = (double)totalWrites / safeExecs,
+ AvgPhysicalIoReads = (double)totalPhysReads / safeExecs,
+ AvgMemoryGrantPages = (double)totalMem / safeExecs,
+ LastExecutedUtc = lastExec,
+ };
+ }
+
private QueryStoreFilter? BuildSearchFilter()
{
var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
@@ -282,6 +530,106 @@ private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
}
}
+ private int[]? _savedColumnDisplayIndices;
+
+ private void GroupBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!_initialOrderByLoaded) return;
+ var tag = (GroupByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "none";
+ var newMode = tag switch
+ {
+ "query-hash" => QueryStoreGroupBy.QueryHash,
+ "module" => QueryStoreGroupBy.Module,
+ _ => QueryStoreGroupBy.None,
+ };
+ if (newMode == _groupByMode) return;
+ _groupByMode = newMode;
+
+ // Show/hide the expand column (first column in the grid)
+ ResultsGrid.Columns[0].IsVisible = _groupByMode != QueryStoreGroupBy.None;
+
+ // Reorder columns: move the group key column right after expand+checkbox
+ ReorderColumnsForGroupBy();
+
+ // Re-fetch with new grouping
+ Fetch_Click(null, new RoutedEventArgs());
+ }
+
+ private void ReorderColumnsForGroupBy()
+ {
+ var cols = ResultsGrid.Columns;
+
+ if (_groupByMode == QueryStoreGroupBy.None)
+ {
+ // Restore original column order
+ if (_savedColumnDisplayIndices != null)
+ {
+ for (int i = 0; i < cols.Count && i < _savedColumnDisplayIndices.Length; i++)
+ cols[i].DisplayIndex = _savedColumnDisplayIndices[i];
+ _savedColumnDisplayIndices = null;
+ }
+ return;
+ }
+
+ // Save original order if not yet saved
+ _savedColumnDisplayIndices ??= cols.Select(c => c.DisplayIndex).ToArray();
+
+ // Find the target column to promote
+ // cols[4] = QueryHash, cols[6] = Module (from AXAML definition order)
+ var targetColIndex = _groupByMode == QueryStoreGroupBy.QueryHash ? 4 : 6;
+ var targetCol = cols[targetColIndex];
+
+ // Move it to DisplayIndex 2 (right after expand[0] and checkbox[1])
+ targetCol.DisplayIndex = 2;
+ }
+
+ private void ExpandRow_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button btn) return;
+ if (btn.DataContext is not QueryStoreRow row) return;
+ if (!row.HasChildren) return;
+
+ row.IsExpanded = !row.IsExpanded;
+
+ if (row.IsExpanded)
+ {
+ // Insert children after this row in _filteredRows
+ var idx = _filteredRows.IndexOf(row);
+ if (idx < 0) return;
+ var insertAt = idx + 1;
+ foreach (var child in row.Children)
+ {
+ _filteredRows.Insert(insertAt, child);
+ insertAt++;
+ }
+
+ // Scroll the first child into view so the expansion is visible
+ if (row.Children.Count > 0)
+ ResultsGrid.ScrollIntoView(row.Children[0], null);
+ }
+ else
+ {
+ // Remove children (and their expanded children) recursively
+ CollapseRowChildren(row);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void CollapseRowChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ if (child.IsExpanded)
+ {
+ child.IsExpanded = false;
+ CollapseRowChildren(child);
+ }
+ _filteredRows.Remove(child);
+ }
+ }
+
private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (!_initialOrderByLoaded) return;
@@ -698,27 +1046,28 @@ private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
private void SetupColumnHeaders()
{
var cols = ResultsGrid.Columns;
- SetColumnFilterButton(cols[1], "QueryId", "Query ID");
- SetColumnFilterButton(cols[2], "PlanId", "Plan ID");
- SetColumnFilterButton(cols[3], "QueryHash", "Query Hash");
- SetColumnFilterButton(cols[4], "PlanHash", "Plan Hash");
- SetColumnFilterButton(cols[5], "ModuleName", "Module");
- // cols[6] = WaitProfile (no filter button)
- SetColumnFilterButton(cols[7], "LastExecuted", "Last Executed (Local)");
- SetColumnFilterButton(cols[8], "Executions", "Executions");
- SetColumnFilterButton(cols[9], "TotalCpu", "Total CPU (ms)");
- SetColumnFilterButton(cols[10], "AvgCpu", "Avg CPU (ms)");
- SetColumnFilterButton(cols[11], "TotalDuration", "Total Duration (ms)");
- SetColumnFilterButton(cols[12], "AvgDuration", "Avg Duration (ms)");
- SetColumnFilterButton(cols[13], "TotalReads", "Total Reads");
- SetColumnFilterButton(cols[14], "AvgReads", "Avg Reads");
- SetColumnFilterButton(cols[15], "TotalWrites", "Total Writes");
- SetColumnFilterButton(cols[16], "AvgWrites", "Avg Writes");
- SetColumnFilterButton(cols[17], "TotalPhysReads", "Total Physical Reads");
- SetColumnFilterButton(cols[18], "AvgPhysReads", "Avg Physical Reads");
- SetColumnFilterButton(cols[19], "TotalMemory", "Total Memory (MB)");
- SetColumnFilterButton(cols[20], "AvgMemory", "Avg Memory (MB)");
- SetColumnFilterButton(cols[21], "QueryText", "Query Text");
+ // cols[0] = Expand column, cols[1] = Checkbox
+ SetColumnFilterButton(cols[2], "QueryId", "Query ID");
+ SetColumnFilterButton(cols[3], "PlanId", "Plan ID");
+ SetColumnFilterButton(cols[4], "QueryHash", "Query Hash");
+ SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash");
+ SetColumnFilterButton(cols[6], "ModuleName", "Module");
+ // cols[7] = WaitProfile (no filter button)
+ SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)");
+ SetColumnFilterButton(cols[9], "Executions", "Executions");
+ SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)");
+ SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)");
+ SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)");
+ SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)");
+ SetColumnFilterButton(cols[14], "TotalReads", "Total Reads");
+ SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads");
+ SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes");
+ SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes");
+ SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads");
+ SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads");
+ SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)");
+ SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)");
+ SetColumnFilterButton(cols[22], "QueryText", "Query Text");
}
private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
@@ -884,9 +1233,20 @@ private bool RowMatchesAllFilters(QueryStoreRow row)
private void UpdateStatusText()
{
if (_rows.Count == 0) return;
- StatusText.Text = _filteredRows.Count == _rows.Count
- ? $"{_rows.Count} plans"
- : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ var rootCount = _groupedRootRows.Count;
+ var visibleRoots = _filteredRows.Count(r => r.IndentLevel == 0);
+ StatusText.Text = visibleRoots == rootCount
+ ? $"{rootCount} groups ({_rows.Count} total rows)"
+ : $"{visibleRoots} / {rootCount} groups (filtered)";
+ }
+ else
+ {
+ StatusText.Text = _filteredRows.Count == _rows.Count
+ ? $"{_rows.Count} plans"
+ : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
+ }
}
private void ResultsGrid_Sorting(object? sender, DataGridColumnEventArgs e)
@@ -954,6 +1314,12 @@ private void ReapplyTopNSelection()
private void ApplySortAndFilters()
{
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ ApplySortAndFiltersGrouped();
+ return;
+ }
+
IEnumerable source = _rows.Where(RowMatchesAllFilters);
if (_sortedColumnTag != null)
@@ -972,6 +1338,40 @@ private void ApplySortAndFilters()
UpdateBarRatios();
}
+ private void ApplySortAndFiltersGrouped()
+ {
+ // In grouped mode, sort/filter only root rows and rebuild the visible list
+ IEnumerable source = _groupedRootRows.Where(RowMatchesAllFilters);
+
+ if (_sortedColumnTag != null)
+ {
+ source = _sortAscending
+ ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
+ : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
+ }
+
+ _filteredRows.Clear();
+ foreach (var root in source)
+ {
+ _filteredRows.Add(root);
+ if (root.IsExpanded)
+ AddExpandedChildren(root);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void AddExpandedChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ _filteredRows.Add(child);
+ if (child.IsExpanded)
+ AddExpandedChildren(child);
+ }
+ }
+
// ── Bar chart ratio computation ────────────────────────────────────────
// Maps a ColumnId (used in BarChartConfig) to the accessor that returns the raw sort value.
@@ -1060,55 +1460,103 @@ private static IComparable GetSortKey(string columnTag, QueryStoreRow r) =>
};
}
-public class QueryStoreRow : INotifyPropertyChanged
-{
- private bool _isSelected = false;
-
- // Bar ratios [0..1] per column
- private double _execsRatio;
- private double _totalCpuRatio;
- private double _avgCpuRatio;
- private double _totalDurRatio;
- private double _avgDurRatio;
- private double _totalReadsRatio;
- private double _avgReadsRatio;
- private double _totalWritesRatio;
- private double _avgWritesRatio;
- private double _totalPhysReadsRatio;
- private double _avgPhysReadsRatio;
- private double _totalMemRatio;
- private double _avgMemRatio;
-
- // IsSortedColumn flags
- private bool _isSorted_Executions;
- private bool _isSorted_TotalCpu;
- private bool _isSorted_AvgCpu;
- private bool _isSorted_TotalDuration;
- private bool _isSorted_AvgDuration;
- private bool _isSorted_TotalReads;
- private bool _isSorted_AvgReads;
- private bool _isSorted_TotalWrites;
- private bool _isSorted_AvgWrites;
- private bool _isSorted_TotalPhysReads;
- private bool _isSorted_AvgPhysReads;
- private bool _isSorted_TotalMemory;
- private bool _isSorted_AvgMemory;
-
- // Wait stats
- private WaitProfile? _waitProfile;
- private string? _waitHighlightCategory;
-
- public QueryStoreRow(QueryStorePlan plan)
- {
- Plan = plan;
- }
-
- public QueryStorePlan Plan { get; }
-
- public bool IsSelected
- {
- get => _isSelected;
- set { _isSelected = value; OnPropertyChanged(); }
+public class QueryStoreRow : INotifyPropertyChanged
+{
+ private bool _isSelected = false;
+
+ // Bar ratios [0..1] per column
+ private double _execsRatio;
+ private double _totalCpuRatio;
+ private double _avgCpuRatio;
+ private double _totalDurRatio;
+ private double _avgDurRatio;
+ private double _totalReadsRatio;
+ private double _avgReadsRatio;
+ private double _totalWritesRatio;
+ private double _avgWritesRatio;
+ private double _totalPhysReadsRatio;
+ private double _avgPhysReadsRatio;
+ private double _totalMemRatio;
+ private double _avgMemRatio;
+
+ // IsSortedColumn flags
+ private bool _isSorted_Executions;
+ private bool _isSorted_TotalCpu;
+ private bool _isSorted_AvgCpu;
+ private bool _isSorted_TotalDuration;
+ private bool _isSorted_AvgDuration;
+ private bool _isSorted_TotalReads;
+ private bool _isSorted_AvgReads;
+ private bool _isSorted_TotalWrites;
+ private bool _isSorted_AvgWrites;
+ private bool _isSorted_TotalPhysReads;
+ private bool _isSorted_AvgPhysReads;
+ private bool _isSorted_TotalMemory;
+ private bool _isSorted_AvgMemory;
+
+ // Wait stats
+ private WaitProfile? _waitProfile;
+ private string? _waitHighlightCategory;
+
+ // Hierarchy support
+ private bool _isExpanded;
+ private int _indentLevel;
+
+ /// Standard constructor for flat (ungrouped) rows.
+ public QueryStoreRow(QueryStorePlan plan)
+ {
+ Plan = plan;
+ }
+
+ /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
+ public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
+ {
+ Plan = syntheticPlan;
+ _indentLevel = indentLevel;
+ GroupLabel = groupLabel;
+ Children = children;
+ }
+
+ public QueryStorePlan Plan { get; }
+
+ // ── Hierarchy properties ───────────────────────────────────────────────
+
+ /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
+ public int IndentLevel
+ {
+ get => _indentLevel;
+ set { _indentLevel = value; OnPropertyChanged(); }
+ }
+
+ /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
+ public string GroupLabel { get; set; } = "";
+
+ /// Direct children of this group row.
+ public List Children { get; set; } = new();
+
+ public bool HasChildren => Children.Count > 0;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
+ }
+
+ public string ExpandChevron => HasChildren ? (IsExpanded ? "▾" : "▸") : "";
+
+ /// Left margin that increases with indent level to visually show hierarchy.
+ public Avalonia.Thickness IndentMargin => new(IndentLevel * 20, 0, 0, 0);
+
+ /// Text shown next to the chevron: the group label for parent rows, or QueryId/PlanId for leaves.
+ public string GroupDisplayText => !string.IsNullOrEmpty(GroupLabel) ? GroupLabel : "";
+
+ /// Bold for top-level groups, normal for children.
+ public Avalonia.Media.FontWeight GroupFontWeight => IndentLevel == 0 ? Avalonia.Media.FontWeight.Bold : Avalonia.Media.FontWeight.Normal;
+
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set { _isSelected = value; OnPropertyChanged(); }
}
// ── Bar ratio properties ───────────────────────────────────────────────
diff --git a/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs b/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs
new file mode 100644
index 0000000..6d62f70
--- /dev/null
+++ b/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs
@@ -0,0 +1,65 @@
+namespace PlanViewer.Core.Models;
+
+///
+/// Specifies how Query Store grid results are grouped.
+///
+public enum QueryStoreGroupBy
+{
+ /// No grouping — one row per plan (existing behaviour).
+ None,
+ /// Group by query_hash → plan_hash → query_id/plan_id.
+ QueryHash,
+ /// Group by module → query_hash → query_id/plan_id.
+ Module,
+}
+
+///
+/// A row returned by the grouped query. Contains raw totals for app-side aggregation.
+/// Used as the leaf or intermediate row in the hierarchy; parent-level aggregation
+/// (SUM totals, recomputed averages) is done on the application side.
+///
+public class QueryStoreGroupedPlanRow
+{
+ // Grouping keys
+ public string ModuleName { get; set; } = "";
+ public string QueryHash { get; set; } = "";
+ public string QueryPlanHash { get; set; } = "";
+ public long QueryId { get; set; }
+ public long PlanId { get; set; }
+ public string QueryText { get; set; } = "";
+ public string PlanXml { get; set; } = "";
+
+ // Raw totals (aggregated across intervals for this plan_id / plan_hash level)
+ public long CountExecutions { get; set; }
+ public long TotalCpuTimeUs { get; set; }
+ public long TotalDurationUs { get; set; }
+ public long TotalLogicalIoReads { get; set; }
+ public long TotalLogicalIoWrites { get; set; }
+ public long TotalPhysicalIoReads { get; set; }
+ public long TotalMemoryGrantPages { get; set; }
+ public DateTime LastExecutedUtc { get; set; }
+
+ ///
+ /// Indicates whether this row is the "top" (true) or "bottom" (false) representative
+ /// for a query_hash/plan_hash pair. Only meaningful for leaf-level (QueryId/PlanId) rows.
+ ///
+ public bool IsTopRepresentative { get; set; }
+}
+
+///
+/// Complete result of a grouped fetch. Contains intermediate-level rows (per plan_hash)
+/// and leaf-level rows (per query_id/plan_id).
+///
+public class QueryStoreGroupedResult
+{
+ ///
+ /// Intermediate rows: one per (query_hash, plan_hash) or (module, query_hash).
+ /// These carry the aggregated metrics for that group.
+ ///
+ public List IntermediateRows { get; set; } = new();
+
+ ///
+ /// Leaf rows: top and bottom query_id/plan_id representatives per group.
+ ///
+ public List LeafRows { get; set; } = new();
+}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 8b96fbd..7024d85 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -920,6 +920,535 @@ GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0),
return rows;
}
+ // ── Grouped fetches ──────────────────────────────────────────────────
+
+ ///
+ /// Helper: resolves the metric alias used inside aggregation temp tables.
+ /// Returns (planStatsColumn, aggAlias) where planStatsColumn references #plan_stats
+ /// and aggAlias is the column name used in GROUP BY aggregation selects.
+ ///
+ private static (string PlanStatsCol, string AggAlias) ResolveGroupMetric(string orderBy)
+ {
+ var key = orderBy.ToLowerInvariant();
+ return key switch
+ {
+ "cpu" or "avg-cpu" => ("total_cpu_us", "total_cpu_us"),
+ "duration" or "avg-duration" => ("total_duration_us", "total_duration_us"),
+ "reads" or "avg-reads" => ("total_reads", "total_reads"),
+ "writes" or "avg-writes" => ("total_writes", "total_writes"),
+ "physical-reads" or "avg-physical-reads" => ("total_physical_reads","total_physical_reads"),
+ "memory" or "avg-memory" => ("total_memory_pages", "total_memory_pages"),
+ "executions" => ("total_executions", "total_executions"),
+ _ => ("total_cpu_us", "total_cpu_us"),
+ };
+ }
+
+ ///
+ /// Fetches grouped-by-QueryHash results.
+ /// Step 1: Top X query hashes by metric.
+ /// Step 2: Top 5 plan hashes per query hash with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash.
+ /// Returns intermediate (plan_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByQueryHashAsync(
+ string connectionString, int topN = 25, string orderBy = "cpu",
+ QueryStoreFilter? filter = null, CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var (metricCol, _) = ResolveGroupMetric(orderBy);
+ var parameters = new List();
+
+ // Time-range filter
+ string intervalWhereClause;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE())";
+ }
+
+ // Filter clauses
+ var filterClauses = new List();
+ if (filter?.QueryId != null)
+ {
+ filterClauses.Add("AND q.query_id = @filterQueryId");
+ parameters.Add(new SqlParameter("@filterQueryId", filter.QueryId.Value));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.QueryHash))
+ {
+ filterClauses.Add("AND q.query_hash = CONVERT(binary(8), @filterQueryHash, 1)");
+ parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim()));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.ModuleName))
+ {
+ var moduleVal = filter.ModuleName.Trim();
+ if (moduleVal.Contains('%'))
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) LIKE @filterModule");
+ else
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) = @filterModule");
+ parameters.Add(new SqlParameter("@filterModule", moduleVal));
+ }
+ var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : "";
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+/* Phase 1: Pre-filter matching interval IDs */
+DROP TABLE IF EXISTS #intervals;
+CREATE TABLE #intervals (runtime_stats_interval_id bigint NOT NULL PRIMARY KEY CLUSTERED);
+INSERT INTO #intervals (runtime_stats_interval_id)
+SELECT rsi.runtime_stats_interval_id
+FROM sys.query_store_runtime_stats_interval AS rsi
+{intervalWhereClause}
+OPTION (RECOMPILE);
+
+/* Phase 2: Aggregate runtime stats by plan_id */
+DROP TABLE IF EXISTS #plan_stats;
+CREATE TABLE #plan_stats (
+ plan_id bigint NOT NULL PRIMARY KEY CLUSTERED,
+ total_cpu_us float NOT NULL,
+ total_duration_us float NOT NULL,
+ total_reads float NOT NULL,
+ total_writes float NOT NULL,
+ total_physical_reads float NOT NULL,
+ total_memory_pages float NOT NULL,
+ total_executions bigint NOT NULL,
+ last_execution_time datetimeoffset NOT NULL
+);
+INSERT INTO #plan_stats
+SELECT
+ rs.plan_id,
+ SUM(rs.avg_cpu_time * rs.count_executions),
+ SUM(rs.avg_duration * rs.count_executions),
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions),
+ SUM(rs.count_executions),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats AS rs
+WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id)
+GROUP BY rs.plan_id
+OPTION (RECOMPILE);
+
+/* Step 1: Top X query hashes by metric */
+DROP TABLE IF EXISTS #top_hashes;
+;WITH qh AS (
+ SELECT
+ q.query_hash,
+ SUM(ps.{metricCol}) AS metric_total
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE 1=1{filterSql}
+ GROUP BY q.query_hash
+)
+SELECT TOP ({topN}) query_hash, metric_total
+INTO #top_hashes
+FROM qh
+ORDER BY metric_total DESC;
+
+/* Step 2: Top 5 plan hashes per query hash with metrics */
+DROP TABLE IF EXISTS #plan_hash_rows;
+;WITH ph AS (
+ SELECT
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ SUM(ps.total_cpu_us) AS total_cpu_us,
+ SUM(ps.total_duration_us) AS total_duration_us,
+ SUM(ps.total_reads) AS total_reads,
+ SUM(ps.total_writes) AS total_writes,
+ SUM(ps.total_physical_reads) AS total_physical_reads,
+ SUM(ps.total_memory_pages) AS total_memory_pages,
+ SUM(ps.total_executions) AS total_executions,
+ MAX(ps.last_execution_time) AS last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash ORDER BY SUM(ps.{metricCol}) DESC) AS rnum
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE EXISTS (SELECT 1 FROM #top_hashes th WHERE th.query_hash = q.query_hash)
+ GROUP BY q.query_hash, p.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
+)
+SELECT query_hash, plan_hash, module_name,
+ CAST(total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(total_duration_us AS bigint) AS total_duration_us,
+ CAST(total_reads AS bigint) AS total_reads,
+ CAST(total_writes AS bigint) AS total_writes,
+ CAST(total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(total_memory_pages AS bigint) AS total_memory_pages,
+ total_executions,
+ last_execution_time
+INTO #plan_hash_rows
+FROM ph WHERE rnum <= 5;
+
+/* Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash */
+;WITH ranked AS (
+ SELECT
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ q.query_id,
+ ps.plan_id,
+ qt.query_sql_text,
+ TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CAST(ps.total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(ps.total_duration_us AS bigint) AS total_duration_us,
+ CAST(ps.total_reads AS bigint) AS total_reads,
+ CAST(ps.total_writes AS bigint) AS total_writes,
+ CAST(ps.total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(ps.total_memory_pages AS bigint) AS total_memory_pages,
+ ps.total_executions,
+ ps.last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} DESC) AS rn_top,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} ASC) AS rn_bottom
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.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 EXISTS (SELECT 1 FROM #plan_hash_rows phr
+ WHERE phr.query_hash = CONVERT(varchar(18), q.query_hash, 1)
+ AND phr.plan_hash = CONVERT(varchar(18), p.query_plan_hash, 1))
+)
+SELECT query_hash, plan_hash, query_id, plan_id, query_sql_text, plan_xml,
+ module_name, total_cpu_us, total_duration_us, total_reads, total_writes,
+ total_physical_reads, total_memory_pages, total_executions, last_execution_time,
+ CASE WHEN rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM ranked
+WHERE rn_top = 1 OR rn_bottom = 1;
+
+/* Return intermediate rows (result set 1) */
+SELECT * FROM #plan_hash_rows ORDER BY query_hash, total_executions DESC;
+";
+
+ var result = new QueryStoreGroupedResult();
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters) cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ // Result set 1: Leaf rows (top/bottom per query_hash/plan_hash)
+ while (await reader.ReadAsync(ct))
+ {
+ result.LeafRows.Add(new QueryStoreGroupedPlanRow
+ {
+ QueryHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ QueryId = reader.GetInt64(2),
+ PlanId = reader.GetInt64(3),
+ QueryText = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ PlanXml = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ ModuleName = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ TotalCpuTimeUs = reader.GetInt64(7),
+ TotalDurationUs = reader.GetInt64(8),
+ TotalLogicalIoReads = reader.GetInt64(9),
+ TotalLogicalIoWrites = reader.GetInt64(10),
+ TotalPhysicalIoReads = reader.GetInt64(11),
+ TotalMemoryGrantPages = reader.GetInt64(12),
+ CountExecutions = reader.GetInt64(13),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime,
+ IsTopRepresentative = reader.GetInt32(15) == 1,
+ });
+ }
+
+ // Result set 2: Intermediate rows (plan_hash level aggregated)
+ if (await reader.NextResultAsync(ct))
+ {
+ while (await reader.ReadAsync(ct))
+ {
+ result.IntermediateRows.Add(new QueryStoreGroupedPlanRow
+ {
+ QueryHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ ModuleName = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ TotalCpuTimeUs = reader.GetInt64(3),
+ TotalDurationUs = reader.GetInt64(4),
+ TotalLogicalIoReads = reader.GetInt64(5),
+ TotalLogicalIoWrites = reader.GetInt64(6),
+ TotalPhysicalIoReads = reader.GetInt64(7),
+ TotalMemoryGrantPages = reader.GetInt64(8),
+ CountExecutions = reader.GetInt64(9),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(10)).UtcDateTime,
+ });
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Fetches grouped-by-Module results.
+ /// Step 1: Top X modules by metric.
+ /// Step 2: Top 5 query hashes per module with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per module/query_hash.
+ /// Returns intermediate (query_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByModuleAsync(
+ string connectionString, int topN = 25, string orderBy = "cpu",
+ QueryStoreFilter? filter = null, CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var (metricCol, _) = ResolveGroupMetric(orderBy);
+ var parameters = new List();
+
+ // Time-range filter
+ string intervalWhereClause;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE())";
+ }
+
+ // Filter clauses
+ var filterClauses = new List();
+ if (!string.IsNullOrWhiteSpace(filter?.ModuleName))
+ {
+ var moduleVal = filter.ModuleName.Trim();
+ if (moduleVal.Contains('%'))
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) LIKE @filterModule");
+ else
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) = @filterModule");
+ parameters.Add(new SqlParameter("@filterModule", moduleVal));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.QueryHash))
+ {
+ filterClauses.Add("AND q.query_hash = CONVERT(binary(8), @filterQueryHash, 1)");
+ parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim()));
+ }
+ var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : "";
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+/* Phase 1: Pre-filter matching interval IDs */
+DROP TABLE IF EXISTS #intervals;
+CREATE TABLE #intervals (runtime_stats_interval_id bigint NOT NULL PRIMARY KEY CLUSTERED);
+INSERT INTO #intervals (runtime_stats_interval_id)
+SELECT rsi.runtime_stats_interval_id
+FROM sys.query_store_runtime_stats_interval AS rsi
+{intervalWhereClause}
+OPTION (RECOMPILE);
+
+/* Phase 2: Aggregate runtime stats by plan_id */
+DROP TABLE IF EXISTS #plan_stats;
+CREATE TABLE #plan_stats (
+ plan_id bigint NOT NULL PRIMARY KEY CLUSTERED,
+ total_cpu_us float NOT NULL,
+ total_duration_us float NOT NULL,
+ total_reads float NOT NULL,
+ total_writes float NOT NULL,
+ total_physical_reads float NOT NULL,
+ total_memory_pages float NOT NULL,
+ total_executions bigint NOT NULL,
+ last_execution_time datetimeoffset NOT NULL
+);
+INSERT INTO #plan_stats
+SELECT
+ rs.plan_id,
+ SUM(rs.avg_cpu_time * rs.count_executions),
+ SUM(rs.avg_duration * rs.count_executions),
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions),
+ SUM(rs.count_executions),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats AS rs
+WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id)
+GROUP BY rs.plan_id
+OPTION (RECOMPILE);
+
+/* Step 1: Top X modules by metric */
+DROP TABLE IF EXISTS #top_modules;
+;WITH md AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ SUM(ps.{metricCol}) AS metric_total
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE q.object_id <> 0{filterSql}
+ GROUP BY CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+)
+SELECT TOP ({topN}) module_name, metric_total
+INTO #top_modules
+FROM md
+ORDER BY metric_total DESC;
+
+/* Step 2: Top 5 query hashes per module with metrics */
+DROP TABLE IF EXISTS #qhash_rows;
+;WITH qh AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ SUM(ps.total_cpu_us) AS total_cpu_us,
+ SUM(ps.total_duration_us) AS total_duration_us,
+ SUM(ps.total_reads) AS total_reads,
+ SUM(ps.total_writes) AS total_writes,
+ SUM(ps.total_physical_reads) AS total_physical_reads,
+ SUM(ps.total_memory_pages) AS total_memory_pages,
+ SUM(ps.total_executions) AS total_executions,
+ MAX(ps.last_execution_time) AS last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+ ORDER BY SUM(ps.{metricCol}) DESC) AS rnum
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE EXISTS (SELECT 1 FROM #top_modules tm
+ WHERE tm.module_name = CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END)
+ GROUP BY CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+)
+SELECT module_name, query_hash,
+ CAST(total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(total_duration_us AS bigint) AS total_duration_us,
+ CAST(total_reads AS bigint) AS total_reads,
+ CAST(total_writes AS bigint) AS total_writes,
+ CAST(total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(total_memory_pages AS bigint) AS total_memory_pages,
+ total_executions,
+ last_execution_time
+INTO #qhash_rows
+FROM qh WHERE rnum <= 5;
+
+/* Step 3: Top and bottom QueryId/PlanId per module/query_hash */
+;WITH ranked AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ q.query_id,
+ ps.plan_id,
+ qt.query_sql_text,
+ TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ CAST(ps.total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(ps.total_duration_us AS bigint) AS total_duration_us,
+ CAST(ps.total_reads AS bigint) AS total_reads,
+ CAST(ps.total_writes AS bigint) AS total_writes,
+ CAST(ps.total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(ps.total_memory_pages AS bigint) AS total_memory_pages,
+ ps.total_executions,
+ ps.last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+ ORDER BY ps.{metricCol} DESC) AS rn_top,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+ ORDER BY ps.{metricCol} ASC) AS rn_bottom
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.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 EXISTS (SELECT 1 FROM #qhash_rows qhr
+ WHERE qhr.module_name = CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+ AND qhr.query_hash = CONVERT(varchar(18), q.query_hash, 1))
+)
+SELECT module_name, query_hash, plan_hash, query_id, plan_id, query_sql_text, plan_xml,
+ total_cpu_us, total_duration_us, total_reads, total_writes,
+ total_physical_reads, total_memory_pages, total_executions, last_execution_time,
+ CASE WHEN rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM ranked
+WHERE rn_top = 1 OR rn_bottom = 1;
+
+/* Return intermediate rows (result set 2) */
+SELECT * FROM #qhash_rows ORDER BY module_name, total_executions DESC;
+";
+
+ var result = new QueryStoreGroupedResult();
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters) cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ // Result set 1: Leaf rows (top/bottom per module/query_hash)
+ while (await reader.ReadAsync(ct))
+ {
+ result.LeafRows.Add(new QueryStoreGroupedPlanRow
+ {
+ ModuleName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ QueryPlanHash = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ QueryId = reader.GetInt64(3),
+ PlanId = reader.GetInt64(4),
+ QueryText = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ PlanXml = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ TotalCpuTimeUs = reader.GetInt64(7),
+ TotalDurationUs = reader.GetInt64(8),
+ TotalLogicalIoReads = reader.GetInt64(9),
+ TotalLogicalIoWrites = reader.GetInt64(10),
+ TotalPhysicalIoReads = reader.GetInt64(11),
+ TotalMemoryGrantPages = reader.GetInt64(12),
+ CountExecutions = reader.GetInt64(13),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime,
+ IsTopRepresentative = reader.GetInt32(15) == 1,
+ });
+ }
+
+ // Result set 2: Intermediate rows (query_hash level aggregated under module)
+ if (await reader.NextResultAsync(ct))
+ {
+ while (await reader.ReadAsync(ct))
+ {
+ result.IntermediateRows.Add(new QueryStoreGroupedPlanRow
+ {
+ ModuleName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ TotalCpuTimeUs = reader.GetInt64(2),
+ TotalDurationUs = reader.GetInt64(3),
+ TotalLogicalIoReads = reader.GetInt64(4),
+ TotalLogicalIoWrites = reader.GetInt64(5),
+ TotalPhysicalIoReads = reader.GetInt64(6),
+ TotalMemoryGrantPages = reader.GetInt64(7),
+ CountExecutions = reader.GetInt64(8),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(9)).UtcDateTime,
+ });
+ }
+ }
+
+ return result;
+ }
+
///
/// Builds a WaitProfile from raw category totals.
/// Top 3 categories are kept; everything else is consolidated into "Others".
From 1e92eaaec7c0b8b2dbba1b62276b9f5e76c218b2 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:13:49 +0200
Subject: [PATCH 2/8] wait profiles added for GroupBy displays
---
.../Controls/QueryStoreGridControl.axaml.cs | 276 ++++++++++++------
1 file changed, 182 insertions(+), 94 deletions(-)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index cf0ae5b..db0c0cc 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -1,11 +1,10 @@
using System;
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
@@ -302,6 +301,89 @@ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
SelectToggleButton.Content = "Select All";
UpdateStatusText();
UpdateBarRatios();
+
+ // Fetch per-plan wait stats for leaf rows, then consolidate upward
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ ///
+ /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy,
+ /// assigns them to leaf rows, then consolidates upward to intermediate and root rows.
+ ///
+ private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ try
+ {
+ // Collect all real plan IDs from rows that have a real PlanId
+ var allPlanIds = _rows
+ .Where(r => r.PlanId > 0)
+ .Select(r => r.PlanId)
+ .Distinct()
+ .ToList();
+
+ if (allPlanIds.Count == 0) return;
+
+ var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
+ _connectionString, startUtc, endUtc, allPlanIds, ct);
+ if (ct.IsCancellationRequested) return;
+
+ // Build lookup: plan_id → list of WaitCategoryTotal
+ var byPlan = planWaits
+ .GroupBy(x => x.PlanId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
+
+ // 1. Assign raw waits + profiles to rows with a real PlanId
+ foreach (var row in _rows)
+ {
+ if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits))
+ {
+ row.RawWaitCategories = waits;
+ row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
+ }
+ }
+
+ // 2. Consolidate upward through the hierarchy
+ foreach (var root in _groupedRootRows)
+ ConsolidateWaitProfileUpward(root);
+
+ UpdateWaitBarMode();
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception) { }
+ }
+
+ ///
+ /// Recursively consolidates wait profiles from children into their parent.
+ /// For each parent: merges all children's RawWaitCategories by summing WaitRatio
+ /// per category, then builds a new WaitProfile from the merged totals.
+ ///
+ private static void ConsolidateWaitProfileUpward(QueryStoreRow parent)
+ {
+ if (parent.Children.Count == 0) return;
+
+ // Recurse first so children are consolidated before we merge them
+ foreach (var child in parent.Children)
+ ConsolidateWaitProfileUpward(child);
+
+ // Merge all children's raw wait categories by summing WaitRatio per category
+ var merged = parent.Children
+ .SelectMany(c => c.RawWaitCategories)
+ .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc })
+ .Select(g => new WaitCategoryTotal
+ {
+ WaitCategory = g.Key.WaitCategory,
+ WaitCategoryDesc = g.Key.WaitCategoryDesc,
+ WaitRatio = g.Sum(w => w.WaitRatio),
+ })
+ .ToList();
+
+ if (merged.Count > 0)
+ {
+ parent.RawWaitCategories = merged;
+ parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged);
+ }
}
/// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow.
@@ -810,7 +892,10 @@ private async System.Threading.Tasks.Task FetchWaitStatsAsync(
DateTime startUtc, DateTime endUtc, CancellationToken ct)
{
await FetchGlobalWaitStatsOnlyAsync(startUtc, endUtc, ct);
- await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
+ if (_groupByMode != QueryStoreGroupBy.None)
+ await FetchGroupedWaitStatsAsync(startUtc, endUtc, ct);
+ else
+ await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
}
private void OnWaitCategoryClicked(object? sender, string category)
@@ -1460,88 +1545,91 @@ private static IComparable GetSortKey(string columnTag, QueryStoreRow r) =>
};
}
-public class QueryStoreRow : INotifyPropertyChanged
-{
- private bool _isSelected = false;
-
- // Bar ratios [0..1] per column
- private double _execsRatio;
- private double _totalCpuRatio;
- private double _avgCpuRatio;
- private double _totalDurRatio;
- private double _avgDurRatio;
- private double _totalReadsRatio;
- private double _avgReadsRatio;
- private double _totalWritesRatio;
- private double _avgWritesRatio;
- private double _totalPhysReadsRatio;
- private double _avgPhysReadsRatio;
- private double _totalMemRatio;
- private double _avgMemRatio;
-
- // IsSortedColumn flags
- private bool _isSorted_Executions;
- private bool _isSorted_TotalCpu;
- private bool _isSorted_AvgCpu;
- private bool _isSorted_TotalDuration;
- private bool _isSorted_AvgDuration;
- private bool _isSorted_TotalReads;
- private bool _isSorted_AvgReads;
- private bool _isSorted_TotalWrites;
- private bool _isSorted_AvgWrites;
- private bool _isSorted_TotalPhysReads;
- private bool _isSorted_AvgPhysReads;
- private bool _isSorted_TotalMemory;
- private bool _isSorted_AvgMemory;
-
- // Wait stats
- private WaitProfile? _waitProfile;
- private string? _waitHighlightCategory;
-
- // Hierarchy support
- private bool _isExpanded;
- private int _indentLevel;
-
- /// Standard constructor for flat (ungrouped) rows.
- public QueryStoreRow(QueryStorePlan plan)
- {
- Plan = plan;
- }
-
- /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
- public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
- {
- Plan = syntheticPlan;
- _indentLevel = indentLevel;
- GroupLabel = groupLabel;
- Children = children;
- }
-
- public QueryStorePlan Plan { get; }
-
- // ── Hierarchy properties ───────────────────────────────────────────────
-
- /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
- public int IndentLevel
- {
- get => _indentLevel;
- set { _indentLevel = value; OnPropertyChanged(); }
- }
-
- /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
- public string GroupLabel { get; set; } = "";
-
- /// Direct children of this group row.
- public List Children { get; set; } = new();
-
- public bool HasChildren => Children.Count > 0;
-
- public bool IsExpanded
- {
- get => _isExpanded;
- set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
- }
-
+public class QueryStoreRow : INotifyPropertyChanged
+{
+ private bool _isSelected = false;
+
+ // Bar ratios [0..1] per column
+ private double _execsRatio;
+ private double _totalCpuRatio;
+ private double _avgCpuRatio;
+ private double _totalDurRatio;
+ private double _avgDurRatio;
+ private double _totalReadsRatio;
+ private double _avgReadsRatio;
+ private double _totalWritesRatio;
+ private double _avgWritesRatio;
+ private double _totalPhysReadsRatio;
+ private double _avgPhysReadsRatio;
+ private double _totalMemRatio;
+ private double _avgMemRatio;
+
+ // IsSortedColumn flags
+ private bool _isSorted_Executions;
+ private bool _isSorted_TotalCpu;
+ private bool _isSorted_AvgCpu;
+ private bool _isSorted_TotalDuration;
+ private bool _isSorted_AvgDuration;
+ private bool _isSorted_TotalReads;
+ private bool _isSorted_AvgReads;
+ private bool _isSorted_TotalWrites;
+ private bool _isSorted_AvgWrites;
+ private bool _isSorted_TotalPhysReads;
+ private bool _isSorted_AvgPhysReads;
+ private bool _isSorted_TotalMemory;
+ private bool _isSorted_AvgMemory;
+
+ // Wait stats
+ private WaitProfile? _waitProfile;
+ private string? _waitHighlightCategory;
+
+ /// Raw wait category totals for this row. Used for upward consolidation in grouped mode.
+ public List RawWaitCategories { get; set; } = new();
+
+ // Hierarchy support
+ private bool _isExpanded;
+ private int _indentLevel;
+
+ /// Standard constructor for flat (ungrouped) rows.
+ public QueryStoreRow(QueryStorePlan plan)
+ {
+ Plan = plan;
+ }
+
+ /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
+ public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
+ {
+ Plan = syntheticPlan;
+ _indentLevel = indentLevel;
+ GroupLabel = groupLabel;
+ Children = children;
+ }
+
+ public QueryStorePlan Plan { get; }
+
+ // ── Hierarchy properties ───────────────────────────────────────────────
+
+ /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
+ public int IndentLevel
+ {
+ get => _indentLevel;
+ set { _indentLevel = value; OnPropertyChanged(); }
+ }
+
+ /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
+ public string GroupLabel { get; set; } = "";
+
+ /// Direct children of this group row.
+ public List Children { get; set; } = new();
+
+ public bool HasChildren => Children.Count > 0;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
+ }
+
public string ExpandChevron => HasChildren ? (IsExpanded ? "▾" : "▸") : "";
/// Left margin that increases with indent level to visually show hierarchy.
@@ -1553,10 +1641,10 @@ public bool IsExpanded
/// Bold for top-level groups, normal for children.
public Avalonia.Media.FontWeight GroupFontWeight => IndentLevel == 0 ? Avalonia.Media.FontWeight.Bold : Avalonia.Media.FontWeight.Normal;
- public bool IsSelected
- {
- get => _isSelected;
- set { _isSelected = value; OnPropertyChanged(); }
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set { _isSelected = value; OnPropertyChanged(); }
}
// ── Bar ratio properties ───────────────────────────────────────────────
From c489d6a37d810d6efdbea11f5d08f90095801259 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:36:28 +0200
Subject: [PATCH 3/8] =?UTF-8?q?1.=20Auto-expand=20first=20row=20(ExpandRow?=
=?UTF-8?q?Recursive):=20After=20populating=20grouped=20rows=20in=20FetchG?=
=?UTF-8?q?roupedPlansAsync,=20the=20first=20root=20row=20is=20recursively?=
=?UTF-8?q?=20expanded=20to=20the=20deepest=20level=20so=20the=20user=20im?=
=?UTF-8?q?mediately=20sees=20the=20full=20hierarchy.=202.=20Load=20child?=
=?UTF-8?q?=20plans=20(LoadHighlightedPlan=5FClick=20/=20LoadSelected=5FCl?=
=?UTF-8?q?ick=20/=20CollectLeafPlans):=20=E2=80=A2=20"Load=20Plan"=20on?=
=?UTF-8?q?=20a=20grouped=20row=20(QueryHash,=20PlanHash,=20or=20Module=20?=
=?UTF-8?q?level)=20now=20recursively=20collects=20all=20descendant=20leaf?=
=?UTF-8?q?=20plans=20where=20both=20PlanId=20>=200=20and=20QueryId=20>=20?=
=?UTF-8?q?0=20=E2=80=A2=20Rows=20with=20PlanId=20=3D=3D=200=20or=20QueryI?=
=?UTF-8?q?d=20=3D=3D=200=20are=20never=20loaded=20=E2=80=A2=20"Load=20Sel?=
=?UTF-8?q?ected"=20does=20the=20same=20expansion=20for=20each=20selected?=
=?UTF-8?q?=20row=203.=20Golden=20headers=20(ApplyGroupByHeaderColors):=20?=
=?UTF-8?q?=E2=80=A2=20When=20GroupBy=3DQueryHash:=20"Query=20Hash"=20and?=
=?UTF-8?q?=20"Plan=20Hash"=20column=20headers=20get=20gold=20(#FFD700)=20?=
=?UTF-8?q?foreground=20=E2=80=A2=20When=20GroupBy=3DModule:=20"Module"=20?=
=?UTF-8?q?and=20"Query=20Hash"=20column=20headers=20get=20gold=20?=
=?UTF-8?q?=E2=80=A2=20When=20GroupBy=3DNone:=20all=20header=20colors=20ar?=
=?UTF-8?q?e=20reset=20to=20default=204.=20Column=20reorder=20(ReorderColu?=
=?UTF-8?q?mnsForGroupBy):=20=E2=80=A2=20QueryId=20and=20PlanId=20columns?=
=?UTF-8?q?=20are=20now=20pushed=20after=20the=20GroupBy=20key=20columns?=
=?UTF-8?q?=20=E2=80=A2=20QueryHash=20mode:=20Expand=20=E2=86=92=20Checkbo?=
=?UTF-8?q?x=20=E2=86=92=20QueryHash=20=E2=86=92=20PlanHash=20=E2=86=92=20?=
=?UTF-8?q?QueryId=20=E2=86=92=20PlanId=20=E2=86=92=20rest=20=E2=80=A2=20M?=
=?UTF-8?q?odule=20mode:=20Expand=20=E2=86=92=20Checkbox=20=E2=86=92=20Mod?=
=?UTF-8?q?ule=20=E2=86=92=20QueryHash=20=E2=86=92=20QueryId=20=E2=86=92?=
=?UTF-8?q?=20PlanId=20=E2=86=92=20rest=20=E2=80=A2=20None=20mode:=20origi?=
=?UTF-8?q?nal=20column=20order=20is=20fully=20restored?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/QueryStoreGridControl.axaml | 12 +-
.../Controls/QueryStoreGridControl.axaml.cs | 135 ++++++++++++++++--
2 files changed, 134 insertions(+), 13 deletions(-)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index d276f73..792b910 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -150,17 +150,19 @@
-
+
-
+
0)
+ {
+ var first = rootRows[0];
+ ExpandRowRecursive(first);
+ }
+
UpdateStatusText();
UpdateBarRatios();
@@ -307,6 +315,29 @@ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
_ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
}
+ ///
+ /// Recursively expands a row and all its children, inserting them into _filteredRows.
+ ///
+ private void ExpandRowRecursive(QueryStoreRow row)
+ {
+ if (!row.HasChildren) return;
+ row.IsExpanded = true;
+
+ var idx = _filteredRows.IndexOf(row);
+ if (idx < 0) return;
+
+ var insertAt = idx + 1;
+ foreach (var child in row.Children)
+ {
+ _filteredRows.Insert(insertAt, child);
+ insertAt++;
+ }
+
+ // Recurse into each child that has children
+ foreach (var child in row.Children)
+ ExpandRowRecursive(child);
+ }
+
///
/// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy,
/// assigns them to leaf rows, then consolidates upward to intermediate and root rows.
@@ -650,19 +681,65 @@ private void ReorderColumnsForGroupBy()
cols[i].DisplayIndex = _savedColumnDisplayIndices[i];
_savedColumnDisplayIndices = null;
}
+ // Reset header colors
+ ApplyGroupByHeaderColors();
return;
}
// Save original order if not yet saved
_savedColumnDisplayIndices ??= cols.Select(c => c.DisplayIndex).ToArray();
- // Find the target column to promote
- // cols[4] = QueryHash, cols[6] = Module (from AXAML definition order)
- var targetColIndex = _groupByMode == QueryStoreGroupBy.QueryHash ? 4 : 6;
- var targetCol = cols[targetColIndex];
+ // Column definition indices (AXAML order):
+ // 0=Expand, 1=Checkbox, 2=QueryId, 3=PlanId, 4=QueryHash, 5=PlanHash, 6=Module
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Order: Expand, Checkbox, QueryHash, PlanHash, QueryId, PlanId, ...
+ cols[4].DisplayIndex = 2; // QueryHash → 2
+ cols[5].DisplayIndex = 3; // PlanHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
+ else // Module
+ {
+ // Order: Expand, Checkbox, Module, QueryHash, QueryId, PlanId, ...
+ cols[6].DisplayIndex = 2; // Module → 2
+ cols[4].DisplayIndex = 3; // QueryHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
- // Move it to DisplayIndex 2 (right after expand[0] and checkbox[1])
- targetCol.DisplayIndex = 2;
+ // Apply golden header colors for expandable columns
+ ApplyGroupByHeaderColors();
+ }
+
+ ///
+ /// Applies golden foreground to column headers that represent expandable/collapsible
+ /// grouping levels in the current GroupBy mode, and resets others.
+ ///
+ private void ApplyGroupByHeaderColors()
+ {
+ // Column definition indices: 4=QueryHash, 5=PlanHash, 6=Module
+ var goldenCols = _groupByMode switch
+ {
+ QueryStoreGroupBy.QueryHash => new HashSet { 4, 5 }, // QueryHash + PlanHash
+ QueryStoreGroupBy.Module => new HashSet { 6, 4 }, // Module + QueryHash
+ _ => new HashSet(),
+ };
+
+ var goldenBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00)); // Gold
+
+ for (int i = 0; i < ResultsGrid.Columns.Count; i++)
+ {
+ var col = ResultsGrid.Columns[i];
+ if (col.Header is not StackPanel sp) continue;
+ var label = sp.Children.OfType().LastOrDefault();
+ if (label == null) continue;
+
+ if (goldenCols.Contains(i))
+ label.Foreground = goldenBrush;
+ else
+ label.ClearValue(TextBlock.ForegroundProperty);
+ }
}
private void ExpandRow_Click(object? sender, RoutedEventArgs e)
@@ -1006,15 +1083,57 @@ private void SelectToggle_Click(object? sender, RoutedEventArgs e)
private void LoadSelected_Click(object? sender, RoutedEventArgs e)
{
- var selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ List selected;
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ // In grouped mode, expand selected grouped rows to their leaf plans
+ selected = _filteredRows
+ .Where(r => r.IsSelected)
+ .SelectMany(r => r.HasChildren ? CollectLeafPlans(r) : (r.PlanId > 0 && r.QueryId > 0 ? [r.Plan] : []))
+ .ToList();
+ }
+ else
+ {
+ selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ }
if (selected.Count > 0)
PlansSelected?.Invoke(this, selected);
}
private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
{
- if (ResultsGrid.SelectedItem is QueryStoreRow row)
+ if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+
+ // In grouped mode, load all descendant leaf plans with real IDs
+ if (_groupByMode != QueryStoreGroupBy.None && row.HasChildren)
+ {
+ var leafPlans = CollectLeafPlans(row);
+ if (leafPlans.Count > 0)
+ PlansSelected?.Invoke(this, leafPlans);
+ }
+ else if (row.PlanId > 0 && row.QueryId > 0)
+ {
PlansSelected?.Invoke(this, new List { row.Plan });
+ }
+ }
+
+ ///
+ /// Recursively collects all leaf-level plans (PlanId > 0 and QueryId > 0) from a grouped row and its descendants.
+ ///
+ private static List CollectLeafPlans(QueryStoreRow row)
+ {
+ var plans = new List();
+ if (row.Children.Count == 0)
+ {
+ if (row.PlanId > 0 && row.QueryId > 0)
+ plans.Add(row.Plan);
+ }
+ else
+ {
+ foreach (var child in row.Children)
+ plans.AddRange(CollectLeafPlans(child));
+ }
+ return plans;
}
private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
From f9218861df1c90122d801046d3891a56a825354b Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:36:18 +0200
Subject: [PATCH 4/8] improve performance of FetchGroupedByQueryHashAsync : get
heavy elements (query text and plan xml) after computations and top/bottom
reduction
---
.../Services/QueryStoreService.cs | 31 +++++++++++++++----
1 file changed, 25 insertions(+), 6 deletions(-)
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 7024d85..ba8211d 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -1098,8 +1098,7 @@ ELSE N'' END
CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
q.query_id,
ps.plan_id,
- qt.query_sql_text,
- TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ q.query_text_id,
CASE WHEN q.object_id <> 0
THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
ELSE N'' END AS module_name,
@@ -1121,13 +1120,33 @@ WHERE EXISTS (SELECT 1 FROM #plan_hash_rows phr
WHERE phr.query_hash = CONVERT(varchar(18), q.query_hash, 1)
AND phr.plan_hash = CONVERT(varchar(18), p.query_plan_hash, 1))
)
-SELECT query_hash, plan_hash, query_id, plan_id, query_sql_text, plan_xml,
- module_name, total_cpu_us, total_duration_us, total_reads, total_writes,
- total_physical_reads, total_memory_pages, total_executions, last_execution_time,
- CASE WHEN rn_top = 1 THEN 1 ELSE 0 END AS is_top
+SELECT *
+into #ranked_light
FROM ranked
WHERE rn_top = 1 OR rn_bottom = 1;
+/* Final select: join heavy elements (query_text, plan_xml) only for the top/bottom representatives */
+SELECT
+r.query_hash,
+r.plan_hash,
+r.query_id,
+r.plan_id,
+qt.query_sql_text,
+TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+r.module_name,
+r.total_cpu_us,
+r.total_duration_us,
+r.total_reads,
+r.total_writes,
+r.total_physical_reads,
+r.total_memory_pages,
+r.total_executions,
+r.last_execution_time,
+CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM #ranked_light r
+JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id
+JOIN sys.query_store_plan p ON r.plan_id = p.plan_id;
+
/* Return intermediate rows (result set 1) */
SELECT * FROM #plan_hash_rows ORDER BY query_hash, total_executions DESC;
";
From fcc8a3c0baba99a4b05e1d2edc28dd7f98f70464 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:37:21 +0200
Subject: [PATCH 5/8] adjust summary
---
.../Services/QueryStoreService.cs | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index ba8211d..cefc319 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -943,14 +943,15 @@ private static (string PlanStatsCol, string AggAlias) ResolveGroupMetric(string
};
}
- ///
- /// Fetches grouped-by-QueryHash results.
- /// Step 1: Top X query hashes by metric.
- /// Step 2: Top 5 plan hashes per query hash with metrics.
- /// Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash.
- /// Returns intermediate (plan_hash level) and leaf (query_id/plan_id level) rows.
- ///
- public static async Task FetchGroupedByQueryHashAsync(
+ ///
+ /// Fetches grouped-by-QueryHash results.
+ /// Step 1: Top X query hashes by metric.
+ /// Step 2: Top 5 plan hashes per query hash with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash.
+ /// Final : Fetch Query Text and Plan XML for the identified QueryId/PlanId.
+ /// Returns intermediate (plan_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByQueryHashAsync(
string connectionString, int topN = 25, string orderBy = "cpu",
QueryStoreFilter? filter = null, CancellationToken ct = default,
DateTime? startUtc = null, DateTime? endUtc = null)
From 8b25fee1c12b72b7a5f6a629657de240685b1650 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:54:58 +0200
Subject: [PATCH 6/8] improve performance of FetchGroupedByModuleAsync using a
late get heavy element
---
.../Services/QueryStoreService.cs | 48 +++++++++++++------
1 file changed, 34 insertions(+), 14 deletions(-)
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index cefc319..cbbda7a 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -1208,14 +1208,15 @@ FROM ranked
return result;
}
- ///
- /// Fetches grouped-by-Module results.
- /// Step 1: Top X modules by metric.
- /// Step 2: Top 5 query hashes per module with metrics.
- /// Step 3: Top and bottom QueryId/PlanId per module/query_hash.
- /// Returns intermediate (query_hash level) and leaf (query_id/plan_id level) rows.
- ///
- public static async Task FetchGroupedByModuleAsync(
+ ///
+ /// Fetches grouped-by-Module results.
+ /// Step 1: Top X modules by metric.
+ /// Step 2: Top 5 query hashes per module with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per module/query_hash.
+ /// Final Step: Fetch Query Text and Plan XML for the identified QueryId/PlanId.
+ /// Returns intermediate (query_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByModuleAsync(
string connectionString, int topN = 25, string orderBy = "cpu",
QueryStoreFilter? filter = null, CancellationToken ct = default,
DateTime? startUtc = null, DateTime? endUtc = null)
@@ -1371,8 +1372,7 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
q.query_id,
ps.plan_id,
- qt.query_sql_text,
- TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ qt.query_text_id,
CAST(ps.total_cpu_us AS bigint) AS total_cpu_us,
CAST(ps.total_duration_us AS bigint) AS total_duration_us,
CAST(ps.total_reads AS bigint) AS total_reads,
@@ -1403,13 +1403,33 @@ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
ELSE N'' END
AND qhr.query_hash = CONVERT(varchar(18), q.query_hash, 1))
)
-SELECT module_name, query_hash, plan_hash, query_id, plan_id, query_sql_text, plan_xml,
- total_cpu_us, total_duration_us, total_reads, total_writes,
- total_physical_reads, total_memory_pages, total_executions, last_execution_time,
- CASE WHEN rn_top = 1 THEN 1 ELSE 0 END AS is_top
+SELECT *
+into #ranked_light
FROM ranked
WHERE rn_top = 1 OR rn_bottom = 1;
+/* Final select: join heavy elements (query_text, plan_xml) only for the top/bottom representatives */
+SELECT
+ r.module_name,
+ r.query_hash,
+ r.plan_hash,
+ r.query_id,
+ r.plan_id,
+ qt.query_sql_text,
+ TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ r.total_cpu_us,
+ r.total_duration_us,
+ r.total_reads,
+ r.total_writes,
+ r.total_physical_reads,
+ r.total_memory_pages,
+ r.total_executions,
+ r.last_execution_time,
+ CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM #ranked_light r
+JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id
+JOIN sys.query_store_plan p ON r.plan_id = p.plan_id;
+
/* Return intermediate rows (result set 2) */
SELECT * FROM #qhash_rows ORDER BY module_name, total_executions DESC;
";
From be88ec8f636157e8abf138ef7e0eb10d08181fe6 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 12 Apr 2026 17:04:38 +0200
Subject: [PATCH 7/8] =?UTF-8?q?In=20BuildGroupedRows,=20after=20building?=
=?UTF-8?q?=20the=20leaf=20children=20for=20each=20intermediate=20row,=20t?=
=?UTF-8?q?he=20code=20now=20picks=20the=20QueryText=20from=20the=20top=20?=
=?UTF-8?q?representative=20leaf=20row=20(the=20one=20with=20IsTopRepresen?=
=?UTF-8?q?tative=20=3D=3D=20true,=20falling=20back=20to=20the=20first=20a?=
=?UTF-8?q?vailable=20leaf)=20and=20assigns=20it=20to=20the=20parent's=20Q?=
=?UTF-8?q?ueryStorePlan.QueryText.=20This=20is=20done=20at=20two=20levels?=
=?UTF-8?q?:=201.=20Intermediate=20rows=20(PlanHash=20in=20QueryHash=20mod?=
=?UTF-8?q?e,=20QueryHash=20in=20Module=20mode):=20Gets=20the=20query=20te?=
=?UTF-8?q?xt=20from=20the=20top=20representative=20leaf=20within=20that?=
=?UTF-8?q?=20specific=20group.=202.=20Root=20rows=20(QueryHash=20level,?=
=?UTF-8?q?=20Module=20level):=20Gets=20the=20query=20text=20from=20the=20?=
=?UTF-8?q?top=20representative=20leaf=20across=20all=20leaves=20in=20that?=
=?UTF-8?q?=20root=20group.=20No=20additional=20database=20queries=20are?=
=?UTF-8?q?=20made=20=E2=80=94=20the=20text=20comes=20entirely=20from=20gr?=
=?UTF-8?q?ouped.LeafRows=20which=20was=20already=20fetched.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/QueryStoreGridControl.axaml.cs | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 6f45fb1..74ec6cf 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -473,6 +473,10 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this plan hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
}
@@ -481,6 +485,13 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
// Aggregate metrics at QueryHash level
var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? "");
+ // Populate QueryText from the top representative leaf across all leaves in this query hash group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
}
}
@@ -516,6 +527,10 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this query hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
}
@@ -524,6 +539,13 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
// Aggregate metrics at Module level
var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey);
+ // Populate QueryText from the top representative leaf across all leaves in this module group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
}
}
From 44a5ed8e38c0a9b669b2435703aa2d5103440c2e Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 13 Apr 2026 23:12:17 +0200
Subject: [PATCH 8/8] =?UTF-8?q?1.=20FetchPlansForRangeAsync=20=E2=80=94=20?=
=?UTF-8?q?Adds=20GridEmptyMessage.IsVisible=20=3D=20false=20alongside=20G?=
=?UTF-8?q?ridLoadingOverlay.IsVisible=20=3D=20true=20at=20the=20start=20o?=
=?UTF-8?q?f=20every=20fetch,=20so=20any=20lingering=20empty=20message=20i?=
=?UTF-8?q?s=20dismissed=20before=20the=20next=20request.=202.=20FetchGrou?=
=?UTF-8?q?pedPlansAsync=20=E2=80=94=20When=20IntermediateRows.Count=20=3D?=
=?UTF-8?q?=3D=200=20and=20the=20mode=20is=20Module:=20sets=20the=20messag?=
=?UTF-8?q?e=20text=20to=20"No=20module=20found=20in=20the=20selected=20pe?=
=?UTF-8?q?riod",=20shows=20the=20overlay,=20and=20returns=20=E2=80=94=20w?=
=?UTF-8?q?ithout=20writing=20to=20StatusText.=20For=20QueryHash=20mode=20?=
=?UTF-8?q?(or=20any=20other=20non-Module=20grouped=20mode)=20that=20still?=
=?UTF-8?q?=20hits=20the=20empty=20case,=20the=20original=20status=20bar?=
=?UTF-8?q?=20message=20is=20preserved.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/QueryStoreGridControl.axaml | 10 ++++++++++
.../Controls/QueryStoreGridControl.axaml.cs | 12 +++++++++++-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 792b910..20c2f54 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -363,6 +363,16 @@
Foreground="{DynamicResource ForegroundBrush}"/>
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 74ec6cf..71c779a 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -187,6 +187,7 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
StatusText.Text = "Fetching plans...";
GridLoadingOverlay.IsVisible = true;
GridLoadingText.Text = "Fetching plans...";
+ GridEmptyMessage.IsVisible = false;
_rows.Clear();
_filteredRows.Clear();
_groupedRootRows.Clear();
@@ -266,10 +267,19 @@ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
}
GridLoadingOverlay.IsVisible = false;
+ GridEmptyMessage.IsVisible = false;
if (grouped.IntermediateRows.Count == 0)
{
- StatusText.Text = "No Query Store data found for the selected range.";
+ if (_groupByMode == QueryStoreGroupBy.Module)
+ {
+ GridEmptyMessageText.Text = "No module found in the selected period";
+ GridEmptyMessage.IsVisible = true;
+ }
+ else
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ }
return;
}