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 38965c8..697ad74 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"> @@ -70,6 +67,15 @@ Watermark="0x1AB2C3, dbo.MyProc" KeyDown="SearchValue_KeyDown"/> + + + + + + + + + + + + + + + diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs new file mode 100644 index 0000000..ca0d453 --- /dev/null +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -0,0 +1,560 @@ +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; +using PlanViewer.Core.Services; + +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 ──────────────────────────────────────────────────────────── + + public 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 = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc); + 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 = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart)); + var end = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd)); + 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/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs index 39c2361..f3b51af 100644 --- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs +++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs @@ -107,8 +107,8 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() { var planCount = _historyData.Select(r => r.PlanId).Distinct().Count(); var totalExec = _historyData.Sum(r => r.CountExecutions); - var first = _historyData.Min(r => r.IntervalStartUtc).ToLocalTime(); - var last = _historyData.Max(r => r.IntervalStartUtc).ToLocalTime(); + var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc)); + var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc)); StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " + $"{totalExec:N0} total executions | " + $"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}"; @@ -158,7 +158,7 @@ private void UpdateChart() foreach (var group in planGroups) { var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList(); - var xs = ordered.Select(r => r.IntervalStartUtc.ToLocalTime().ToOADate()).ToArray(); + 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); diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 0c8e1e6..c57666b 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -6,7 +6,7 @@ app.manifest EDD.ico true - 1.2.2 + 1.2.3 Erik Darling Darling Data LLC Performance Studio 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.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index bc0685f..34a23bc 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -11,7 +11,7 @@ enable PlanViewer.Cli planview - 1.2.2 + 1.2.3 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs index f2b691f..b38c6ba 100644 --- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs +++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs @@ -1,4 +1,5 @@ using System; +using PlanViewer.Core.Services; namespace PlanViewer.Core.Models; @@ -27,6 +28,6 @@ public class QueryStoreHistoryRow public int MaxDop { get; set; } public DateTime? LastExecutionUtc { get; set; } - public string IntervalStartLocal => IntervalStartUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); - public string LastExecutionLocal => LastExecutionUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? ""; + public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc); + public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : ""; } 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/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index 4aa8fc3..eace461 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -5,7 +5,7 @@ enable enable PlanViewer.Core - 1.2.2 + 1.2.3 Erik Darling Darling Data LLC SQL Performance Studio diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index b435d92..1f59c07 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,20 @@ 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())"; + parameters.Add(new SqlParameter("@hoursBack", hoursBack)); + } + // 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 +164,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 +323,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 +364,64 @@ 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 }; + cmd.Parameters.Add(new SqlParameter("@daysBack", daysBack)); + 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; + } } diff --git a/src/PlanViewer.Core/Services/TimeDisplayHelper.cs b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs new file mode 100644 index 0000000..b5dd608 --- /dev/null +++ b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs @@ -0,0 +1,45 @@ +using System; + +namespace PlanViewer.Core.Services; + +public enum TimeDisplayMode +{ + Local, + Utc, + Server +} + +public static class TimeDisplayHelper +{ + public static TimeDisplayMode Current { get; set; } = TimeDisplayMode.Local; + + /// + /// Offset in minutes from UTC to the connected SQL Server's local time. + /// Set after connecting to a server. + /// + public static int ServerUtcOffsetMinutes { get; set; } + + public static DateTime ConvertForDisplay(DateTime utcTime) + { + return Current switch + { + TimeDisplayMode.Local => utcTime.ToLocalTime(), + TimeDisplayMode.Utc => DateTime.SpecifyKind(utcTime, DateTimeKind.Utc), + TimeDisplayMode.Server => utcTime.AddMinutes(ServerUtcOffsetMinutes), + _ => utcTime.ToLocalTime() + }; + } + + public static string FormatForDisplay(DateTime utcTime, string format = "yyyy-MM-dd HH:mm") + { + return ConvertForDisplay(utcTime).ToString(format); + } + + public static string Suffix => Current switch + { + TimeDisplayMode.Local => "", + TimeDisplayMode.Utc => " (UTC)", + TimeDisplayMode.Server => " (Server)", + _ => "" + }; +} diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj index 25a9b33..3915948 100644 --- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj +++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj @@ -6,7 +6,7 @@ InstallSsmsExtension PlanViewer.Ssms.Installer app.manifest - 1.2.2 + 1.2.3 Erik Darling Darling Data LLC SQL Performance Studio