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}"> - + - - - + + + + + + + + + + + + + @@ -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) : ""; }