From 0c19ef831496c7d5854147f6d7a4dab357d930a5 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 03:28:36 +0200
Subject: [PATCH 1/9] - Add FetchHistoryByHashAsync method to QueryStoreService
- Update QueryStoreHistoryWindow constructor to accept query hash, metric
tag, and time range parameters - Update QueryStoreHistoryWindow.axaml.cs
LoadHistoryAsync to call new hash-based service method - Update
QueryStoreHistoryWindow.axaml UI layout - Add average line to chart in
UpdateChart method - Update QueryStoreGridControl ViewHistory_Click to pass
queryHash, metric, and time range
---
.../Controls/QueryStoreGridControl.axaml.cs | 11 +-
.../Dialogs/QueryStoreHistoryWindow.axaml | 52 ++++---
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 137 ++++++++++++++++--
.../Services/QueryStoreService.cs | 113 +++++++++++++++
4 files changed, 278 insertions(+), 35 deletions(-)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index a192af6..08f3ee1 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -587,12 +587,19 @@ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
{
if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+ if (string.IsNullOrEmpty(row.QueryHash)) return;
+
+ var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
var window = new QueryStoreHistoryWindow(
_connectionString,
- row.QueryId,
+ row.QueryHash,
row.FullQueryText,
- _database);
+ _database,
+ initialMetricTag: metricTag,
+ slicerStartUtc: _slicerStartUtc,
+ slicerEndUtc: _slicerEndUtc,
+ slicerDaysBack: _slicerDaysBack);
var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 08e58fb..8d541c6 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -9,19 +9,37 @@
Icon="avares://PlanViewer.App/EDD.ico"
Background="{DynamicResource BackgroundBrush}">
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -45,12 +63,10 @@
-
-
-
+
+
-
-
+
-
-
+
-
-
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index f3b51af..71b654b 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -17,8 +17,13 @@ namespace PlanViewer.App.Dialogs;
public partial class QueryStoreHistoryWindow : Window
{
private readonly string _connectionString;
- private readonly long _queryId;
+ private readonly string _queryHash;
private readonly string _database;
+ private readonly string _queryText;
+ private readonly DateTime? _slicerStartUtc;
+ private readonly DateTime? _slicerEndUtc;
+ private readonly int _maxHoursBack;
+ private bool _useFullHistory;
private CancellationTokenSource? _fetchCts;
private List _historyData = new();
private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
@@ -39,21 +44,56 @@ public partial class QueryStoreHistoryWindow : Window
ScottPlot.Color.FromHex("#A1887F"),
};
- public QueryStoreHistoryWindow(string connectionString, long queryId,
- string queryText, string database, int hoursBack = 24)
+ // Map grid orderBy tags to history metric tags
+ private static readonly Dictionary OrderByToMetricTag = new()
+ {
+ ["cpu"] = "TotalCpuMs",
+ ["avg-cpu"] = "AvgCpuMs",
+ ["duration"] = "TotalDurationMs",
+ ["avg-duration"] = "AvgDurationMs",
+ ["reads"] = "TotalLogicalReads",
+ ["avg-reads"] = "AvgLogicalReads",
+ ["writes"] = "TotalLogicalWrites",
+ ["avg-writes"] = "AvgLogicalWrites",
+ ["physical-reads"] = "TotalPhysicalReads",
+ ["avg-physical-reads"] = "AvgPhysicalReads",
+ ["memory"] = "TotalCpuMs", // no total memory metric in history, fallback
+ ["avg-memory"] = "AvgMemoryMb",
+ ["executions"] = "CountExecutions",
+ };
+
+ public QueryStoreHistoryWindow(string connectionString, string queryHash,
+ string queryText, string database,
+ string initialMetricTag = "AvgCpuMs",
+ DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
+ int slicerDaysBack = 30)
{
_connectionString = connectionString;
- _queryId = queryId;
+ _queryHash = queryHash;
_database = database;
+ _queryText = queryText;
+ _slicerStartUtc = slicerStartUtc;
+ _slicerEndUtc = slicerEndUtc;
+ _maxHoursBack = slicerDaysBack * 24;
InitializeComponent();
- HoursBackBox.Value = hoursBack;
+ QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
+ QueryTextBox.Text = queryText;
- var preview = queryText.Length > 120
- ? queryText[..120].Replace("\n", " ").Replace("\r", "") + "..."
- : queryText.Replace("\n", " ").Replace("\r", "");
- QueryIdentifierText.Text = $"Query Store History: Query {queryId} in [{database}]";
- SummaryText.Text = preview;
+ // Select initial metric in the combo box
+ var metricTag = initialMetricTag;
+ foreach (ComboBoxItem item in MetricSelector.Items)
+ {
+ if (item.Tag?.ToString() == metricTag)
+ {
+ MetricSelector.SelectedItem = item;
+ break;
+ }
+ }
+
+ // Default to range period mode when slicer range is available
+ _useFullHistory = !(_slicerStartUtc.HasValue && _slicerEndUtc.HasValue);
+ UpdateRangeToggleButton();
// Build hover tooltip
_tooltipText = new TextBlock
@@ -85,6 +125,16 @@ public QueryStoreHistoryWindow(string connectionString, long queryId,
Opened += async (_, _) => await LoadHistoryAsync();
}
+ ///
+ /// Maps a grid orderBy tag (e.g. "cpu", "avg-duration") to the history metric tag.
+ ///
+ public static string MapOrderByToMetricTag(string orderBy)
+ {
+ return OrderByToMetricTag.TryGetValue(orderBy?.ToLowerInvariant() ?? "", out var tag)
+ ? tag
+ : "AvgCpuMs";
+ }
+
private async System.Threading.Tasks.Task LoadHistoryAsync()
{
_fetchCts?.Cancel();
@@ -92,14 +142,27 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;
- var hoursBack = (int)(HoursBackBox.Value ?? 24);
RefreshButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
- _historyData = await QueryStoreService.FetchHistoryAsync(
- _connectionString, _queryId, hoursBack, ct);
+ if (_useFullHistory)
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
+ else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, ct: ct,
+ startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value);
+ }
+ else
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
HistoryDataGrid.ItemsSource = _historyData;
@@ -171,6 +234,23 @@ private void UpdateChart()
colorIndex++;
}
+ // Add average line
+ var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
+ if (allValues.Length > 0)
+ {
+ var avg = allValues.Average();
+ var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F");
+ hLine.LineWidth = 1.5f;
+ hLine.LinePattern = LinePattern.Dashed;
+ hLine.LegendText = $"avg: {avg:N2} {label}";
+ }
+
+ // Show legend when multiple plans exist
+ HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
+ ? Alignment.UpperRight
+ : Alignment.UpperRight);
+
HistoryChart.Plot.Axes.DateTimeTicksBottom();
HistoryChart.Plot.YLabel(label);
ApplyDarkTheme();
@@ -195,7 +275,7 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
string bestLabel = "";
bool found = false;
- foreach (var (scatter, label) in _scatters)
+ foreach (var (scatter, chartLabel) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
if (!nearest.IsReal) continue;
@@ -209,7 +289,7 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
bestDist = dy;
bestPoint = nearest;
- bestLabel = label;
+ bestLabel = chartLabel;
found = true;
}
}
@@ -268,6 +348,25 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
+ private void UpdateRangeToggleButton()
+ {
+ if (_useFullHistory)
+ {
+ RangeToggleButton.Content = "Full History";
+ }
+ else
+ {
+ RangeToggleButton.Content = "Range Period";
+ }
+ }
+
+ private async void RangeToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _useFullHistory = !_useFullHistory;
+ UpdateRangeToggleButton();
+ await LoadHistoryAsync();
+ }
+
private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (IsVisible && _historyData.Count > 0)
@@ -279,5 +378,13 @@ private async void Refresh_Click(object? sender, RoutedEventArgs e)
await LoadHistoryAsync();
}
+ private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_queryText)) return;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard != null)
+ await clipboard.SetTextAsync(_queryText);
+ }
+
private void Close_Click(object? sender, RoutedEventArgs e) => Close();
}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 7692981..e133725 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -432,6 +432,119 @@ JOIN sys.query_store_plan p
return rows;
}
+ ///
+ /// Fetches interval-level history rows for all queries sharing the given query_hash.
+ /// When / are provided they define the
+ /// time window (slicer range); otherwise falls back to .
+ ///
+ public static async Task> FetchHistoryByHashAsync(
+ string connectionString, string queryHash, int hoursBack = 24,
+ CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var parameters = new List();
+ parameters.Add(new SqlParameter("@queryHash", queryHash.Trim()));
+
+ string timeFilter;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ timeFilter = "AND rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ timeFilter = "AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE())";
+ parameters.Add(new SqlParameter("@hoursBack", hoursBack));
+ }
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ p.plan_id,
+ CONVERT(varchar(18), MAX(p.query_plan_hash), 1),
+ rsi.start_time,
+ SUM(rs.count_executions),
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ SUM(rs.avg_duration * rs.count_executions) / 1000.0,
+ SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0,
+ 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),
+ MIN(rs.min_dop),
+ MAX(rs.max_dop),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats rs
+JOIN sys.query_store_runtime_stats_interval rsi
+ ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
+JOIN sys.query_store_plan p
+ ON rs.plan_id = p.plan_id
+JOIN sys.query_store_query q
+ ON p.query_id = q.query_id
+WHERE q.query_hash = CONVERT(binary(8), @queryHash, 1)
+{timeFilter}
+GROUP BY p.plan_id, rsi.start_time
+ORDER BY rsi.start_time, p.plan_id;";
+
+ var rows = new List();
+
+ 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);
+
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new QueryStoreHistoryRow
+ {
+ PlanId = reader.GetInt64(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ IntervalStartUtc = ((DateTimeOffset)reader.GetValue(2)).UtcDateTime,
+ CountExecutions = reader.GetInt64(3),
+ AvgDurationMs = reader.GetDouble(4),
+ AvgCpuMs = reader.GetDouble(5),
+ AvgLogicalReads = reader.GetDouble(6),
+ AvgLogicalWrites = reader.GetDouble(7),
+ AvgPhysicalReads = reader.GetDouble(8),
+ AvgMemoryMb = reader.GetDouble(9),
+ AvgRowcount = reader.GetDouble(10),
+ TotalDurationMs = reader.GetDouble(11),
+ TotalCpuMs = reader.GetDouble(12),
+ TotalLogicalReads = reader.GetDouble(13),
+ TotalLogicalWrites = reader.GetDouble(14),
+ TotalPhysicalReads = reader.GetDouble(15),
+ MinDop = (int)reader.GetInt64(16),
+ MaxDop = (int)reader.GetInt64(17),
+ LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime,
+ });
+ }
+
+ return rows;
+ }
+
///
/// Fetches hourly-aggregated metric data for the time-range slicer.
/// Limits data to the last days (default 30).
From fd9c6f7f36b31b2b96aa2f6add83f7f591824d04 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:03:00 +0200
Subject: [PATCH 2/9] =?UTF-8?q?Query=20History=20improvment=20step=202=20:?=
=?UTF-8?q?=20=E2=80=A2=20Generate=20a=20new=20query=20to=20get=20query=20?=
=?UTF-8?q?history=20data=20Group=20data=20by=20PlanHash=20and=20IntervalS?=
=?UTF-8?q?tart.=20Get=20inspired=20by=20the=20existing=20FetchHistoryAsyn?=
=?UTF-8?q?c=20but=20create=20a=20new=20FetchAgregateHistoryAsync.=20Use?=
=?UTF-8?q?=20smart=20agregation=20for=20other=20fields=20=20=E2=80=A2=20s?=
=?UTF-8?q?um(field)=20for=20Totals%=20fields=20and=20Executions=20=20?=
=?UTF-8?q?=E2=80=A2=20avg(field)=20for=20Avg%=20fields=20=20=E2=80=A2=20m?=
=?UTF-8?q?ax(last=5Fexecution)=20=E2=80=A2=20Allow=20the=20use=20to=20sel?=
=?UTF-8?q?ect=20several=20dots=20in=20the=20charts=20using=20a=20box=20se?=
=?UTF-8?q?lector=20=E2=80=A2=20When=20the=20user=20finish=20the=20selecti?=
=?UTF-8?q?on=20with=20the=20box=20(or=20a=20single=20dot=20selection)=20h?=
=?UTF-8?q?ightligh=20the=20corresponding=20rows=20in=20the=20query=20hist?=
=?UTF-8?q?ory=20grid=20=E2=80=A2=20Add=20a=20thin=20color=20column=20in?=
=?UTF-8?q?=20the=20query=20history=20grid=20like=20the=20legend=20in=20ch?=
=?UTF-8?q?art=20=E2=80=A2=20add=20a=20small=20light=20grey=20border=20for?=
=?UTF-8?q?=20dots=20=E2=80=A2=20the=20label=20of=20avg=20in=20the=20chart?=
=?UTF-8?q?=20should=20only=20have=20"avg:".=20Dont=20insert=20the?=
=?UTF-8?q?=20metric=20inside=20this=20label.=20=E2=80=A2=20The=20avg=20la?=
=?UTF-8?q?bel=20should=20have=20a=20light=20grey=20transparent=20backgrou?=
=?UTF-8?q?nd=20and=20be=20just=20above=20the=20avg=20horizontal=20line=20?=
=?UTF-8?q?=E2=80=A2=20make=20the=20avg=20horizontal=20line=20a=20little?=
=?UTF-8?q?=20more=20transparent=20=E2=80=A2=20in=20the=20chart=20the=20Y-?=
=?UTF-8?q?axis=20should=20always=20include=200=20as=20origin=20=E2=80=A2?=
=?UTF-8?q?=20make=202=20buttons=20"Range=20Period"=20and=20"Full=20Histor?=
=?UTF-8?q?y"=20and=20highlight=20the=20selected=20and=20current=20one=20?=
=?UTF-8?q?=E2=80=A2=20remove=20the=20refresh=20button=20=E2=80=A2=20align?=
=?UTF-8?q?=20the=20informatial=20text=20(number=20of=20interval,=20number?=
=?UTF-8?q?=20of=20plans...|=20period)=20to=20the=20right=20=E2=80=A2=20re?=
=?UTF-8?q?move=20the=20word=20metric=20on=20top=20of=20the=20metric=20sel?=
=?UTF-8?q?ector=20=E2=80=A2=20make=20the=20X-axis=20label=20more=20smart?=
=?UTF-8?q?=20:=20hours=20first=20(top)=20and=20the=20date=20and=20"agrega?=
=?UTF-8?q?te=20date".=20Still=20use=20an=20adaptative=20X-axis=20display?=
=?UTF-8?q?=20if=20the=20interval=20is=20large?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 38 +-
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 431 ++++++++++++++++--
.../Services/QueryStoreService.cs | 113 +++++
3 files changed, 528 insertions(+), 54 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 8d541c6..1df1d37 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -42,11 +42,10 @@
-
-
-
+
+
@@ -62,20 +61,19 @@
+
+
+
+
-
-
-
-
-
-
-
-
+
@@ -96,7 +94,13 @@
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
-
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 71b654b..6dfb472 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -1,4 +1,5 @@
using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -8,6 +9,7 @@
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
+using Avalonia.VisualTree;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
using ScottPlot;
@@ -26,12 +28,26 @@ public partial class QueryStoreHistoryWindow : Window
private bool _useFullHistory;
private CancellationTokenSource? _fetchCts;
private List _historyData = new();
- private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label, string PlanHash)> _scatters = new();
// Hover tooltip
private readonly Popup _tooltip;
private readonly TextBlock _tooltipText;
+ // Box selection state
+ private bool _isDragging;
+ private Point _dragStartPoint;
+ private ScottPlot.Plottables.Rectangle? _selectionRect;
+ private readonly HashSet _selectedRowIndices = new();
+
+ // Color mapping: plan hash -> color
+ private readonly Dictionary _planHashColorMap = new();
+
+ // Active button highlight brush
+ private static readonly SolidColorBrush ActiveButtonBg = new(Avalonia.Media.Color.FromRgb(0x4F, 0xC3, 0xF7));
+ private static readonly SolidColorBrush ActiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x11, 0x12, 0x17));
+ private static readonly SolidColorBrush InactiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x9D, 0xA5, 0xB4));
+
private static readonly ScottPlot.Color[] PlanColors =
{
ScottPlot.Color.FromHex("#4FC3F7"),
@@ -57,7 +73,7 @@ public partial class QueryStoreHistoryWindow : Window
["avg-writes"] = "AvgLogicalWrites",
["physical-reads"] = "TotalPhysicalReads",
["avg-physical-reads"] = "AvgPhysicalReads",
- ["memory"] = "TotalCpuMs", // no total memory metric in history, fallback
+ ["memory"] = "TotalCpuMs",
["avg-memory"] = "AvgMemoryMb",
["executions"] = "CountExecutions",
};
@@ -93,7 +109,7 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
// Default to range period mode when slicer range is available
_useFullHistory = !(_slicerStartUtc.HasValue && _slicerEndUtc.HasValue);
- UpdateRangeToggleButton();
+ UpdateRangeButtons();
// Build hover tooltip
_tooltipText = new TextBlock
@@ -121,6 +137,11 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
HistoryChart.PointerMoved += OnChartPointerMoved;
HistoryChart.PointerExited += (_, _) => _tooltip.IsOpen = false;
+ HistoryChart.PointerPressed += OnChartPointerPressed;
+ HistoryChart.PointerReleased += OnChartPointerReleased;
+
+ // Disable ScottPlot's built-in left-click-drag pan so our box selection works
+ HistoryChart.UserInputProcessor.LeftClickDragPan(enable: false);
Opened += async (_, _) => await LoadHistoryAsync();
}
@@ -142,33 +163,34 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;
- RefreshButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
if (_useFullHistory)
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, _maxHoursBack, ct);
}
else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, ct: ct,
startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value);
}
else
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, _maxHoursBack, ct);
}
+ BuildColorMap();
HistoryDataGrid.ItemsSource = _historyData;
+ ApplyColorIndicators();
if (_historyData.Count > 0)
{
- var planCount = _historyData.Select(r => r.PlanId).Distinct().Count();
+ var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count();
var totalExec = _historyData.Sum(r => r.CountExecutions);
var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc));
var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc));
@@ -191,16 +213,79 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
}
- finally
+ }
+
+ private void BuildColorMap()
+ {
+ _planHashColorMap.Clear();
+ var hashes = _historyData.Select(r => r.QueryPlanHash).Distinct().OrderBy(h => h).ToList();
+ for (int i = 0; i < hashes.Count; i++)
+ _planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length];
+ }
+
+ private void ApplyColorIndicators()
+ {
+ HistoryDataGrid.LoadingRow -= OnDataGridLoadingRow;
+ HistoryDataGrid.LoadingRow += OnDataGridLoadingRow;
+ }
+
+ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ if (e.Row.DataContext is QueryStoreHistoryRow row &&
+ _planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
- RefreshButton.IsEnabled = true;
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ // Find the ColorIndicator border in the first cell
+ e.Row.Tag = new SolidColorBrush(avColor);
}
+
+ // Defer color application after visual tree is built
+ e.Row.Loaded -= OnRowLoaded;
+ e.Row.Loaded += OnRowLoaded;
+ }
+
+ private void OnRowLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not DataGridRow dgRow) return;
+ dgRow.Loaded -= OnRowLoaded;
+
+ if (dgRow.Tag is not SolidColorBrush brush) return;
+
+ // Walk the visual tree to find the Border named ColorIndicator
+ var presenter = FindVisualChild(dgRow);
+ if (presenter == null) return;
+
+ // First column cell (index 0) contains our color indicator
+ var cell = presenter.Children.OfType().FirstOrDefault();
+ if (cell == null) return;
+
+ var border = FindVisualChild(cell, "ColorIndicator");
+ if (border != null)
+ border.Background = brush;
+ }
+
+ private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual
+ {
+ if (parent is T t && (name == null || (t is Control c && c.Name == name)))
+ return t;
+
+ var children = parent.GetVisualChildren();
+ foreach (var child in children)
+ {
+ if (child is Avalonia.Visual vc)
+ {
+ var found = FindVisualChild(vc, name);
+ if (found != null) return found;
+ }
+ }
+ return null;
}
private void UpdateChart()
{
HistoryChart.Plot.Clear();
_scatters.Clear();
+ _selectionRect = null;
if (_historyData.Count == 0)
{
@@ -213,54 +298,316 @@ private void UpdateChart()
var label = selected?.Content?.ToString() ?? "Avg CPU (ms)";
var planGroups = _historyData
- .GroupBy(r => r.PlanId)
+ .GroupBy(r => r.QueryPlanHash)
.OrderBy(g => g.Key)
.ToList();
- int colorIndex = 0;
foreach (var group in planGroups)
{
+ var planHash = group.Key;
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+
var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList();
var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
- scatter.Color = PlanColors[colorIndex % PlanColors.Length];
- scatter.LegendText = $"Plan {group.Key}";
+ scatter.Color = color;
+ scatter.LegendText = planHash.Length > 10 ? planHash[..10] : planHash;
scatter.LineWidth = 2;
- scatter.MarkerSize = ordered.Count <= 2 ? 8 : 4;
+ scatter.MarkerSize = ordered.Count <= 2 ? 8 : 5;
+ scatter.MarkerLineColor = ScottPlot.Color.FromHex("#888888");
+ scatter.MarkerLineWidth = 0.5f;
- _scatters.Add((scatter, $"Plan {group.Key}"));
- colorIndex++;
+ _scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash));
}
- // Add average line
+ // Add average line with built-in label
var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
if (allValues.Length > 0)
{
var avg = allValues.Average();
var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F");
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(80);
hLine.LineWidth = 1.5f;
hLine.LinePattern = LinePattern.Dashed;
- hLine.LegendText = $"avg: {avg:N2} {label}";
+ hLine.LegendText = $"avg:{avg:N2}";
+ hLine.Text = $"avg:{avg:N2}";
+ hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
+ hLine.LabelFontSize = 11;
+ hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#22252b").WithAlpha(180);
+ hLine.LabelOppositeAxis = true;
}
+ // Y-axis always includes 0 as origin
+ HistoryChart.Plot.Axes.AutoScale();
+ var yLimits = HistoryChart.Plot.Axes.GetLimits();
+ HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1);
+
// Show legend when multiple plans exist
HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
? Alignment.UpperRight
: Alignment.UpperRight);
- HistoryChart.Plot.Axes.DateTimeTicksBottom();
+ // Smart X-axis labels
+ ConfigureSmartXAxis();
+
HistoryChart.Plot.YLabel(label);
ApplyDarkTheme();
HistoryChart.Refresh();
}
+ private void ConfigureSmartXAxis()
+ {
+ if (_historyData.Count == 0) return;
+
+ var minTime = _historyData.Min(r => r.IntervalStartUtc);
+ var maxTime = _historyData.Max(r => r.IntervalStartUtc);
+ var span = maxTime - minTime;
+
+ // Use ScottPlot's DateTime tick generator with smart formatting
+ HistoryChart.Plot.Axes.DateTimeTicksBottom();
+
+ // Customize tick label format based on span
+ if (span.TotalHours <= 48)
+ {
+ // Short range: show HH:mm on top line, MM/dd below
+ HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#9DA5B4");
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else if (span.TotalDays <= 14)
+ {
+ // Medium range: show HH:mm on top, MM/dd below
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else
+ {
+ // Large range: show MM/dd on top, yyyy below
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("MM/dd\nyyyy")
+ };
+ }
+ }
+
+ // ── Box selection ────────────────────────────────────────────────────
+
+ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(HistoryChart).Properties.IsLeftButtonPressed) return;
+
+ _isDragging = true;
+ _dragStartPoint = e.GetPosition(HistoryChart);
+
+ // Remove old selection rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isDragging) return;
+ _isDragging = false;
+
+ var endPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var endCoords = PixelToCoordinates(endPoint);
+
+ // Determine if this was a click (small drag) or a box selection
+ var dx = Math.Abs(endPoint.X - _dragStartPoint.X);
+ var dy = Math.Abs(endPoint.Y - _dragStartPoint.Y);
+
+ if (dx < 5 && dy < 5)
+ {
+ // Single click: find nearest dot and select it
+ HandleSingleClickSelection(endPoint);
+ }
+ else
+ {
+ // Box selection
+ HandleBoxSelection(startCoords, endCoords);
+ }
+
+ e.Handled = true;
+ }
+
+ private ScottPlot.Coordinates PixelToCoordinates(Point pos)
+ {
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(pos.X * scaling), (float)(pos.Y * scaling));
+ return HistoryChart.Plot.GetCoordinates(pixel);
+ }
+
+ private void HandleSingleClickSelection(Point clickPoint)
+ {
+ if (_scatters.Count == 0) return;
+
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(clickPoint.X * scaling), (float)(clickPoint.Y * scaling));
+ var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel);
+
+ double bestDist = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestPlanHash = "";
+ bool found = false;
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+
+ var nearestPixel = HistoryChart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ var d = Math.Sqrt(Math.Pow(nearestPixel.X - pixel.X, 2) + Math.Pow(nearestPixel.Y - pixel.Y, 2));
+
+ if (d < 30 && d < bestDist)
+ {
+ bestDist = d;
+ bestPoint = nearest;
+ bestPlanHash = planHash;
+ found = true;
+ }
+ }
+
+ _selectedRowIndices.Clear();
+
+ if (found)
+ {
+ var clickedTime = DateTime.FromOADate(bestPoint.X);
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc);
+ if (row.QueryPlanHash == bestPlanHash &&
+ Math.Abs((displayTime - clickedTime).TotalMinutes) < 1)
+ {
+ _selectedRowIndices.Add(i);
+ }
+ }
+ }
+
+ HighlightGridRows();
+ }
+
+ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinates end)
+ {
+ var x1 = Math.Min(start.X, end.X);
+ var x2 = Math.Max(start.X, end.X);
+ var y1 = Math.Min(start.Y, end.Y);
+ var y2 = Math.Max(start.Y, end.Y);
+
+ // Draw selection rectangle on chart
+ if (_selectionRect != null)
+ HistoryChart.Plot.Remove(_selectionRect);
+
+ _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
+ _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
+ _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
+ _selectionRect.LineWidth = 1;
+ HistoryChart.Refresh();
+
+ // Find all data points inside the box
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+ _selectedRowIndices.Clear();
+
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate();
+ var yVal = GetMetricValue(row, tag);
+
+ if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2)
+ _selectedRowIndices.Add(i);
+ }
+
+ HighlightGridRows();
+ }
+
+ private void HighlightGridRows()
+ {
+ // Clear all row highlighting first, then apply selection
+ var highlightBrush = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
+ var transparentBrush = Brushes.Transparent;
+
+ // Scroll to first selected row if any
+ if (_selectedRowIndices.Count > 0)
+ {
+ var firstIdx = _selectedRowIndices.Min();
+ if (firstIdx < _historyData.Count)
+ HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null);
+ }
+
+ // Use LoadingRow event + force refresh to highlight
+ HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow;
+ HistoryDataGrid.LoadingRow += OnHighlightLoadingRow;
+
+ // Force grid to re-render rows
+ var source = _historyData;
+ HistoryDataGrid.ItemsSource = null;
+ HistoryDataGrid.ItemsSource = source;
+ }
+
+ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ var idx = e.Row.GetIndex();
+ if (_selectedRowIndices.Contains(idx))
+ {
+ e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
+ }
+ else if (_selectedRowIndices.Count > 0)
+ {
+ e.Row.Background = Brushes.Transparent;
+ }
+ else
+ {
+ e.Row.Background = Brushes.Transparent;
+ }
+ }
+
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
if (_scatters.Count == 0) { _tooltip.IsOpen = false; return; }
+ // If dragging, update selection rectangle preview
+ if (_isDragging)
+ {
+ var currentPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var currentCoords = PixelToCoordinates(currentPoint);
+
+ if (_selectionRect != null)
+ HistoryChart.Plot.Remove(_selectionRect);
+
+ var x1 = Math.Min(startCoords.X, currentCoords.X);
+ var x2 = Math.Max(startCoords.X, currentCoords.X);
+ var y1 = Math.Min(startCoords.Y, currentCoords.Y);
+ var y2 = Math.Max(startCoords.Y, currentCoords.Y);
+
+ _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
+ _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
+ _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
+ _selectionRect.LineWidth = 1;
+ HistoryChart.Refresh();
+
+ _tooltip.IsOpen = false;
+ return;
+ }
+
try
{
var pos = e.GetPosition(HistoryChart);
@@ -275,19 +622,19 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
string bestLabel = "";
bool found = false;
- foreach (var (scatter, chartLabel) in _scatters)
+ foreach (var (scatter, chartLabel, _) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
if (!nearest.IsReal) continue;
var nearestPixel = HistoryChart.Plot.GetPixel(
new ScottPlot.Coordinates(nearest.X, nearest.Y));
- double dx = Math.Abs(nearestPixel.X - pixel.X);
- double dy = Math.Abs(nearestPixel.Y - pixel.Y);
+ double ddx = Math.Abs(nearestPixel.X - pixel.X);
+ double ddy = Math.Abs(nearestPixel.Y - pixel.Y);
- if (dx < 80 && dy < bestDist)
+ if (ddx < 80 && ddy < bestDist)
{
- bestDist = dy;
+ bestDist = ddy;
bestPoint = nearest;
bestLabel = chartLabel;
found = true;
@@ -348,34 +695,44 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
- private void UpdateRangeToggleButton()
+ private void UpdateRangeButtons()
{
if (_useFullHistory)
{
- RangeToggleButton.Content = "Full History";
+ FullHistoryButton.Background = ActiveButtonBg;
+ FullHistoryButton.Foreground = ActiveButtonFg;
+ RangePeriodButton.Background = Brushes.Transparent;
+ RangePeriodButton.Foreground = InactiveButtonFg;
}
else
{
- RangeToggleButton.Content = "Range Period";
+ RangePeriodButton.Background = ActiveButtonBg;
+ RangePeriodButton.Foreground = ActiveButtonFg;
+ FullHistoryButton.Background = Brushes.Transparent;
+ FullHistoryButton.Foreground = InactiveButtonFg;
}
}
- private async void RangeToggle_Click(object? sender, RoutedEventArgs e)
+ private async void RangePeriod_Click(object? sender, RoutedEventArgs e)
{
- _useFullHistory = !_useFullHistory;
- UpdateRangeToggleButton();
+ if (!_useFullHistory) return;
+ _useFullHistory = false;
+ UpdateRangeButtons();
await LoadHistoryAsync();
}
- private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ private async void FullHistory_Click(object? sender, RoutedEventArgs e)
{
- if (IsVisible && _historyData.Count > 0)
- UpdateChart();
+ if (_useFullHistory) return;
+ _useFullHistory = true;
+ UpdateRangeButtons();
+ await LoadHistoryAsync();
}
- private async void Refresh_Click(object? sender, RoutedEventArgs e)
+ private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
- await LoadHistoryAsync();
+ if (IsVisible && _historyData.Count > 0)
+ UpdateChart();
}
private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index e133725..42ed986 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -545,6 +545,119 @@ JOIN sys.query_store_query q
return rows;
}
+ ///
+ /// Fetches interval-level history rows for all queries sharing the given query_hash,
+ /// grouped by query_plan_hash and interval start.
+ /// Smart aggregation: SUM for totals/executions, weighted AVG for averages, MAX for last_execution.
+ /// When / are provided they define the
+ /// time window; otherwise falls back to .
+ ///
+ public static async Task> FetchAggregateHistoryAsync(
+ string connectionString, string queryHash, int hoursBack = 24,
+ CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var parameters = new List();
+ parameters.Add(new SqlParameter("@queryHash", queryHash.Trim()));
+
+ string timeFilter;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ timeFilter = "AND rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ timeFilter = "AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE())";
+ parameters.Add(new SqlParameter("@hoursBack", hoursBack));
+ }
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ CONVERT(varchar(18), p.query_plan_hash, 1),
+ rsi.start_time,
+ SUM(rs.count_executions),
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ SUM(rs.avg_duration * rs.count_executions) / 1000.0,
+ SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0,
+ 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),
+ MIN(rs.min_dop),
+ MAX(rs.max_dop),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats rs
+JOIN sys.query_store_runtime_stats_interval rsi
+ ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
+JOIN sys.query_store_plan p
+ ON rs.plan_id = p.plan_id
+JOIN sys.query_store_query q
+ ON p.query_id = q.query_id
+WHERE q.query_hash = CONVERT(binary(8), @queryHash, 1)
+{timeFilter}
+GROUP BY p.query_plan_hash, rsi.start_time
+ORDER BY rsi.start_time, p.query_plan_hash;";
+
+ var rows = new List();
+
+ 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);
+
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new QueryStoreHistoryRow
+ {
+ QueryPlanHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ IntervalStartUtc = ((DateTimeOffset)reader.GetValue(1)).UtcDateTime,
+ CountExecutions = reader.GetInt64(2),
+ AvgDurationMs = reader.GetDouble(3),
+ AvgCpuMs = reader.GetDouble(4),
+ AvgLogicalReads = reader.GetDouble(5),
+ AvgLogicalWrites = reader.GetDouble(6),
+ AvgPhysicalReads = reader.GetDouble(7),
+ AvgMemoryMb = reader.GetDouble(8),
+ AvgRowcount = reader.GetDouble(9),
+ TotalDurationMs = reader.GetDouble(10),
+ TotalCpuMs = reader.GetDouble(11),
+ TotalLogicalReads = reader.GetDouble(12),
+ TotalLogicalWrites = reader.GetDouble(13),
+ TotalPhysicalReads = reader.GetDouble(14),
+ MinDop = (int)reader.GetInt64(15),
+ MaxDop = (int)reader.GetInt64(16),
+ LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime,
+ });
+ }
+
+ return rows;
+ }
+
///
/// Fetches hourly-aggregated metric data for the time-range slicer.
/// Limits data to the last days (default 30).
From 7ff48fd19f744e5454a22ad5bb7c43fbc5f48457 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:35:56 +0200
Subject: [PATCH 3/9] polish average line and label in query history
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 61 ++++--
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 193 +++++++++++++-----
.../Models/QueryStoreHistoryRow.cs | 14 ++
3 files changed, 201 insertions(+), 67 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 1df1d37..24cc0f3 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -76,12 +76,36 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,18 +129,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 6dfb472..be1924a 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -8,6 +8,7 @@
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using PlanViewer.Core.Models;
@@ -40,9 +41,16 @@ public partial class QueryStoreHistoryWindow : Window
private ScottPlot.Plottables.Rectangle? _selectionRect;
private readonly HashSet _selectedRowIndices = new();
+ // Highlight markers for selected dots
+ private readonly List _highlightMarkers = new();
+
// Color mapping: plan hash -> color
private readonly Dictionary _planHashColorMap = new();
+ // Legend state
+ private bool _legendExpanded;
+ private bool _suppressGridSelectionEvent;
+
// Active button highlight brush
private static readonly SolidColorBrush ActiveButtonBg = new(Avalonia.Media.Color.FromRgb(0x4F, 0xC3, 0xF7));
private static readonly SolidColorBrush ActiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x11, 0x12, 0x17));
@@ -204,6 +212,7 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
}
UpdateChart();
+ PopulateLegendPanel();
}
catch (OperationCanceledException)
{
@@ -235,11 +244,9 @@ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
_planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- // Find the ColorIndicator border in the first cell
e.Row.Tag = new SolidColorBrush(avColor);
}
- // Defer color application after visual tree is built
e.Row.Loaded -= OnRowLoaded;
e.Row.Loaded += OnRowLoaded;
}
@@ -251,11 +258,9 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
if (dgRow.Tag is not SolidColorBrush brush) return;
- // Walk the visual tree to find the Border named ColorIndicator
var presenter = FindVisualChild(dgRow);
if (presenter == null) return;
- // First column cell (index 0) contains our color indicator
var cell = presenter.Children.OfType().FirstOrDefault();
if (cell == null) return;
@@ -281,11 +286,48 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
return null;
}
+ // ── Legend ────────────────────────────────────────────────────────────
+
+ private void PopulateLegendPanel()
+ {
+ LegendItemsPanel.Children.Clear();
+ foreach (var (hash, color) in _planHashColorMap.OrderBy(kv => kv.Key))
+ {
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ var item = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 };
+ item.Children.Add(new Border
+ {
+ Width = 12, Height = 12,
+ CornerRadius = new CornerRadius(2),
+ Background = new SolidColorBrush(avColor),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ });
+ item.Children.Add(new TextBlock
+ {
+ Text = hash,
+ FontSize = 11,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0))
+ });
+ LegendItemsPanel.Children.Add(item);
+ }
+ }
+
+ private void LegendToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _legendExpanded = !_legendExpanded;
+ LegendPanel.IsVisible = _legendExpanded;
+ LegendArrow.Text = _legendExpanded ? "\u25b2" : "\u25bc";
+ }
+
+ // ── Chart ────────────────────────────────────────────────────────────
+
private void UpdateChart()
{
HistoryChart.Plot.Clear();
_scatters.Clear();
_selectionRect = null;
+ _highlightMarkers.Clear();
if (_historyData.Count == 0)
{
@@ -312,31 +354,35 @@ private void UpdateChart()
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
- scatter.Color = color;
- scatter.LegendText = planHash.Length > 10 ? planHash[..10] : planHash;
+ scatter.Color = color.WithAlpha(140);
+ scatter.LegendText = "";
scatter.LineWidth = 2;
- scatter.MarkerSize = ordered.Count <= 2 ? 8 : 5;
- scatter.MarkerLineColor = ScottPlot.Color.FromHex("#888888");
- scatter.MarkerLineWidth = 0.5f;
+ scatter.MarkerSize = 8;
+ scatter.MarkerShape = MarkerShape.FilledCircle;
+ scatter.MarkerLineColor = ScottPlot.Color.FromHex("#AAAAAA");
+ scatter.MarkerLineWidth = 1f;
_scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash));
}
- // Add average line with built-in label
+ // Add average line with label positioned just above the line
var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
if (allValues.Length > 0)
{
var avg = allValues.Average();
var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(80);
- hLine.LineWidth = 1.5f;
- hLine.LinePattern = LinePattern.Dashed;
- hLine.LegendText = $"avg:{avg:N2}";
- hLine.Text = $"avg:{avg:N2}";
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
+ hLine.LineWidth = 2f;
+ hLine.LinePattern = LinePattern.DenselyDashed;
+ hLine.Text = $"avg: {avg:N0}";
hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
hLine.LabelFontSize = 11;
- hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#22252b").WithAlpha(180);
- hLine.LabelOppositeAxis = true;
+ hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ hLine.LabelOppositeAxis = false;
+ hLine.LabelRotation = 0;
+ hLine.LabelAlignment = Alignment.LowerLeft;
+ hLine.LabelOffsetX = 38;
+ hLine.LabelOffsetY = -8;
}
// Y-axis always includes 0 as origin
@@ -344,10 +390,8 @@ private void UpdateChart()
var yLimits = HistoryChart.Plot.Axes.GetLimits();
HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1);
- // Show legend when multiple plans exist
- HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
- ? Alignment.UpperRight
- : Alignment.UpperRight);
+ // Disable ScottPlot's built-in legend — we use our custom overlay
+ HistoryChart.Plot.HideLegend();
// Smart X-axis labels
ConfigureSmartXAxis();
@@ -365,13 +409,10 @@ private void ConfigureSmartXAxis()
var maxTime = _historyData.Max(r => r.IntervalStartUtc);
var span = maxTime - minTime;
- // Use ScottPlot's DateTime tick generator with smart formatting
HistoryChart.Plot.Axes.DateTimeTicksBottom();
- // Customize tick label format based on span
if (span.TotalHours <= 48)
{
- // Short range: show HH:mm on top line, MM/dd below
HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#9DA5B4");
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
@@ -380,7 +421,6 @@ private void ConfigureSmartXAxis()
}
else if (span.TotalDays <= 14)
{
- // Medium range: show HH:mm on top, MM/dd below
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
@@ -388,7 +428,6 @@ private void ConfigureSmartXAxis()
}
else
{
- // Large range: show MM/dd on top, yyyy below
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
LabelFormatter = dt => dt.ToString("MM/dd\nyyyy")
@@ -396,6 +435,52 @@ private void ConfigureSmartXAxis()
}
}
+ // ── Dot highlighting on chart ────────────────────────────────────────
+
+ private void ClearHighlightMarkers()
+ {
+ foreach (var m in _highlightMarkers)
+ HistoryChart.Plot.Remove(m);
+ _highlightMarkers.Clear();
+ }
+
+ private void HighlightDotsOnChart(HashSet rowIndices)
+ {
+ ClearHighlightMarkers();
+ if (rowIndices.Count == 0)
+ {
+ HistoryChart.Refresh();
+ return;
+ }
+
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ // Group selected rows by plan hash for coloring
+ var groups = rowIndices
+ .Where(i => i >= 0 && i < _historyData.Count)
+ .Select(i => _historyData[i])
+ .GroupBy(r => r.QueryPlanHash);
+
+ foreach (var group in groups)
+ {
+ var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]);
+ var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
+ var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
+
+ var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
+ highlight.LineWidth = 0;
+ highlight.MarkerSize = 12;
+ highlight.MarkerShape = MarkerShape.OpenCircle;
+ highlight.MarkerLineColor = ScottPlot.Colors.White;
+ highlight.MarkerLineWidth = 2f;
+ highlight.Color = ScottPlot.Colors.Transparent;
+
+ _highlightMarkers.Add(highlight);
+ }
+
+ HistoryChart.Refresh();
+ }
+
// ── Box selection ────────────────────────────────────────────────────
private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
@@ -420,6 +505,13 @@ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
if (!_isDragging) return;
_isDragging = false;
+ // Remove the drag preview rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
var endPoint = e.GetPosition(HistoryChart);
var startCoords = PixelToCoordinates(_dragStartPoint);
var endCoords = PixelToCoordinates(endPoint);
@@ -430,12 +522,10 @@ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
if (dx < 5 && dy < 5)
{
- // Single click: find nearest dot and select it
HandleSingleClickSelection(endPoint);
}
else
{
- // Box selection
HandleBoxSelection(startCoords, endCoords);
}
@@ -501,6 +591,7 @@ private void HandleSingleClickSelection(Point clickPoint)
}
}
+ HighlightDotsOnChart(_selectedRowIndices);
HighlightGridRows();
}
@@ -511,16 +602,6 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
var y1 = Math.Min(start.Y, end.Y);
var y2 = Math.Max(start.Y, end.Y);
- // Draw selection rectangle on chart
- if (_selectionRect != null)
- HistoryChart.Plot.Remove(_selectionRect);
-
- _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
- _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
- _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
- _selectionRect.LineWidth = 1;
- HistoryChart.Refresh();
-
// Find all data points inside the box
var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
_selectedRowIndices.Clear();
@@ -535,15 +616,12 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
_selectedRowIndices.Add(i);
}
+ HighlightDotsOnChart(_selectedRowIndices);
HighlightGridRows();
}
private void HighlightGridRows()
{
- // Clear all row highlighting first, then apply selection
- var highlightBrush = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
- var transparentBrush = Brushes.Transparent;
-
// Scroll to first selected row if any
if (_selectedRowIndices.Count > 0)
{
@@ -552,14 +630,15 @@ private void HighlightGridRows()
HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null);
}
- // Use LoadingRow event + force refresh to highlight
HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow;
HistoryDataGrid.LoadingRow += OnHighlightLoadingRow;
// Force grid to re-render rows
+ _suppressGridSelectionEvent = true;
var source = _historyData;
HistoryDataGrid.ItemsSource = null;
HistoryDataGrid.ItemsSource = source;
+ _suppressGridSelectionEvent = false;
}
private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
@@ -569,16 +648,35 @@ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
{
e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
}
- else if (_selectedRowIndices.Count > 0)
+ else
{
e.Row.Background = Brushes.Transparent;
}
- else
+ }
+
+ // ── Grid row click → chart highlight ─────────────────────────────────
+
+ private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressGridSelectionEvent) return;
+ if (HistoryDataGrid.SelectedItems == null || HistoryDataGrid.SelectedItems.Count == 0) return;
+
+ _selectedRowIndices.Clear();
+ foreach (var item in HistoryDataGrid.SelectedItems)
{
- e.Row.Background = Brushes.Transparent;
+ if (item is QueryStoreHistoryRow row)
+ {
+ var idx = _historyData.IndexOf(row);
+ if (idx >= 0)
+ _selectedRowIndices.Add(idx);
+ }
}
+
+ HighlightDotsOnChart(_selectedRowIndices);
}
+ // ── Hover tooltip ────────────────────────────────────────────────────
+
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
if (_scatters.Count == 0) { _tooltip.IsOpen = false; return; }
@@ -690,9 +788,6 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Grid.MajorLineColor = grid;
HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = text;
HistoryChart.Plot.Axes.Left.TickLabelStyle.ForeColor = text;
- HistoryChart.Plot.Legend.BackgroundColor = fig;
- HistoryChart.Plot.Legend.FontColor = ScottPlot.Color.FromHex("#E4E6EB");
- HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
private void UpdateRangeButtons()
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index b38c6ba..77e160c 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -28,6 +28,20 @@ public class QueryStoreHistoryRow
public int MaxDop { get; set; }
public DateTime? LastExecutionUtc { get; set; }
+ // Display-formatted properties (2 decimal places)
+ public string AvgDurationMsDisplay => AvgDurationMs.ToString("N2");
+ public string AvgCpuMsDisplay => AvgCpuMs.ToString("N2");
+ public string AvgLogicalReadsDisplay => AvgLogicalReads.ToString("N2");
+ public string AvgLogicalWritesDisplay => AvgLogicalWrites.ToString("N2");
+ public string AvgPhysicalReadsDisplay => AvgPhysicalReads.ToString("N2");
+ public string AvgMemoryMbDisplay => AvgMemoryMb.ToString("N2");
+ public string AvgRowcountDisplay => AvgRowcount.ToString("N2");
+ public string TotalDurationMsDisplay => TotalDurationMs.ToString("N2");
+ public string TotalCpuMsDisplay => TotalCpuMs.ToString("N2");
+ public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
+ public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
+ public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+
public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
From ecc960e7f8ce6d3e167be896abed248680403161 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:46:26 +0200
Subject: [PATCH 4/9] Fix grid color column correctly in query history
---
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 24 ++++++++++++++-----
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index be1924a..e5c6314 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -244,9 +244,15 @@ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
_planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- e.Row.Tag = new SolidColorBrush(avColor);
+ var brush = new SolidColorBrush(avColor);
+ e.Row.Tag = brush;
+
+ // Try to apply immediately (works for recycled rows whose visual tree already exists)
+ if (TryApplyColorIndicator(e.Row, brush))
+ return;
}
+ // Visual tree not ready yet (first load) — defer to Loaded
e.Row.Loaded -= OnRowLoaded;
e.Row.Loaded += OnRowLoaded;
}
@@ -256,17 +262,23 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
if (sender is not DataGridRow dgRow) return;
dgRow.Loaded -= OnRowLoaded;
- if (dgRow.Tag is not SolidColorBrush brush) return;
+ if (dgRow.Tag is SolidColorBrush brush)
+ TryApplyColorIndicator(dgRow, brush);
+ }
+ private bool TryApplyColorIndicator(DataGridRow dgRow, SolidColorBrush brush)
+ {
var presenter = FindVisualChild(dgRow);
- if (presenter == null) return;
+ if (presenter == null) return false;
var cell = presenter.Children.OfType().FirstOrDefault();
- if (cell == null) return;
+ if (cell == null) return false;
var border = FindVisualChild(cell, "ColorIndicator");
- if (border != null)
- border.Background = brush;
+ if (border == null) return false;
+
+ border.Background = brush;
+ return true;
}
private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual
From 223ba4129d0f1b720c2b761ff127912a961f484f Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:58:11 +0200
Subject: [PATCH 5/9] in the query history : - emphasize selected dots : bigger
and white border - allow the user to select multiples rows in the grid using
standard CRLT+Click or SHIFT+Click for multiselection - emphasize the dots of
a manually selected rows - when the user click on a planHash in the legend :
highlight all dots and the line (thicker and less transparency). also - -
recompute the avg in the chart using selected/highlighted dots
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 1 +
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 143 +++++++++++++++---
2 files changed, 120 insertions(+), 24 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 24cc0f3..35bfd20 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -110,6 +110,7 @@
kv.Key))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- var item = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 };
+ var item = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ Spacing = 6,
+ Tag = hash,
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ };
item.Children.Add(new Border
{
Width = 12, Height = 12,
@@ -321,10 +331,90 @@ private void PopulateLegendPanel()
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0))
});
+ item.PointerPressed += OnLegendItemClicked;
LegendItemsPanel.Children.Add(item);
}
}
+ private void OnLegendItemClicked(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not StackPanel panel || panel.Tag is not string planHash) return;
+
+ // Toggle: click again to deselect
+ if (_highlightedPlanHash == planHash)
+ _highlightedPlanHash = null;
+ else
+ _highlightedPlanHash = planHash;
+
+ ApplyPlanHighlight();
+ UpdateLegendVisuals();
+ }
+
+ private void UpdateLegendVisuals()
+ {
+ foreach (var child in LegendItemsPanel.Children)
+ {
+ if (child is not StackPanel panel || panel.Tag is not string hash) continue;
+ var isActive = _highlightedPlanHash == null || _highlightedPlanHash == hash;
+ panel.Opacity = isActive ? 1.0 : 0.4;
+ }
+ }
+
+ private void ApplyPlanHighlight()
+ {
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ if (_highlightedPlanHash == null)
+ {
+ // No highlight: restore normal appearance
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(140);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 8;
+ }
+ else if (planHash == _highlightedPlanHash)
+ {
+ // Highlighted plan: emphasized
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(220);
+ scatter.LineWidth = 4;
+ scatter.MarkerSize = 10;
+ }
+ else
+ {
+ // Other plans: dimmed
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(40);
+ scatter.LineWidth = 1;
+ scatter.MarkerSize = 5;
+ }
+ }
+
+ // Recompute average line based on highlighted plan or all data
+ if (_avgLine != null)
+ {
+ var relevantRows = _highlightedPlanHash != null
+ ? _historyData.Where(r => r.QueryPlanHash == _highlightedPlanHash).ToList()
+ : _historyData;
+
+ if (relevantRows.Count > 0)
+ {
+ var avg = relevantRows.Select(r => GetMetricValue(r, tag)).Average();
+ _avgLine.Y = avg;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.IsVisible = true;
+ }
+ else
+ {
+ _avgLine.IsVisible = false;
+ }
+ }
+
+ HistoryChart.Refresh();
+ }
+
private void LegendToggle_Click(object? sender, RoutedEventArgs e)
{
_legendExpanded = !_legendExpanded;
@@ -340,6 +430,8 @@ private void UpdateChart()
_scatters.Clear();
_selectionRect = null;
_highlightMarkers.Clear();
+ _avgLine = null;
+ _highlightedPlanHash = null;
if (_historyData.Count == 0)
{
@@ -382,19 +474,19 @@ private void UpdateChart()
if (allValues.Length > 0)
{
var avg = allValues.Average();
- var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
- hLine.LineWidth = 2f;
- hLine.LinePattern = LinePattern.DenselyDashed;
- hLine.Text = $"avg: {avg:N0}";
- hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
- hLine.LabelFontSize = 11;
- hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
- hLine.LabelOppositeAxis = false;
- hLine.LabelRotation = 0;
- hLine.LabelAlignment = Alignment.LowerLeft;
- hLine.LabelOffsetX = 38;
- hLine.LabelOffsetY = -8;
+ _avgLine = HistoryChart.Plot.Add.HorizontalLine(avg);
+ _avgLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
+ _avgLine.LineWidth = 2f;
+ _avgLine.LinePattern = LinePattern.DenselyDashed;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
+ _avgLine.LabelFontSize = 11;
+ _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ _avgLine.LabelOppositeAxis = false;
+ _avgLine.LabelRotation = 0;
+ _avgLine.LabelAlignment = Alignment.LowerLeft;
+ _avgLine.LabelOffsetX = 38;
+ _avgLine.LabelOffsetY = -8;
}
// Y-axis always includes 0 as origin
@@ -479,13 +571,14 @@ private void HighlightDotsOnChart(HashSet rowIndices)
var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
+ // Bigger filled dot with white border for emphasis
var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
highlight.LineWidth = 0;
- highlight.MarkerSize = 12;
- highlight.MarkerShape = MarkerShape.OpenCircle;
+ highlight.MarkerSize = 14;
+ highlight.MarkerShape = MarkerShape.FilledCircle;
+ highlight.Color = color;
highlight.MarkerLineColor = ScottPlot.Colors.White;
- highlight.MarkerLineWidth = 2f;
- highlight.Color = ScottPlot.Colors.Transparent;
+ highlight.MarkerLineWidth = 2.5f;
_highlightMarkers.Add(highlight);
}
@@ -671,16 +764,18 @@ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressGridSelectionEvent) return;
- if (HistoryDataGrid.SelectedItems == null || HistoryDataGrid.SelectedItems.Count == 0) return;
_selectedRowIndices.Clear();
- foreach (var item in HistoryDataGrid.SelectedItems)
+ if (HistoryDataGrid.SelectedItems != null)
{
- if (item is QueryStoreHistoryRow row)
+ foreach (var item in HistoryDataGrid.SelectedItems)
{
- var idx = _historyData.IndexOf(row);
- if (idx >= 0)
- _selectedRowIndices.Add(idx);
+ if (item is QueryStoreHistoryRow row)
+ {
+ var idx = _historyData.IndexOf(row);
+ if (idx >= 0)
+ _selectedRowIndices.Add(idx);
+ }
}
}
From 53a4861dcc97ac0ee64392bf31ef367336b221c2 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:46:08 +0200
Subject: [PATCH 6/9] remove double using system
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index d1270af..010f8ac 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -1,5 +1,4 @@
using System;
-using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
From 6d525e70a374fd4b2159d2325fafce0a7e91d3bc Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:48:09 +0200
Subject: [PATCH 7/9] avgline LabelBackgroundColor Alpha 270 ==> 170
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 010f8ac..b94732e 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -480,7 +480,7 @@ private void UpdateChart()
_avgLine.Text = $"avg: {avg:N0}";
_avgLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
_avgLine.LabelFontSize = 11;
- _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(170);
_avgLine.LabelOppositeAxis = false;
_avgLine.LabelRotation = 0;
_avgLine.LabelAlignment = Alignment.LowerLeft;
From 77ec4a43faf9d7f1baa589f0cb4614919ba9f85f Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:11:38 +0200
Subject: [PATCH 8/9] add TotalMemoryMB metric for query history
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml | 2 ++
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 5 +++--
src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs | 7 ++++---
src/PlanViewer.Core/Services/QueryStoreService.cs | 6 ++++--
4 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 35bfd20..04d9188 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -59,6 +59,7 @@
+
@@ -142,6 +143,7 @@
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index b94732e..b71aef8 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -84,7 +84,7 @@ public partial class QueryStoreHistoryWindow : Window
["avg-writes"] = "AvgLogicalWrites",
["physical-reads"] = "TotalPhysicalReads",
["avg-physical-reads"] = "AvgPhysicalReads",
- ["memory"] = "TotalCpuMs",
+ ["memory"] = "TotalMemoryMb",
["avg-memory"] = "AvgMemoryMb",
["executions"] = "CountExecutions",
};
@@ -877,7 +877,8 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
"TotalLogicalReads" => row.TotalLogicalReads,
"TotalLogicalWrites" => row.TotalLogicalWrites,
"TotalPhysicalReads" => row.TotalPhysicalReads,
- "CountExecutions" => row.CountExecutions,
+ "TotalMemoryMb" => row.TotalMemoryMb,
+ "CountExecutions" => row.CountExecutions,
_ => row.AvgCpuMs,
};
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index 77e160c..62127f2 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -23,8 +23,8 @@ public class QueryStoreHistoryRow
public double TotalLogicalReads { get; set; }
public double TotalLogicalWrites { get; set; }
public double TotalPhysicalReads { get; set; }
-
- public int MinDop { get; set; }
+ public double TotalMemoryMb { get; set; }
+ public int MinDop { get; set; }
public int MaxDop { get; set; }
public DateTime? LastExecutionUtc { get; set; }
@@ -41,7 +41,8 @@ public class QueryStoreHistoryRow
public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+ public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
- public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
+ public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 42ed986..8b96fbd 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -608,7 +608,8 @@ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
SUM(rs.avg_physical_io_reads * rs.count_executions),
MIN(rs.min_dop),
MAX(rs.max_dop),
- MAX(rs.last_execution_time)
+ MAX(rs.last_execution_time),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions)
FROM sys.query_store_runtime_stats rs
JOIN sys.query_store_runtime_stats_interval rsi
ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
@@ -652,7 +653,8 @@ JOIN sys.query_store_query q
MinDop = (int)reader.GetInt64(15),
MaxDop = (int)reader.GetInt64(16),
LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime,
- });
+ TotalMemoryMb = reader.GetDouble(18),
+ });
}
return rows;
From e88096bf2cea4013cfb7276b69572d066a482d90 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:14:45 +0200
Subject: [PATCH 9/9] update TotalLogicalReads, TotalLogicalWrites,
TotalPhysicalReads format to no decimals (N0)
---
src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index 62127f2..b275acf 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -38,11 +38,10 @@ public class QueryStoreHistoryRow
public string AvgRowcountDisplay => AvgRowcount.ToString("N2");
public string TotalDurationMsDisplay => TotalDurationMs.ToString("N2");
public string TotalCpuMsDisplay => TotalCpuMs.ToString("N2");
- public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
- public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
- public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+ public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N0");
+ public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N0");
+ public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N0");
public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
-
public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}