From 94b36cc42fc3bc5a425ede21d0bac019aebba777 Mon Sep 17 00:00:00 2001 From: Romain Ferraton <16419423+rferraton@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:17:28 +0100 Subject: [PATCH 1/3] Feature/query store timeslicer (#114) * Add a Range Navigator Slicer/Filter to the querystore screen : the metric display in background of the range is base on the sorting metric in the drop down selector Auto fetch when opening the query store * improve RangeTime Slicer : - Fix selection bugs : fetch is relaunch when moving - Selection default selection on top 25 query limited to 24h using Rangetime which display the time selection - number of hours selected in the Range selector is displayed in the top left of the Rangetime selector * Enable dynamic metric switching in Query Store grid Add SelectionChanged handler for "Order by" ComboBox to allow users to switch metrics and refresh data without losing time range selection. Preserve slicer selection across metric changes and suppress redundant fetches. Remove row limit from time-slice SQL query to ensure all data is available for the slicer : using where filtering only * filtering grid using query_store_endtime_stats_interval.starttime and instead of query_store_runtime_stats.last_execution_time only * try to add supplemental filters on query_store_runtime_stats.first_execution_time to filter on fact table first --- .../Controls/QueryStoreGridControl.axaml | 17 +- .../Controls/QueryStoreGridControl.axaml.cs | 148 ++++- .../Controls/TimeRangeSlicerControl.axaml | 41 ++ .../Controls/TimeRangeSlicerControl.axaml.cs | 559 ++++++++++++++++++ .../Services/AppSettingsService.cs | 6 + src/PlanViewer.App/Themes/DarkTheme.axaml | 12 + .../Models/QueryStoreTimeSlice.cs | 18 + .../Services/QueryStoreService.cs | 80 ++- 8 files changed, 859 insertions(+), 22 deletions(-) create mode 100644 src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml create mode 100644 src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs create mode 100644 src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index 38965c8..bff0fb5 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -3,9 +3,12 @@ xmlns:local="using:PlanViewer.App.Controls" x:Class="PlanViewer.App.Controls.QueryStoreGridControl" Background="{DynamicResource BackgroundBrush}"> - + + + + - @@ -27,16 +30,10 @@ Width="120" Height="36" FontSize="14" FormatString="0" HorizontalContentAlignment="Center"/> - - - - + SelectedIndex="0" SelectionChanged="OrderBy_SelectionChanged"> @@ -86,7 +83,7 @@ - >? PlansSelected; public event EventHandler? DatabaseChanged; @@ -45,11 +52,21 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi _credentialService = credentialService; _database = initialDatabase; _connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase); + _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays; InitializeComponent(); ResultsGrid.ItemsSource = _filteredRows; EnsureFilterPopup(); SetupColumnHeaders(); PopulateDatabaseBox(databases, initialDatabase); + TimeRangeSlicer.RangeChanged += OnTimeRangeChanged; + TimeRangeSlicer.IsExpanded = true; + + // Auto-fetch with default settings on connect + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Fetch_Click(null, new RoutedEventArgs()); + _initialOrderByLoaded = true; + }, Avalonia.Threading.DispatcherPriority.Loaded); } private void PopulateDatabaseBox(List databases, string selectedDatabase) @@ -101,25 +118,61 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e) _fetchCts = new CancellationTokenSource(); var ct = _fetchCts.Token; - var topN = (int)(TopNBox.Value ?? 25); - var hoursBack = (int)(HoursBackBox.Value ?? 24); var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu"; + _lastFetchedOrderBy = orderBy; + + FetchButton.IsEnabled = false; + LoadButton.IsEnabled = false; + StatusText.Text = "Loading time slicer..."; + _rows.Clear(); + _filteredRows.Clear(); + + try + { + // Load slicer data first — LoadData sets a default 24h selection and + // fires RangeChanged which triggers FetchPlansForRangeAsync. + await LoadTimeSlicerDataAsync(orderBy, ct); + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + FetchButton.IsEnabled = true; + } + } + + private async System.Threading.Tasks.Task FetchPlansForRangeAsync() + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + var topN = (int)(TopNBox.Value ?? 25); + var orderBy = _lastFetchedOrderBy; var filter = BuildSearchFilter(); FetchButton.IsEnabled = false; LoadButton.IsEnabled = false; - StatusText.Text = "Fetching..."; + StatusText.Text = "Fetching plans..."; _rows.Clear(); _filteredRows.Clear(); try { var plans = await QueryStoreService.FetchTopPlansAsync( - _connectionString, topN, orderBy, hoursBack, filter, ct); + _connectionString, topN, orderBy, ct: ct, + startUtc: _slicerStartUtc, endUtc: _slicerEndUtc); if (plans.Count == 0) { - StatusText.Text = "No Query Store data found."; + StatusText.Text = "No Query Store data found for the selected range."; return; } @@ -194,12 +247,91 @@ private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e) } } + private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (!_initialOrderByLoaded) return; + var newOrderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu"; + if (newOrderBy == _lastFetchedOrderBy) return; + + _lastFetchedOrderBy = newOrderBy; + + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + // Capture the current slicer selection so it survives the reload + var selStart = TimeRangeSlicer.SelectionStart; + var selEnd = TimeRangeSlicer.SelectionEnd; + + FetchButton.IsEnabled = false; + StatusText.Text = "Refreshing metric..."; + + try + { + var sliceData = await QueryStoreService.FetchTimeSliceDataAsync( + _connectionString, newOrderBy, _slicerDaysBack, ct); + if (ct.IsCancellationRequested) return; + + if (sliceData.Count > 0) + { + // Suppress the implicit RangeChanged fetch — we will refresh the grid explicitly below + _suppressRangeChanged = true; + try { TimeRangeSlicer.LoadData(sliceData, newOrderBy, selStart, selEnd); } + finally { _suppressRangeChanged = false; } + + // Explicitly refresh the grid with the new metric and current time range + await FetchPlansForRangeAsync(); + } + else + { + StatusText.Text = "No time-slicer data available."; + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message; + } + finally + { + FetchButton.IsEnabled = true; + } + } + private void ClearSearch_Click(object? sender, RoutedEventArgs e) { SearchTypeBox.SelectedIndex = 0; SearchValueBox.Text = ""; } + private async System.Threading.Tasks.Task LoadTimeSlicerDataAsync(string metric, CancellationToken ct) + { + try + { + var sliceData = await QueryStoreService.FetchTimeSliceDataAsync( + _connectionString, metric, _slicerDaysBack, ct); + if (ct.IsCancellationRequested) return; + if (sliceData.Count > 0) + TimeRangeSlicer.LoadData(sliceData, metric); + else + StatusText.Text = "No time-slicer data available."; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + StatusText.Text = $"Slicer: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; + } + } + + private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs e) + { + _slicerStartUtc = e.StartUtc; + _slicerEndUtc = e.EndUtc; + if (_suppressRangeChanged) return; + await FetchPlansForRangeAsync(); + } + private void SelectToggle_Click(object? sender, RoutedEventArgs e) { var allSelected = _filteredRows.Count > 0 && _filteredRows.All(r => r.IsSelected); @@ -225,14 +357,11 @@ private async void ViewHistory_Click(object? sender, RoutedEventArgs e) { if (ResultsGrid.SelectedItem is not QueryStoreRow row) return; - var hoursBack = (int)(HoursBackBox.Value ?? 24); - var window = new QueryStoreHistoryWindow( _connectionString, row.QueryId, row.FullQueryText, - _database, - hoursBack); + _database); var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this); if (topLevel is Window parentWindow) @@ -588,7 +717,6 @@ private void ApplySortAndFilters() : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r)); } - _filteredRows.Clear(); foreach (var row in source) _filteredRows.Add(row); diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml new file mode 100644 index 0000000..ef28a01 --- /dev/null +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs new file mode 100644 index 0000000..eb3a3f3 --- /dev/null +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -0,0 +1,559 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using PlanViewer.Core.Models; + +namespace PlanViewer.App.Controls; + +public partial class TimeRangeSlicerControl : UserControl +{ + private List _data = new(); + private string _metric = "cpu"; + private bool _isExpanded = true; + + // Range as normalised [0..1] positions within _data + private double _rangeStart; + private double _rangeEnd = 1.0; + + private const double HandleWidthPx = 8; + private const double HandleGripWidthPx = 20; // extended hit-test area for easier grabbing + private const double MinIntervalHours = 3; + private const double ChartPaddingTop = 16; + private const double ChartPaddingBottom = 20; + + private enum DragMode { None, MoveRange, DragStart, DragEnd } + private DragMode _dragMode = DragMode.None; + private double _dragOriginX; + private double _dragOriginRangeStart; + private double _dragOriginRangeEnd; + + public event EventHandler? RangeChanged; + + public TimeRangeSlicerControl() + { + InitializeComponent(); + SlicerBorder.SizeChanged += (_, _) => Redraw(); + } + + public bool IsExpanded + { + get => _isExpanded; + set + { + _isExpanded = value; + SlicerBorder.IsVisible = _isExpanded; + ToggleIcon.Text = _isExpanded ? "▾" : "▸"; + } + } + + public void LoadData(List data, string metric, + DateTime? selectionStart = null, DateTime? selectionEnd = null) + { + _data = data; + _metric = metric; + + if (selectionStart.HasValue && selectionEnd.HasValue && _data.Count >= 2) + { + // Restore a previous selection + _rangeStart = GetNormFromDateTime(selectionStart.Value); + _rangeEnd = GetNormFromDateTime(selectionEnd.Value); + } + else + { + // Default selection: last 24 hours + _rangeEnd = 1.0; + if (_data.Count >= 2) + { + var last = _data[^1].IntervalStartUtc.AddHours(1); + var start24h = last.AddHours(-24); + _rangeStart = GetNormFromDateTime(start24h); + } + else + { + _rangeStart = 0; + } + } + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + } + + public void SetMetric(string metric) + { + _metric = metric; + Redraw(); + } + + public DateTime? SelectionStart => _data.Count > 0 + ? GetDateTimeAtNorm(_rangeStart) + : null; + + public DateTime? SelectionEnd => _data.Count > 0 + ? GetDateTimeAtNorm(_rangeEnd) + : null; + + private DateTime GetDateTimeAtNorm(double norm) + { + if (_data.Count == 0) return DateTime.UtcNow; + var first = _data[0].IntervalStartUtc; + var last = _data[^1].IntervalStartUtc.AddHours(1); + var ticks = first.Ticks + (long)((last.Ticks - first.Ticks) * norm); + return new DateTime(Math.Clamp(ticks, first.Ticks, last.Ticks), DateTimeKind.Utc); + } + + private double GetNormFromDateTime(DateTime dt) + { + if (_data.Count == 0) return 0; + var first = _data[0].IntervalStartUtc; + var last = _data[^1].IntervalStartUtc.AddHours(1); + if (last <= first) return 0; + return Math.Clamp((double)(dt.Ticks - first.Ticks) / (last.Ticks - first.Ticks), 0, 1); + } + + private double MinNormInterval + { + get + { + if (_data.Count == 0) return 0; + var first = _data[0].IntervalStartUtc; + var last = _data[^1].IntervalStartUtc.AddHours(1); + var totalHours = (last - first).TotalHours; + if (totalHours <= 0) return 1; + return Math.Min(MinIntervalHours / totalHours, 1); + } + } + + private void Toggle_Click(object? sender, RoutedEventArgs e) + { + IsExpanded = !IsExpanded; + } + + private double[] GetMetricValues() + { + return _metric switch + { + "cpu" or "avg-cpu" => _data.Select(d => d.TotalCpu).ToArray(), + "duration" or "avg-duration" => _data.Select(d => d.TotalDuration).ToArray(), + "reads" or "avg-reads" => _data.Select(d => d.TotalReads).ToArray(), + "writes" or "avg-writes" => _data.Select(d => d.TotalWrites).ToArray(), + "physical-reads" or "avg-physical-reads" => _data.Select(d => d.TotalPhysicalReads).ToArray(), + "memory" or "avg-memory" => _data.Select(d => d.TotalMemory).ToArray(), + "executions" => _data.Select(d => (double)d.TotalExecutions).ToArray(), + _ => _data.Select(d => d.TotalCpu).ToArray(), + }; + } + + private string GetMetricLabel() + { + return _metric switch + { + "cpu" or "avg-cpu" => "Total CPU (ms)", + "duration" or "avg-duration" => "Total Duration (ms)", + "reads" or "avg-reads" => "Total Reads", + "writes" or "avg-writes" => "Total Writes", + "physical-reads" or "avg-physical-reads" => "Total Physical Reads", + "memory" or "avg-memory" => "Total Memory (MB)", + "executions" => "Executions", + _ => "Total CPU (ms)", + }; + } + + // ── Drawing ──────────────────────────────────────────────────────────── + + private void Redraw() + { + SlicerCanvas.Children.Clear(); + if (_data.Count < 2) return; + + // Use the parent Border bounds — Canvas has no intrinsic size + var w = SlicerBorder.Bounds.Width; + var h = SlicerBorder.Bounds.Height; + if (w <= 0 || h <= 0) return; + + var values = GetMetricValues(); + var max = values.Max(); + if (max <= 0) max = 1; + + var chartTop = ChartPaddingTop; + var chartBottom = h - ChartPaddingBottom; + var chartHeight = chartBottom - chartTop; + if (chartHeight <= 0) return; + + var n = values.Length; + var stepX = w / n; + + // Draw filled area + line for the chart + var linePoints = new List(n); + for (int i = 0; i < n; i++) + { + var x = i * stepX + stepX / 2; + var y = chartBottom - (values[i] / max) * chartHeight; + linePoints.Add(new Point(x, y)); + } + + // Area fill + var fillBrush = TryFindBrush("SlicerChartFillBrush", new SolidColorBrush(Color.Parse("#332EAEF1"))); + var areaGeometry = new StreamGeometry(); + using (var ctx = areaGeometry.Open()) + { + ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true); + foreach (var pt in linePoints) + ctx.LineTo(pt); + ctx.LineTo(new Point(linePoints[^1].X, chartBottom)); + ctx.EndFigure(true); + } + SlicerCanvas.Children.Add(new Path + { + Data = areaGeometry, + Fill = fillBrush, + }); + + // Line + var lineBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + var lineGeometry = new StreamGeometry(); + using (var ctx = lineGeometry.Open()) + { + ctx.BeginFigure(linePoints[0], false); + for (int i = 1; i < linePoints.Count; i++) + ctx.LineTo(linePoints[i]); + ctx.EndFigure(false); + } + SlicerCanvas.Children.Add(new Path + { + Data = lineGeometry, + Stroke = lineBrush, + StrokeThickness = 1.5, + }); + + // X-axis labels (show a few ticks) + var labelBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#99E4E6EB"))); + int labelInterval = Math.Max(1, n / 8); + for (int i = 0; i < n; i += labelInterval) + { + var x = i * stepX + stepX / 2; + var dt = _data[i].IntervalStartUtc.ToLocalTime(); + var label = dt.ToString("MM/dd HH:mm"); + var tb = new TextBlock + { + Text = label, + FontSize = 9, + Foreground = labelBrush, + }; + Canvas.SetLeft(tb, x - 25); + Canvas.SetTop(tb, chartBottom + 2); + SlicerCanvas.Children.Add(tb); + } + + // Metric label top-right + var metricTb = new TextBlock + { + Text = GetMetricLabel(), + FontSize = 9, + Foreground = labelBrush, + }; + Canvas.SetRight(metricTb, 4); + Canvas.SetTop(metricTb, 2); + SlicerCanvas.Children.Add(metricTb); + + // ── Overlays + selection ─────────────────────────────────────────── + var overlayBrush = TryFindBrush("SlicerOverlayBrush", new SolidColorBrush(Color.Parse("#99000000"))); + var selectedBrush = TryFindBrush("SlicerSelectedBrush", new SolidColorBrush(Color.Parse("#22FFFFFF"))); + var handleBrush = TryFindBrush("SlicerHandleBrush", new SolidColorBrush(Color.Parse("#E4E6EB"))); + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + // Left overlay + if (selLeft > 0) + { + SlicerCanvas.Children.Add(new Rectangle + { + Width = selLeft, + Height = h, + Fill = overlayBrush, + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], 0); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + } + + // Right overlay + if (selRight < w) + { + SlicerCanvas.Children.Add(new Rectangle + { + Width = w - selRight, + Height = h, + Fill = overlayBrush, + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], selRight); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + } + + // Selected region (darker = more visible) + SlicerCanvas.Children.Add(new Rectangle + { + Width = Math.Max(0, selRight - selLeft), + Height = h, + Fill = selectedBrush, + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], selLeft); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + + // Left handle + DrawHandle(selLeft, h, handleBrush); + // Right handle + DrawHandle(selRight - HandleWidthPx, h, handleBrush); + + // Selection border top and bottom lines + var borderBrush = TryFindBrush("SlicerHandleBrush", handleBrush); + // Top border of selection + SlicerCanvas.Children.Add(new Line + { + StartPoint = new Point(selLeft, 0), + EndPoint = new Point(selRight, 0), + Stroke = borderBrush, + StrokeThickness = 1, + Opacity = 0.5, + }); + // Bottom border of selection + SlicerCanvas.Children.Add(new Line + { + StartPoint = new Point(selLeft, h), + EndPoint = new Point(selRight, h), + Stroke = borderBrush, + StrokeThickness = 1, + Opacity = 0.5, + }); + } + + private void DrawHandle(double x, double canvasHeight, IBrush brush) + { + // Handle bar + SlicerCanvas.Children.Add(new Rectangle + { + Width = HandleWidthPx, + Height = canvasHeight, + Fill = brush, + Opacity = 0.7, + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], x); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + + // Grip lines (3 short horizontal lines in the middle of the handle) + var midY = canvasHeight / 2; + for (int i = -1; i <= 1; i++) + { + var gy = midY + i * 5; + SlicerCanvas.Children.Add(new Line + { + StartPoint = new Point(x + 2, gy), + EndPoint = new Point(x + HandleWidthPx - 2, gy), + Stroke = Brushes.Black, + StrokeThickness = 1, + Opacity = 0.6, + }); + } + } + + private IBrush TryFindBrush(string key, IBrush fallback) + { + if (this.TryFindResource(key, this.ActualThemeVariant, out var resource) && resource is IBrush brush) + return brush; + return fallback; + } + + // ── Interaction ──────────────────────────────────────────────────────── + + private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_data.Count < 2) return; + var pos = e.GetPosition(SlicerCanvas); + var w = SlicerBorder.Bounds.Width; + if (w <= 0) return; + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + _dragOriginX = pos.X; + _dragOriginRangeStart = _rangeStart; + _dragOriginRangeEnd = _rangeEnd; + + // Check if near left handle + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) + { + _dragMode = DragMode.DragStart; + e.Pointer.Capture(SlicerCanvas); + e.Handled = true; + return; + } + + // Check if near right handle + if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { + _dragMode = DragMode.DragEnd; + e.Pointer.Capture(SlicerCanvas); + e.Handled = true; + return; + } + + // Check if inside selection → move + if (pos.X >= selLeft && pos.X <= selRight) + { + _dragMode = DragMode.MoveRange; + e.Pointer.Capture(SlicerCanvas); + e.Handled = true; + return; + } + } + + private void Canvas_PointerMoved(object? sender, PointerEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.Bounds.Width; + if (w <= 0) return; + + var pos = e.GetPosition(SlicerCanvas); + + if (_dragMode == DragMode.None) + { + // Update cursor + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || + Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { + SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast); + } + else if (pos.X >= selLeft && pos.X <= selRight) + { + SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeAll); + } + else + { + SlicerCanvas.Cursor = Cursor.Default; + } + return; + } + + var deltaX = pos.X - _dragOriginX; + var deltaNorm = deltaX / w; + var minInterval = MinNormInterval; + + switch (_dragMode) + { + case DragMode.DragStart: + { + var newStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - minInterval); + _rangeStart = newStart; + break; + } + case DragMode.DragEnd: + { + var newEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + minInterval, 1); + _rangeEnd = newEnd; + break; + } + case DragMode.MoveRange: + { + var span = _dragOriginRangeEnd - _dragOriginRangeStart; + var newStart = _dragOriginRangeStart + deltaNorm; + if (newStart < 0) newStart = 0; + if (newStart + span > 1) newStart = 1 - span; + _rangeStart = newStart; + _rangeEnd = newStart + span; + break; + } + } + + UpdateRangeLabel(); + Redraw(); + e.Handled = true; + } + + private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_dragMode != DragMode.None) + { + _dragMode = DragMode.None; + e.Pointer.Capture(null); + FireRangeChanged(); + e.Handled = true; + } + } + + private void Canvas_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (_data.Count < 2) return; + // Only zoom if Ctrl is held + if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) return; + + var w = SlicerBorder.Bounds.Width; + if (w <= 0) return; + + var pos = e.GetPosition(SlicerCanvas); + var pivot = Math.Clamp(pos.X / w, 0, 1); + var span = _rangeEnd - _rangeStart; + var minInterval = MinNormInterval; + + // Zoom in (wheel up) → shrink span; zoom out (wheel down) → expand span + var zoomFactor = e.Delta.Y > 0 ? 0.85 : 1.0 / 0.85; + var newSpan = Math.Clamp(span * zoomFactor, minInterval, 1.0); + + // Keep the pivot point stable in the range + var pivotInRange = (pivot - _rangeStart) / span; + var newStart = pivot - pivotInRange * newSpan; + var newEnd = newStart + newSpan; + + if (newStart < 0) { newStart = 0; newEnd = newSpan; } + if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; } + + _rangeStart = Math.Max(0, newStart); + _rangeEnd = Math.Min(1, newEnd); + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + e.Handled = true; + } + + private void UpdateRangeLabel() + { + if (_data.Count == 0) + { + RangeLabel.Text = ""; + return; + } + var start = GetDateTimeAtNorm(_rangeStart).ToLocalTime(); + var end = GetDateTimeAtNorm(_rangeEnd).ToLocalTime(); + var span = end - start; + RangeLabel.Text = $"{start:yyyy-MM-dd HH:mm} → {end:yyyy-MM-dd HH:mm} ({span.TotalHours:F0}h)"; + } + + private void FireRangeChanged() + { + if (_data.Count == 0) return; + RangeChanged?.Invoke(this, new TimeRangeChangedEventArgs( + GetDateTimeAtNorm(_rangeStart), + GetDateTimeAtNorm(_rangeEnd))); + } +} + +public class TimeRangeChangedEventArgs : EventArgs +{ + public DateTime StartUtc { get; } + public DateTime EndUtc { get; } + + public TimeRangeChangedEventArgs(DateTime startUtc, DateTime endUtc) + { + StartUtc = startUtc; + EndUtc = endUtc; + } +} diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs index e8faf34..46b374b 100644 --- a/src/PlanViewer.App/Services/AppSettingsService.cs +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -113,4 +113,10 @@ internal sealed class AppSettings /// [JsonPropertyName("open_plans")] public List OpenPlans { get; set; } = new(); + + /// + /// Number of days of Query Store data to load in the time-range slicer. Default 30. + /// + [JsonPropertyName("query_store_slicer_days")] + public int QueryStoreSlicerDays { get; set; } = 30; } diff --git a/src/PlanViewer.App/Themes/DarkTheme.axaml b/src/PlanViewer.App/Themes/DarkTheme.axaml index 05c8b14..45b56d9 100644 --- a/src/PlanViewer.App/Themes/DarkTheme.axaml +++ b/src/PlanViewer.App/Themes/DarkTheme.axaml @@ -52,4 +52,16 @@ + + + + + + + + + + + + diff --git a/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs b/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs new file mode 100644 index 0000000..b27ad4f --- /dev/null +++ b/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs @@ -0,0 +1,18 @@ +using System; + +namespace PlanViewer.Core.Models; + +/// +/// One hourly bucket of aggregated Query Store metrics, used by the time-range slicer. +/// +public class QueryStoreTimeSlice +{ + public DateTime IntervalStartUtc { get; set; } + public double TotalCpu { get; set; } + public double TotalDuration { get; set; } + public double TotalReads { get; set; } + public double TotalWrites { get; set; } + public double TotalPhysicalReads { get; set; } + public double TotalMemory { get; set; } + public long TotalExecutions { get; set; } +} diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index b435d92..0420fbe 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -41,11 +41,13 @@ public static class QueryStoreService /// writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions. /// Optional filter narrows results server-side by query_id, plan_id, query_hash, /// query_plan_hash, or module name (schema.name, supports % wildcards). + /// When / are provided they override . /// public static async Task> FetchTopPlansAsync( string connectionString, int topN = 25, string orderBy = "cpu", int hoursBack = 24, QueryStoreFilter? filter = null, - CancellationToken ct = default) + CancellationToken ct = default, + DateTime? startUtc = null, DateTime? endUtc = null) { var key = orderBy.ToLowerInvariant(); @@ -127,6 +129,19 @@ public static async Task> FetchTopPlansAsync( ? "\n" + string.Join("\n", filterClauses) : ""; + // Time-range filter: prefer explicit start/end over hoursBack + string timeWhereClause; + if (startUtc.HasValue && endUtc.HasValue) + { + timeWhereClause = "WHERE rsi.start_time >= @rangeStart AND rsi.end_time < @rangeEnd"; + parameters.Add(new SqlParameter("@rangeStart", startUtc.Value)); + parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value)); + } + else + { + timeWhereClause = $"WHERE rs.last_execution_time >= DATEADD(HOUR, -{hoursBack}, GETUTCDATE())"; + } + // 1. plan_agg: aggregate runtime_stats by plan_id only (cheapest grouping, // avoids joining query_text for the entire dataset). // 2. ranked: join the small aggregated result to plan to get query_id, @@ -148,7 +163,8 @@ WITH plan_agg AS ( SUM(rs.count_executions) AS total_executions, MAX(rs.last_execution_time) AS last_execution_time FROM sys.query_store_runtime_stats rs - WHERE rs.last_execution_time >= DATEADD(HOUR, -{hoursBack}, GETUTCDATE()) + JOIN sys.query_store_runtime_stats_interval rsi on rs.runtime_stats_interval_id=rsi.runtime_stats_interval_id + {timeWhereClause} GROUP BY rs.plan_id ), ranked AS ( @@ -306,6 +322,7 @@ JOIN sys.query_store_plan p ON rs.plan_id = p.plan_id WHERE p.query_id = @queryId AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE()) +AND rs.first_execution_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE()) --performance: filter runtime_stats by time directly GROUP BY p.plan_id, rsi.start_time ORDER BY rsi.start_time, p.plan_id;"; @@ -346,4 +363,63 @@ JOIN sys.query_store_plan p return rows; } + + /// + /// Fetches hourly-aggregated metric data for the time-range slicer. + /// Limits data to the last days (default 30). + /// Returns up to 1000 hourly buckets in chronological order. + /// + public static async Task> FetchTimeSliceDataAsync( + string connectionString, string orderByMetric = "cpu", + int daysBack = 30, + CancellationToken ct = default) + { + var sql = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0) AS bucket_hour, + SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0 AS total_cpu_ms, + SUM(rs.avg_duration * rs.count_executions) / 1000.0 AS total_duration_ms, + SUM(rs.avg_logical_io_reads * rs.count_executions) AS total_reads, + SUM(rs.avg_logical_io_writes * rs.count_executions) AS total_writes, + SUM(rs.avg_physical_io_reads * rs.count_executions) AS total_physical_reads, + SUM(rs.avg_query_max_used_memory * rs.count_executions) * 8.0 / 1024.0 AS total_memory_mb, + SUM(rs.count_executions) AS total_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 +WHERE rsi.start_time >= DATEADD(DAY, -{daysBack}, GETUTCDATE()) +AND rs.first_execution_time >= DATEADD(DAY, -{daysBack}, GETUTCDATE()) --performance: filter runtime_stats directly +GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0) +ORDER BY bucket_hour DESC;"; + + 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 }; + await using var reader = await cmd.ExecuteReaderAsync(ct); + + while (await reader.ReadAsync(ct)) + { + // DATEADD returns plain datetime (not datetimeoffset) — read accordingly + var bucketHour = reader.GetDateTime(0); + rows.Add(new QueryStoreTimeSlice + { + IntervalStartUtc = DateTime.SpecifyKind(bucketHour, DateTimeKind.Utc), + TotalCpu = reader.GetDouble(1), + TotalDuration = reader.GetDouble(2), + TotalReads = reader.GetDouble(3), + TotalWrites = reader.GetDouble(4), + TotalPhysicalReads = reader.GetDouble(5), + TotalMemory = reader.GetDouble(6), + TotalExecutions = reader.GetInt64(7), + }); + } + + // Return in chronological order + rows.Reverse(); + return rows; + } } From 21c2b7a2acd9070b34af4b7f97274175e8bc1537 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:05:26 -0400 Subject: [PATCH 2/3] Add time display picker (Local/UTC/Server) and parameterize SQL (#120) - Time display ComboBox on QS grid toolbar: Local, UTC, Server modes - TimeDisplayHelper shared across grid, slicer, and history window - Server UTC offset fetched on connect via DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) - All timestamps update live when switching modes - Parameterized hoursBack and daysBack in QueryStoreService SQL queries Co-authored-by: Claude Opus 4.6 (1M context) --- .../Controls/QuerySessionControl.axaml.cs | 17 +++++++ .../Controls/QueryStoreGridControl.axaml | 9 ++++ .../Controls/QueryStoreGridControl.axaml.cs | 28 +++++++++++- .../Controls/TimeRangeSlicerControl.axaml.cs | 9 ++-- .../Dialogs/QueryStoreHistoryWindow.axaml.cs | 6 +-- .../Models/QueryStoreHistoryRow.cs | 5 ++- .../Services/QueryStoreService.cs | 10 +++-- .../Services/TimeDisplayHelper.cs | 45 +++++++++++++++++++ 8 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/PlanViewer.Core/Services/TimeDisplayHelper.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 3cb0528..2764dc9 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -362,6 +362,7 @@ private async Task ShowConnectionDialogAsync() await PopulateDatabases(); await FetchServerMetadataAsync(); + await FetchServerUtcOffset(); if (_selectedDatabase != null) { @@ -439,6 +440,22 @@ private async Task FetchServerMetadataAsync() } } + private async Task FetchServerUtcOffset() + { + if (_connectionString == null) return; + try + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + await using var cmd = new SqlCommand( + "SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn); + var offset = await cmd.ExecuteScalarAsync(); + if (offset is int mins) + PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins; + } + catch { } + } + private async Task FetchDatabaseMetadataAsync() { if (_connectionString == null || _serverMetadata == null) return; diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index bff0fb5..697ad74 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -67,6 +67,15 @@ Watermark="0x1AB2C3, dbo.MyProc" KeyDown="SearchValue_KeyDown"/> + + + + + + + +