From b5ab52e16cf335512c60ebb92217365ad7feef32 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:05:19 -0400 Subject: [PATCH] Add per-tab time range slicers to Dashboard query tabs Active Queries, Query Stats, Procedure Stats, Query Store each get their own slicer: - Slicer data aggregated by hourly collection_time buckets - fromSlicer parameter filters by collection_time (not execution time) when slicer is active - Metric label updates on grid sort (total/avg CPU, duration, reads, writes) - Selection preserved on auto-refresh - Grid respects slicer dates during auto-refresh (HasNarrowedSelection check) - Time display mode (Local/UTC/Server) updates slicer labels - Proportional time positioning for accurate data-gap representation - Hour-boundary snapping for accurate filtering - WAITFOR queries excluded from Active Queries slicer - TRY_CAST to money for sp_WhoIsActive varchar metric columns Also bumps Lite slicer height to 130px, Dashboard to 150px. Fixes proportional time positioning and label spacing in both Lite and Dashboard slicers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/QueryPerformanceContent.xaml | 31 +- .../Controls/QueryPerformanceContent.xaml.cs | 325 ++++++++++++++- .../Controls/TimeRangeSlicerControl.xaml | 43 ++ .../Controls/TimeRangeSlicerControl.xaml.cs | 390 ++++++++++++++++++ Dashboard/Models/TimeSliceBucket.cs | 21 + .../DatabaseService.QueryPerformance.cs | 231 ++++++++++- Dashboard/Themes/CoolBreezeTheme.xaml | 11 + Dashboard/Themes/DarkTheme.xaml | 11 + Dashboard/Themes/LightTheme.xaml | 11 + Lite/Controls/TimeRangeSlicerControl.xaml | 2 +- Lite/Controls/TimeRangeSlicerControl.xaml.cs | 46 ++- 11 files changed, 1090 insertions(+), 32 deletions(-) create mode 100644 Dashboard/Controls/TimeRangeSlicerControl.xaml create mode 100644 Dashboard/Controls/TimeRangeSlicerControl.xaml.cs create mode 100644 Dashboard/Models/TimeSliceBucket.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml index 5236029c..2126752a 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml +++ b/Dashboard/Controls/QueryPerformanceContent.xaml @@ -80,7 +80,12 @@ - + + + + + @@ -565,10 +570,16 @@ - + + + + + @@ -859,10 +870,16 @@ - + + + + + @@ -1122,10 +1139,16 @@ - + + + + + diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index fb9a5e82..4e6a0c20 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -225,6 +225,259 @@ public void Initialize(DatabaseService databaseService, Action? statusCa { _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); _statusCallback = statusCallback; + ActiveQueriesSlicer.RangeChanged += OnActiveQueriesSlicerChanged; + QueryStatsSlicer.RangeChanged += OnQueryStatsSlicerChanged; + ProcStatsSlicer.RangeChanged += OnProcStatsSlicerChanged; + QueryStoreSlicer.RangeChanged += OnQueryStoreSlicerChanged; + } + + // ── Active Queries Slicer ── + + private async Task LoadActiveQueriesSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetActiveQuerySlicerDataAsync( + _activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); + if (data.Count > 0) + ActiveQueriesSlicer.LoadData(data, "Sessions"); + } + catch { } + } + + private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + // Dashboard data is in server time; slicer sends server time directly + var data = await _databaseService.GetQuerySnapshotsAsync(0, e.Start, e.End); + _activeQueriesUnfilteredData = data; + ActiveQueriesDataGrid.ItemsSource = data; + ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + catch { } + } + + // ── Query Stats Slicer ── + + private List? _queryStatsSlicerData; + private string _queryStatsSlicerMetric = "TotalCpu"; + + private async Task LoadQueryStatsSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStatsSlicerDataAsync( + _queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); + _queryStatsSlicerData = data; + _queryStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStatsSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStatsAsync(0, e.Start, e.End, fromSlicer: true); + PopulateQueryStatsGrid(data); + } + catch { } + } + + private void QueryStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStatsSlicerData == null || _queryStatsSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b) + col = b.Path.Path; + + var (metric, label) = col switch + { + "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" or "AvgLogicalReads" => ("TotalReads", "Total Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"), + "IntervalExecutions" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStatsSlicerMetric) return; + _queryStatsSlicerMetric = metric; + + foreach (var bucket in _queryStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + QueryStatsSlicer.UpdateMetric(label); + } + + // ── Procedure Stats Slicer ── + + private List? _procStatsSlicerData; + private string _procStatsSlicerMetric = "TotalCpu"; + + private async Task LoadProcStatsSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetProcStatsSlicerDataAsync( + _procStatsHoursBack, _procStatsFromDate, _procStatsToDate); + _procStatsSlicerData = data; + _procStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + ProcStatsSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetProcedureStatsAsync(0, e.Start, e.End, fromSlicer: true); + PopulateProcStatsGrid(data); + } + catch { } + } + + private void ProcStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_procStatsSlicerData == null || _procStatsSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc2 && bc2.Binding is System.Windows.Data.Binding b2) + col = b2.Path.Path; + + var (metric, label) = col switch + { + "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" => ("TotalReads", "Total Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"), + "IntervalExecutions" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _procStatsSlicerMetric) return; + _procStatsSlicerMetric = metric; + + foreach (var bucket in _procStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + ProcStatsSlicer.UpdateMetric(label); + } + + // ── Query Store Slicer ── + + private List? _queryStoreSlicerData; + private string _queryStoreSlicerMetric = "TotalCpu"; + + private async Task LoadQueryStoreSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStoreSlicerDataAsync( + _queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); + _queryStoreSlicerData = data; + _queryStoreSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStoreSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStoreDataAsync(0, e.Start, e.End, fromSlicer: true); + PopulateQueryStoreGrid(data); + } + catch { } + } + + private void QueryStoreDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStoreSlicerData == null || _queryStoreSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc3 && bc3.Binding is System.Windows.Data.Binding b3) + col = b3.Path.Path; + + var (metric, label) = col switch + { + "AvgCpuTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "AvgDurationMs" => ("AvgElapsed", "Avg Duration (ms)"), + "AvgLogicalReads" => ("TotalReads", "Avg Reads"), + "AvgLogicalWrites" => ("TotalWrites", "Avg Writes"), + "AvgPhysicalReads" => ("TotalReads", "Avg Physical Reads"), + "ExecutionCount" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStoreSlicerMetric) return; + _queryStoreSlicerMetric = metric; + + foreach (var bucket in _queryStoreSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + QueryStoreSlicer.UpdateMetric(label); } public void RefreshGridBindings() @@ -236,6 +489,10 @@ public void RefreshGridBindings() ActiveQueriesDataGrid.Items.Refresh(); CurrentActiveQueriesDataGrid.Items.Refresh(); LongRunningQueryPatternsDataGrid.Items.Refresh(); + ActiveQueriesSlicer.Redraw(); + QueryStatsSlicer.Redraw(); + ProcStatsSlicer.Redraw(); + QueryStoreSlicer.Redraw(); } /// @@ -333,9 +590,37 @@ await Task.WhenAll( ); // Populate grids from summary data - PopulateQueryStatsGrid(await queryStatsTask); - PopulateProcStatsGrid(await procStatsTask); - PopulateQueryStoreGrid(await queryStoreTask); + // If slicer is narrowed, re-query with slicer dates instead of global range + if (QueryStatsSlicer.HasNarrowedSelection) + { + var slicerData = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true); + PopulateQueryStatsGrid(slicerData); + } + else + { + PopulateQueryStatsGrid(await queryStatsTask); + } + LoadQueryStatsSlicerAsync().ConfigureAwait(false); + if (ProcStatsSlicer.HasNarrowedSelection) + { + var slicerProcData = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true); + PopulateProcStatsGrid(slicerProcData); + } + else + { + PopulateProcStatsGrid(await procStatsTask); + } + LoadProcStatsSlicerAsync().ConfigureAwait(false); + if (QueryStoreSlicer.HasNarrowedSelection) + { + var slicerQsData = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true); + PopulateQueryStoreGrid(slicerQsData); + } + else + { + PopulateQueryStoreGrid(await queryStoreTask); + } + LoadQueryStoreSlicerAsync().ConfigureAwait(false); // Populate charts from time-series data LoadDurationChart(QueryPerfTrendsQueryChart, await queryDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[0], _queryDurationHover); @@ -373,22 +658,37 @@ private async Task RefreshPerformanceTrendsAsync() private async Task RefreshQueryStatsGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); + List data; + if (QueryStatsSlicer.HasNarrowedSelection) + data = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); PopulateQueryStatsGrid(data); + LoadQueryStatsSlicerAsync().ConfigureAwait(false); } private async Task RefreshProcStatsGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate); + List data; + if (ProcStatsSlicer.HasNarrowedSelection) + data = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate); PopulateProcStatsGrid(data); + LoadProcStatsSlicerAsync().ConfigureAwait(false); } private async Task RefreshQueryStoreGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); + List data; + if (QueryStoreSlicer.HasNarrowedSelection) + data = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); PopulateQueryStoreGrid(data); + LoadQueryStoreSlicerAsync().ConfigureAwait(false); } private void PopulateQueryStatsGrid(List data) @@ -540,10 +840,21 @@ private async Task RefreshActiveQueriesAsync() } SetStatus("Loading active queries..."); - var data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); + // If user has narrowed the slicer, use slicer dates for the grid + List data; + if (ActiveQueriesSlicer.HasNarrowedSelection) + { + data = await _databaseService.GetQuerySnapshotsAsync(0, ActiveQueriesSlicer.SelectionStart, ActiveQueriesSlicer.SelectionEnd); + } + else + { + data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); + } + ActiveQueriesDataGrid.ItemsSource = data; ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; SetStatus($"Loaded {data.Count} query snapshots"); + LoadActiveQueriesSlicerAsync().ConfigureAwait(false); } catch (Exception ex) { diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml b/Dashboard/Controls/TimeRangeSlicerControl.xaml new file mode 100644 index 00000000..d67c1ef3 --- /dev/null +++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs new file mode 100644 index 00000000..b90e09f9 --- /dev/null +++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Controls; + +/// +/// Time range slicer for Dashboard. Timestamps are in SERVER LOCAL TIME +/// (matching Dashboard's collect.* tables which use SYSDATETIME()). +/// +public partial class TimeRangeSlicerControl : UserControl +{ + private List _data = new(); + private string _metricLabel = "Sessions"; + private bool _isExpanded = true; + + private double _rangeStart; + private double _rangeEnd = 1.0; + + private const double HandleWidthPx = 8; + private const double HandleGripWidthPx = 20; + private const double MinRangeNorm = 0.02; + 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; + + /// + /// Fired when the user finishes adjusting the slicer handles. + /// Start/End are in server local time (matching Dashboard data). + /// + public event EventHandler? RangeChanged; + + public TimeRangeSlicerControl() + { + InitializeComponent(); + SlicerBorder.SizeChanged += (_, _) => Redraw(); + IsVisibleChanged += (_, _) => { if (IsVisible) Redraw(); }; + } + + public bool IsExpanded + { + get => _isExpanded; + set + { + _isExpanded = value; + SlicerBorder.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed; + ToggleIcon.Text = _isExpanded ? "▾" : "▸"; + } + } + + public void LoadData(List data, string metricLabel) + { + // Preserve selection if we already have data (auto-refresh) + DateTime? prevStart = null, prevEnd = null; + if (_data.Count > 0 && (_rangeStart > 0 || _rangeEnd < 1.0)) + { + prevStart = TimeAtNorm(_rangeStart); + prevEnd = TimeAtNorm(_rangeEnd); + } + + _data = data; + _metricLabel = metricLabel; + + if (prevStart.HasValue && prevEnd.HasValue && _data.Count >= 2) + { + _rangeStart = NormAtTime(prevStart.Value); + _rangeEnd = NormAtTime(prevEnd.Value); + } + else + { + _rangeStart = 0; + _rangeEnd = 1.0; + } + + UpdateRangeLabel(); + Redraw(); + } + + public void UpdateMetric(string metricLabel) + { + _metricLabel = metricLabel; + Redraw(); + } + + public DateTime? SelectionStart => _data.Count > 0 ? TimeAtNorm(_rangeStart) : null; + public DateTime? SelectionEnd => _data.Count > 0 ? TimeAtNorm(_rangeEnd) : null; + public bool HasNarrowedSelection => _data.Count > 0 && (_rangeStart > 0.01 || _rangeEnd < 0.99); + + private DateTime DataStart => _data[0].BucketTime; + private DateTime DataEnd => _data[^1].BucketTime.AddHours(1); + + private DateTime TimeAtNorm(double norm) + { + var ticks = DataStart.Ticks + (long)((DataEnd.Ticks - DataStart.Ticks) * norm); + return new DateTime(Math.Clamp(ticks, DataStart.Ticks, DataEnd.Ticks)); + } + + private double NormAtTime(DateTime dt) + { + var span = DataEnd.Ticks - DataStart.Ticks; + if (span <= 0) return 0; + return Math.Clamp((double)(dt.Ticks - DataStart.Ticks) / span, 0, 1); + } + + // ── Drawing ── + + public void Redraw() + { + SlicerCanvas.Children.Clear(); + if (_data.Count < 2) return; + + var w = SlicerBorder.ActualWidth; + var h = SlicerBorder.ActualHeight; + if (w <= 0 || h <= 0) return; + + var values = _data.Select(d => d.Value).ToArray(); + 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 linePoints = new List(n); + for (int i = 0; i < n; i++) + { + var x = NormAtTime(_data[i].BucketTime) * w; + var y = chartBottom - (values[i] / max) * chartHeight; + linePoints.Add(new Point(x, y)); + } + + var fillBrush = FindBrush("SlicerChartFillBrush", "#332EAEF1"); + var areaGeo = new StreamGeometry(); + using (var ctx = areaGeo.Open()) + { + ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true, true); + foreach (var pt in linePoints) ctx.LineTo(pt, true, false); + ctx.LineTo(new Point(linePoints[^1].X, chartBottom), true, false); + } + SlicerCanvas.Children.Add(new Path { Data = areaGeo, Fill = fillBrush }); + + var lineBrush = FindBrush("SlicerChartLineBrush", "#2EAEF1"); + var lineGeo = new StreamGeometry(); + using (var ctx = lineGeo.Open()) + { + ctx.BeginFigure(linePoints[0], false, false); + for (int i = 1; i < linePoints.Count; i++) ctx.LineTo(linePoints[i], true, false); + } + SlicerCanvas.Children.Add(new Path { Data = lineGeo, Stroke = lineBrush, StrokeThickness = 1.5 }); + + // X-axis labels — evenly spaced by TIME across the full range, skip if too close + var labelBrush = FindBrush("SlicerLabelBrush", "#99E4E6EB"); + const double minLabelSpacingPx = 90; + double lastLabelX = -minLabelSpacingPx; + int targetLabels = Math.Max(2, (int)(w / minLabelSpacingPx)); + var timeStep = (DataEnd - DataStart).TotalHours / targetLabels; + for (int tick = 0; tick <= targetLabels; tick++) + { + var tickTime = DataStart.AddHours(tick * timeStep); + var x = NormAtTime(tickTime) * w; + if (x - lastLabelX < minLabelSpacingPx) continue; + if (x < 10 || x > w - 40) continue; // avoid edge clipping + var dt = ServerTimeHelper.ConvertForDisplay(tickTime, ServerTimeHelper.CurrentDisplayMode); + var tb = new TextBlock { Text = dt.ToString("MM/dd HH:mm"), FontSize = 9, Foreground = labelBrush }; + Canvas.SetLeft(tb, x - 25); + Canvas.SetTop(tb, chartBottom + 2); + SlicerCanvas.Children.Add(tb); + lastLabelX = x; + } + + var metricBrush = FindBrush("SlicerToggleBrush", "#E4E6EB"); + var metricTb = new TextBlock { Text = _metricLabel, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = metricBrush }; + Canvas.SetLeft(metricTb, w - 120); + Canvas.SetTop(metricTb, 2); + SlicerCanvas.Children.Add(metricTb); + + var overlayBrush = FindBrush("SlicerOverlayBrush", "#99000000"); + var selectedBrush = FindBrush("SlicerSelectedBrush", "#22FFFFFF"); + var handleBrush = FindBrush("SlicerHandleBrush", "#E4E6EB"); + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + if (selLeft > 0) AddRect(0, 0, selLeft, h, overlayBrush); + if (selRight < w) AddRect(selRight, 0, w - selRight, h, overlayBrush); + AddRect(selLeft, 0, Math.Max(0, selRight - selLeft), h, selectedBrush); + + DrawHandle(selLeft, h, handleBrush); + DrawHandle(selRight - HandleWidthPx, h, handleBrush); + AddLine(selLeft, 0, selRight, 0, handleBrush, 0.5); + AddLine(selLeft, h, selRight, h, handleBrush, 0.5); + } + + private void AddRect(double x, double y, double width, double height, Brush fill) + { + var rect = new Rectangle { Width = width, Height = height, Fill = fill }; + Canvas.SetLeft(rect, x); Canvas.SetTop(rect, y); + SlicerCanvas.Children.Add(rect); + } + + private void AddLine(double x1, double y1, double x2, double y2, Brush stroke, double opacity) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, + Stroke = stroke, StrokeThickness = 1, Opacity = opacity + }); + } + + private void DrawHandle(double x, double canvasHeight, Brush brush) + { + AddRect(x, 0, HandleWidthPx, canvasHeight, brush); + ((Rectangle)SlicerCanvas.Children[^1]).Opacity = 0.7; + var midY = canvasHeight / 2; + for (int i = -1; i <= 1; i++) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x + 2, Y1 = midY + i * 5, X2 = x + HandleWidthPx - 2, Y2 = midY + i * 5, + Stroke = Brushes.Black, StrokeThickness = 1, Opacity = 0.6 + }); + } + } + + private Brush FindBrush(string key, string fallbackHex) + { + if (TryFindResource(key) is Brush b) return b; + return new SolidColorBrush((Color)ColorConverter.ConvertFromString(fallbackHex)); + } + + // ── Range label ── + + private void UpdateRangeLabel() + { + if (_data.Count == 0) { RangeLabel.Text = ""; return; } + var start = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeStart), ServerTimeHelper.CurrentDisplayMode); + var end = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeEnd), ServerTimeHelper.CurrentDisplayMode); + var span = end - start; + RangeLabel.Text = $"{start:yyyy-MM-dd HH:mm} \u2192 {end:yyyy-MM-dd HH:mm} ({span.TotalHours:F0}h)"; + } + + // ── Mouse interaction ── + + private void Toggle_Click(object sender, RoutedEventArgs e) => IsExpanded = !IsExpanded; + + private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + _dragOriginX = pos.X; + _dragOriginRangeStart = _rangeStart; + _dragOriginRangeEnd = _rangeEnd; + + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) + { _dragMode = DragMode.DragStart; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { _dragMode = DragMode.DragEnd; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (pos.X >= selLeft && pos.X <= selRight) + { _dragMode = DragMode.MoveRange; SlicerCanvas.CaptureMouse(); e.Handled = true; } + } + + private void Canvas_MouseMove(object sender, MouseEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + + if (_dragMode == DragMode.None) + { + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + SlicerCanvas.Cursor = Cursors.SizeWE; + else if (pos.X >= selLeft && pos.X <= selRight) + SlicerCanvas.Cursor = Cursors.SizeAll; + else + SlicerCanvas.Cursor = Cursors.Arrow; + return; + } + + var deltaNorm = (pos.X - _dragOriginX) / w; + switch (_dragMode) + { + case DragMode.DragStart: + _rangeStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - MinRangeNorm); + break; + case DragMode.DragEnd: + _rangeEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + MinRangeNorm, 1); + 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_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (_dragMode != DragMode.None) + { + _dragMode = DragMode.None; + SlicerCanvas.ReleaseMouseCapture(); + FireRangeChanged(); + e.Handled = true; + } + } + + private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e) + { + if (_data.Count < 2) return; + if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + + var pos = e.GetPosition(SlicerCanvas); + var pivot = Math.Clamp(pos.X / w, 0, 1); + var span = _rangeEnd - _rangeStart; + + var zoomFactor = e.Delta > 0 ? 0.85 : 1.0 / 0.85; + var newSpan = Math.Clamp(span * zoomFactor, MinRangeNorm, 1.0); + + 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 FireRangeChanged() + { + if (_data.Count == 0) return; + // Snap to hour boundaries so slider positions align with hourly buckets + var start = FloorToHour(TimeAtNorm(_rangeStart)); + var end = CeilToHour(TimeAtNorm(_rangeEnd)); + RangeChanged?.Invoke(this, new SlicerRangeEventArgs(start, end)); + } + + private static DateTime FloorToHour(DateTime dt) => + new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind); + + private static DateTime CeilToHour(DateTime dt) + { + var floored = FloorToHour(dt); + return floored == dt ? dt : floored.AddHours(1); + } +} + +public class SlicerRangeEventArgs : EventArgs +{ + public DateTime Start { get; } + public DateTime End { get; } + public SlicerRangeEventArgs(DateTime start, DateTime end) { Start = start; End = end; } +} diff --git a/Dashboard/Models/TimeSliceBucket.cs b/Dashboard/Models/TimeSliceBucket.cs new file mode 100644 index 00000000..69e35a6f --- /dev/null +++ b/Dashboard/Models/TimeSliceBucket.cs @@ -0,0 +1,21 @@ +using System; + +namespace PerformanceMonitorDashboard.Models; + +/// +/// One hourly bucket of aggregated metrics for a time-range slicer. +/// In Dashboard, timestamps are in server local time (matching collect.* tables). +/// +public class TimeSliceBucket +{ + public DateTime BucketTime { get; set; } + public long SessionCount { get; set; } + public double TotalCpu { get; set; } + public double TotalElapsed { get; set; } + public double TotalReads { get; set; } + public double TotalLogicalReads { get; set; } + public double TotalWrites { get; set; } + + /// The display value used by the slicer chart. Set by the caller based on sort column. + public double Value { get; set; } +} diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index 29c00c18..e49e752a 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -505,6 +505,57 @@ ORDER BY return items; } + public async Task> GetActiveQuerySlicerDataAsync( + int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + var timeFilter = fromDate.HasValue && toDate.HasValue + ? "AND qs.collection_time >= @from_date AND qs.collection_time <= @to_date" + : "AND qs.collection_time >= DATEADD(HOUR, -@hours_back, SYSDATETIME())"; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + DATEADD(HOUR, DATEDIFF(HOUR, 0, qs.collection_time), 0) AS bucket_hour, + COUNT(*) AS session_count, + ISNULL(SUM(TRY_CAST(qs.CPU AS money)), 0) AS total_cpu, + ISNULL(SUM(TRY_CAST(qs.CPU AS money)), 0) AS total_elapsed, + ISNULL(SUM(TRY_CAST(qs.reads AS money)), 0) AS total_reads, + ISNULL(SUM(TRY_CAST(qs.physical_reads AS money)), 0) AS total_physical_reads, + ISNULL(SUM(TRY_CAST(qs.writes AS money)), 0) AS total_writes +FROM report.query_snapshots AS qs +WHERE CONVERT(nvarchar(max), qs.sql_text) NOT LIKE N'WAITFOR%' +{timeFilter} +GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, qs.collection_time), 0) +ORDER BY bucket_hour;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 120 }; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new Models.TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = Convert.ToInt64(reader.GetValue(1)), + TotalCpu = Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = Convert.ToDouble(reader.GetValue(3)), + TotalReads = Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = Convert.ToDouble(reader.GetValue(5)), + TotalWrites = Convert.ToDouble(reader.GetValue(6)), + Value = Convert.ToDouble(reader.GetValue(1)), + }); + } + return items; + } + public async Task> GetQuerySnapshotsAsync(int hoursBack = 1, DateTime? fromDate = null, DateTime? toDate = null) { var items = new List(); @@ -847,7 +898,58 @@ ORDER BY return items; } - public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetQueryStatsSlicerDataAsync( + int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + var timeFilter = fromDate.HasValue && toDate.HasValue + ? "AND qs.collection_time >= @from_date AND qs.collection_time <= @to_date" + : "AND qs.collection_time >= DATEADD(HOUR, -@hours_back, SYSDATETIME())"; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + DATEADD(HOUR, DATEDIFF(HOUR, 0, qs.collection_time), 0) AS bucket_hour, + COUNT(DISTINCT qs.query_hash) AS query_count, + ISNULL(SUM(CAST(qs.total_worker_time AS float)), 0) / 1000.0 AS total_cpu_ms, + ISNULL(SUM(CAST(qs.total_elapsed_time AS float)), 0) / 1000.0 AS total_elapsed_ms, + ISNULL(SUM(CAST(qs.total_logical_reads AS float)), 0) AS total_reads, + ISNULL(SUM(CAST(qs.total_physical_reads AS float)), 0) AS total_physical_reads, + ISNULL(SUM(CAST(qs.total_logical_writes AS float)), 0) AS total_writes +FROM collect.query_stats AS qs +WHERE 1 = 1 +{timeFilter} +GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, qs.collection_time), 0) +ORDER BY bucket_hour;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 120 }; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new Models.TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = Convert.ToInt64(reader.GetValue(1)), + TotalCpu = Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = Convert.ToDouble(reader.GetValue(3)), + TotalReads = Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = Convert.ToDouble(reader.GetValue(5)), + TotalWrites = Convert.ToDouble(reader.GetValue(6)), + Value = Convert.ToDouble(reader.GetValue(2)), + }); + } + return items; + } + + public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, bool fromSlicer = false) { var items = new List(); @@ -903,9 +1005,11 @@ WITH per_lifetime AS plan_handle = MAX(qs.plan_handle) FROM collect.query_stats AS qs WHERE ( - (@useCustomDates = 0 AND qs.last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) + (@fromSlicer = 1 AND qs.collection_time >= @fromDate AND qs.collection_time <= @toDate) OR - (@useCustomDates = 1 AND + (@fromSlicer = 0 AND @useCustomDates = 0 AND qs.last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) + OR + (@fromSlicer = 0 AND @useCustomDates = 1 AND ((qs.creation_time >= @fromDate AND qs.creation_time <= @toDate) OR (qs.last_execution_time >= @fromDate AND qs.last_execution_time <= @toDate) OR (qs.creation_time <= @fromDate AND qs.last_execution_time >= @toDate))) @@ -977,6 +1081,7 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE') using var command = new SqlCommand(query, connection); command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@fromSlicer", SqlDbType.Bit) { Value = fromSlicer }); command.Parameters.Add(new SqlParameter("@useCustomDates", SqlDbType.Bit) { Value = useCustomDates }); command.Parameters.Add(new SqlParameter("@hoursBack", SqlDbType.Int) { Value = hoursBack }); command.Parameters.Add(new SqlParameter("@fromDate", SqlDbType.DateTime2) { Value = (object?)fromDate ?? DBNull.Value }); @@ -1032,7 +1137,58 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE') return items; } - public async Task> GetProcedureStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetProcStatsSlicerDataAsync( + int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + var timeFilter = fromDate.HasValue && toDate.HasValue + ? "AND ps.collection_time >= @from_date AND ps.collection_time <= @to_date" + : "AND ps.collection_time >= DATEADD(HOUR, -@hours_back, SYSDATETIME())"; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + DATEADD(HOUR, DATEDIFF(HOUR, 0, ps.collection_time), 0) AS bucket_hour, + COUNT(DISTINCT ps.object_name) AS proc_count, + ISNULL(SUM(CAST(ps.total_worker_time AS float)), 0) / 1000.0 AS total_cpu_ms, + ISNULL(SUM(CAST(ps.total_elapsed_time AS float)), 0) / 1000.0 AS total_elapsed_ms, + ISNULL(SUM(CAST(ps.total_logical_reads AS float)), 0) AS total_reads, + ISNULL(SUM(CAST(ps.total_physical_reads AS float)), 0) AS total_physical_reads, + ISNULL(SUM(CAST(ps.total_logical_writes AS float)), 0) AS total_writes +FROM collect.procedure_stats AS ps +WHERE 1 = 1 +{timeFilter} +GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, ps.collection_time), 0) +ORDER BY bucket_hour;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 120 }; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new Models.TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = Convert.ToInt64(reader.GetValue(1)), + TotalCpu = Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = Convert.ToDouble(reader.GetValue(3)), + TotalReads = Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = Convert.ToDouble(reader.GetValue(5)), + TotalWrites = Convert.ToDouble(reader.GetValue(6)), + Value = Convert.ToDouble(reader.GetValue(2)), + }); + } + return items; + } + + public async Task> GetProcedureStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, bool fromSlicer = false) { var items = new List(); @@ -1084,9 +1240,11 @@ WITH per_lifetime AS plan_handle = MAX(ps.plan_handle) FROM collect.procedure_stats AS ps WHERE ( - (@useCustomDates = 0 AND ps.last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) + (@fromSlicer = 1 AND ps.collection_time >= @fromDate AND ps.collection_time <= @toDate) OR - (@useCustomDates = 1 AND + (@fromSlicer = 0 AND @useCustomDates = 0 AND ps.last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) + OR + (@fromSlicer = 0 AND @useCustomDates = 1 AND ((ps.cached_time >= @fromDate AND ps.cached_time <= @toDate) OR (ps.last_execution_time >= @fromDate AND ps.last_execution_time <= @toDate) OR (ps.cached_time <= @fromDate AND ps.last_execution_time >= @toDate))) @@ -1152,6 +1310,7 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE') using var command = new SqlCommand(query, connection); command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@fromSlicer", SqlDbType.Bit) { Value = fromSlicer }); command.Parameters.Add(new SqlParameter("@useCustomDates", SqlDbType.Bit) { Value = useCustomDates }); command.Parameters.Add(new SqlParameter("@hoursBack", SqlDbType.Int) { Value = hoursBack }); command.Parameters.Add(new SqlParameter("@fromDate", SqlDbType.DateTime2) { Value = (object?)fromDate ?? DBNull.Value }); @@ -1205,7 +1364,58 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE') return items; } - public async Task> GetQueryStoreDataAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetQueryStoreSlicerDataAsync( + int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + var timeFilter = fromDate.HasValue && toDate.HasValue + ? "AND qsd.collection_time >= @from_date AND qsd.collection_time <= @to_date" + : "AND qsd.collection_time >= DATEADD(HOUR, -@hours_back, SYSDATETIME())"; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + DATEADD(HOUR, DATEDIFF(HOUR, 0, qsd.collection_time), 0) AS bucket_hour, + COUNT(DISTINCT qsd.query_id) AS query_count, + ISNULL(SUM(qsd.avg_cpu_time * qsd.count_executions), 0) / 1000.0 AS total_cpu_ms, + ISNULL(SUM(qsd.avg_duration * qsd.count_executions), 0) / 1000.0 AS total_duration_ms, + ISNULL(SUM(qsd.avg_logical_io_reads * qsd.count_executions), 0) AS total_reads, + ISNULL(SUM(qsd.avg_physical_io_reads * qsd.count_executions), 0) AS total_physical_reads, + ISNULL(SUM(qsd.avg_logical_io_writes * qsd.count_executions), 0) AS total_writes +FROM collect.query_store_data AS qsd +WHERE CAST(DECOMPRESS(qsd.query_sql_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%' +{timeFilter} +GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, qsd.collection_time), 0) +ORDER BY bucket_hour;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 120 }; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new Models.TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = Convert.ToInt64(reader.GetValue(1)), + TotalCpu = Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = Convert.ToDouble(reader.GetValue(3)), + TotalReads = Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = Convert.ToDouble(reader.GetValue(5)), + TotalWrites = Convert.ToDouble(reader.GetValue(6)), + Value = Convert.ToDouble(reader.GetValue(2)), + }); + } + return items; + } + + public async Task> GetQueryStoreDataAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, bool fromSlicer = false) { var items = new List(); @@ -1271,9 +1481,11 @@ public async Task> GetQueryStoreDataAsync(int hoursBack = 2 max_log_bytes_used = MAX(qsd.max_log_bytes_used) FROM collect.query_store_data AS qsd WHERE ( - (@useCustomDates = 0 AND qsd.server_last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) + (@fromSlicer = 1 AND qsd.collection_time >= @fromDate AND qsd.collection_time <= @toDate) + OR + (@fromSlicer = 0 AND @useCustomDates = 0 AND qsd.server_last_execution_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())) OR - (@useCustomDates = 1 AND + (@fromSlicer = 0 AND @useCustomDates = 1 AND ((qsd.server_first_execution_time >= @fromDate AND qsd.server_first_execution_time <= @toDate) OR (qsd.server_last_execution_time >= @fromDate AND qsd.server_last_execution_time <= @toDate) OR (qsd.server_first_execution_time <= @fromDate AND qsd.server_last_execution_time >= @toDate))) @@ -1294,6 +1506,7 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE') using var command = new SqlCommand(query, connection); command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@fromSlicer", SqlDbType.Bit) { Value = fromSlicer }); command.Parameters.Add(new SqlParameter("@useCustomDates", SqlDbType.Bit) { Value = useCustomDates }); command.Parameters.Add(new SqlParameter("@hoursBack", SqlDbType.Int) { Value = hoursBack }); command.Parameters.Add(new SqlParameter("@fromDate", SqlDbType.DateTime2) { Value = (object?)fromDate ?? DBNull.Value }); diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 71b7078b..ab50dea2 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -1258,6 +1258,17 @@