From 1b05bae6b0887952ff3c6aeda09e565a197b4a60 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:17:27 -0400 Subject: [PATCH 1/2] Add correlated timeline lanes to Lite Overview tab (#688) Replaces the 2x2 chart grid with 5 vertically stacked lanes sharing a synchronized time axis and crosshair: CPU, Wait Stats, Blocking & Deadlocking, Memory, and I/O Latency. Hovering any lane shows values from all lanes at that time point. New files: - CorrelatedTimelineLanesControl (XAML + code-behind) - CorrelatedCrosshairManager (cross-chart sync with multi-series support) - GetTotalWaitTrendAsync (aggregated wait stats query) Also fixes GetDeadlockTrendAsync granularity from hourly to per-minute buckets to match blocking trend. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CorrelatedTimelineLanesControl.xaml | 100 +++++ .../CorrelatedTimelineLanesControl.xaml.cs | 380 ++++++++++++++++++ Lite/Controls/ServerTab.xaml | 25 +- Lite/Controls/ServerTab.xaml.cs | 172 +------- Lite/Helpers/CorrelatedCrosshairManager.cs | 309 ++++++++++++++ Lite/Services/LocalDataService.Blocking.cs | 6 +- Lite/Services/LocalDataService.WaitStats.cs | 49 +++ 7 files changed, 848 insertions(+), 193 deletions(-) create mode 100644 Lite/Controls/CorrelatedTimelineLanesControl.xaml create mode 100644 Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs create mode 100644 Lite/Helpers/CorrelatedCrosshairManager.cs diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml b/Lite/Controls/CorrelatedTimelineLanesControl.xaml new file mode 100644 index 00000000..ef273bac --- /dev/null +++ b/Lite/Controls/CorrelatedTimelineLanesControl.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs new file mode 100644 index 00000000..5f4e01d8 --- /dev/null +++ b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + * + * SYNC WARNING: Dashboard has a matching copy at Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs. + * Changes here must be mirrored there. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class CorrelatedTimelineLanesControl : UserControl +{ + private LocalDataService? _dataService; + private int _serverId; + private CorrelatedCrosshairManager? _crosshairManager; + private bool _isRefreshing; + + public CorrelatedTimelineLanesControl() + { + InitializeComponent(); + Unloaded += (_, _) => _crosshairManager?.Dispose(); + } + + /// + /// Initializes the control with the data service and server ID. + /// Must be called before RefreshAsync. + /// + public void Initialize(LocalDataService dataService, int serverId) + { + _dataService = dataService; + _serverId = serverId; + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + ApplyTheme(chart); + // Disable zoom/pan/drag but keep mouse events for crosshair + chart.UserInputProcessor.UserActionResponses.Clear(); + } + + _crosshairManager = new CorrelatedCrosshairManager(); + _crosshairManager.AddLane(CpuChart, "CPU", "%", CpuValueLabel); + _crosshairManager.AddLane(WaitStatsChart, "Wait Stats", "ms/sec", WaitStatsValueLabel); + _crosshairManager.AddLane(BlockingChart, "Blocking", "events", BlockingValueLabel); + _crosshairManager.AddLane(MemoryChart, "Memory", "MB", MemoryValueLabel); + _crosshairManager.AddLane(FileIoChart, "I/O Latency", "ms", FileIoValueLabel); + } + + /// + /// Refreshes all lane data for the given time range. + /// + public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + if (_dataService == null || _isRefreshing) return; + _isRefreshing = true; + + try + { + _crosshairManager?.PrepareForRefresh(); + + var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate); + var waitTask = _dataService.GetTotalWaitTrendAsync(_serverId, hoursBack, fromDate, toDate); + var blockingTask = _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate); + var deadlockTask = _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate); + var memoryTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate); + var fileIoTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate); + + try + { + await Task.WhenAll(cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fileIoTask); + } + catch (Exception ex) + { + AppLogger.Info("CorrelatedLanes", $"Data fetch failed: {ex.Message}"); + } + + var utcOffset = ServerTimeHelper.UtcOffsetMinutes; + + if (cpuTask.IsCompletedSuccessfully) + UpdateLane(CpuChart, "CPU %", + cpuTask.Result.Select(d => (d.SampleTime.ToOADate(), (double)d.SqlServerCpu)).ToList(), + "#4FC3F7", 0, 105); + else + ShowEmpty(CpuChart, "CPU %"); + + if (waitTask.IsCompletedSuccessfully) + UpdateLane(WaitStatsChart, "Wait ms/sec", + waitTask.Result.Select(d => (d.CollectionTime.AddMinutes(utcOffset).ToOADate(), d.WaitTimeMsPerSecond)).ToList(), + "#FFB74D"); + else + ShowEmpty(WaitStatsChart, "Wait ms/sec"); + + { + var blockingData = blockingTask.IsCompletedSuccessfully + ? blockingTask.Result.Select(d => (d.Time.AddMinutes(utcOffset).ToOADate(), (double)d.Count)).ToList() + : new List<(double, double)>(); + var deadlockData = deadlockTask.IsCompletedSuccessfully + ? deadlockTask.Result.Select(d => (d.Time.AddMinutes(utcOffset).ToOADate(), (double)d.Count)).ToList() + : new List<(double, double)>(); + UpdateBlockingLane(blockingData, deadlockData); + } + + if (memoryTask.IsCompletedSuccessfully) + UpdateLane(MemoryChart, "Memory MB", + memoryTask.Result.Select(d => (d.CollectionTime.AddMinutes(utcOffset).ToOADate(), d.BufferPoolMb)).ToList(), + "#CE93D8"); + else + ShowEmpty(MemoryChart, "Memory MB"); + + if (fileIoTask.IsCompletedSuccessfully) + { + var ioGrouped = fileIoTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.AddMinutes(utcOffset).ToOADate(), g.Average(x => x.AvgReadLatencyMs))) + .ToList(); + UpdateLane(FileIoChart, "I/O ms", ioGrouped, "#81C784"); + } + else + ShowEmpty(FileIoChart, "I/O ms"); + + _crosshairManager?.ReattachVLines(); + SyncXAxes(hoursBack, fromDate, toDate, utcOffset); + } + finally + { + _isRefreshing = false; + } + } + + private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, + List<(double Time, double Value)> deadlockData) + { + ClearChart(BlockingChart); + ApplyTheme(BlockingChart); + + // Register blocking and deadlock as separate named series for the tooltip + var blockTimes = blockingData.Select(d => d.Time).ToArray(); + var blockValues = blockingData.Select(d => d.Value).ToArray(); + var deadTimes = deadlockData.Select(d => d.Time).ToArray(); + var deadValues = deadlockData.Select(d => d.Value).ToArray(); + + // First series clears any previous data + _crosshairManager?.SetLaneData(BlockingChart, blockTimes, blockValues, isEventBased: true); + // Rename the auto-created series and add the second + _crosshairManager?.AddLaneSeries(BlockingChart, "Deadlocks", "events", + deadTimes, deadValues, isEventBased: true); + + if (blockingData.Count == 0 && deadlockData.Count == 0) + { + ShowEmpty(BlockingChart, "Block/Dead"); + return; + } + + double barWidth = 30.0 / 86400.0; + double maxCount = 0; + + // Blocking bars — red + if (blockingData.Count > 0) + { + var bars = blockingData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth, + FillColor = ScottPlot.Color.FromHex("#E57373"), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, blockingData.Max(d => d.Value)); + } + + // Deadlock bars — yellow/amber, slightly narrower so both are visible + if (deadlockData.Count > 0) + { + var bars = deadlockData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth * 0.6, + FillColor = ScottPlot.Color.FromHex("#FFD54F"), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, deadlockData.Max(d => d.Value)); + } + + BlockingChart.Plot.Axes.DateTimeTicksBottom(); + BlockingChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + ReapplyAxisColors(BlockingChart); + + BlockingChart.Plot.Title(""); + BlockingChart.Plot.YLabel(""); + BlockingChart.Plot.Legend.IsVisible = false; + BlockingChart.Plot.Axes.Margins(bottom: 0); + BlockingChart.Plot.Axes.SetLimitsY(0, Math.Max(maxCount * 1.3, 2)); + + BlockingChart.Refresh(); + } + + private void UpdateLane(ScottPlot.WPF.WpfPlot chart, string title, + List<(double Time, double Value)> data, string colorHex, + double? yMin = null, double? yMax = null) + { + ClearChart(chart); + ApplyTheme(chart); + + if (data.Count == 0) + { + ShowEmpty(chart, title); + return; + } + + var times = data.Select(d => d.Time).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + var scatter = chart.Plot.Add.Scatter(times, values); + scatter.Color = ScottPlot.Color.FromHex(colorHex); + scatter.MarkerSize = 0; + scatter.LineWidth = 1.5f; + scatter.LegendText = title; + scatter.ConnectStyle = ScottPlot.ConnectStyle.Straight; + + _crosshairManager?.SetLaneData(chart, times, values); + + chart.Plot.Axes.DateTimeTicksBottom(); + // Hide bottom tick labels on all lanes except the last (File I/O) + if (chart != FileIoChart) + chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + + ReapplyAxisColors(chart); + + // Compact layout: hide Y label, minimize title, no legend + chart.Plot.Title(""); + chart.Plot.YLabel(""); + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + + if (yMin.HasValue && yMax.HasValue) + chart.Plot.Axes.SetLimitsY(yMin.Value, yMax.Value); + else + { + var maxVal = data.Max(d => d.Value); + var minVal = data.Min(d => d.Value); + var padding = Math.Max((maxVal - minVal) * 0.1, 1); + chart.Plot.Axes.SetLimitsY(Math.Max(0, minVal - padding), maxVal + padding); + } + + chart.Refresh(); + } + + /// + /// Sets identical X-axis limits across all lanes. + /// + private void SyncXAxes(int hoursBack, DateTime? fromDate, DateTime? toDate, double utcOffset) + { + DateTime xStart, xEnd; + if (fromDate.HasValue && toDate.HasValue) + { + xStart = fromDate.Value; + xEnd = toDate.Value; + } + else + { + xEnd = DateTime.UtcNow.AddMinutes(utcOffset); + xStart = xEnd.AddHours(-hoursBack); + } + + double xMin = xStart.ToOADate(); + double xMax = xEnd.ToOADate(); + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Refresh(); + } + } + + private static void ClearChart(ScottPlot.WPF.WpfPlot chart) + { + chart.Reset(); + chart.Plot.Clear(); + } + + private static void ShowEmpty(ScottPlot.WPF.WpfPlot chart, string title) + { + ReapplyAxisColors(chart); + var text = chart.Plot.Add.Text($"{title}\nNo Data", 0, 0); + text.LabelFontColor = ScottPlot.Color.FromHex("#888888"); + text.LabelFontSize = 12; + text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + chart.Plot.HideGrid(); + chart.Plot.Axes.SetLimitsX(-1, 1); + chart.Plot.Axes.SetLimitsY(-1, 1); + chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Legend.IsVisible = false; + chart.Refresh(); + } + + /// + /// Reapplies theme to all lane charts (call on theme change). + /// + public void ReapplyTheme() + { + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + ApplyTheme(chart); + chart.Refresh(); + } + } + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) + { + ScottPlot.Color figureBackground, dataBackground, textColor, gridColor; + + if (ThemeManager.CurrentTheme == "CoolBreeze") + { + figureBackground = ScottPlot.Color.FromHex("#EEF4FA"); + dataBackground = ScottPlot.Color.FromHex("#DAE6F0"); + textColor = ScottPlot.Color.FromHex("#364D61"); + gridColor = ScottPlot.Color.FromHex("#A8BDD0").WithAlpha(120); + } + else if (ThemeManager.HasLightBackground) + { + figureBackground = ScottPlot.Color.FromHex("#FFFFFF"); + dataBackground = ScottPlot.Color.FromHex("#F5F7FA"); + textColor = ScottPlot.Color.FromHex("#4A5568"); + gridColor = ScottPlot.Colors.Black.WithAlpha(20); + } + else + { + figureBackground = ScottPlot.Color.FromHex("#22252b"); + dataBackground = ScottPlot.Color.FromHex("#111217"); + textColor = ScottPlot.Color.FromHex("#9DA5B4"); + gridColor = ScottPlot.Colors.White.WithAlpha(40); + } + + chart.Plot.FigureBackground.Color = figureBackground; + chart.Plot.DataBackground.Color = dataBackground; + chart.Plot.Axes.Color(textColor); + chart.Plot.Grid.MajorLineColor = gridColor; + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Bottom.Label.ForeColor = textColor; + chart.Plot.Axes.Left.Label.ForeColor = textColor; + + chart.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(figureBackground.R, figureBackground.G, figureBackground.B)); + } + + private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart) + { + var textColor = ThemeManager.CurrentTheme == "CoolBreeze" + ? ScottPlot.Color.FromHex("#364D61") + : ThemeManager.HasLightBackground + ? ScottPlot.Color.FromHex("#4A5568") + : ScottPlot.Color.FromHex("#9DA5B4"); + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; + chart.Plot.Axes.Bottom.Label.ForeColor = textColor; + chart.Plot.Axes.Left.Label.ForeColor = textColor; + } +} diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index daa23057..37e079c1 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -129,30 +129,9 @@ - + - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index f3261a5a..5f6869de 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -46,10 +46,6 @@ public partial class ServerTab : UserControl private List _perfmonCounterItems = new(); private Helpers.ChartHoverHelper? _waitStatsHover; private Helpers.ChartHoverHelper? _perfmonHover; - private Helpers.ChartHoverHelper? _overviewCpuHover; - private Helpers.ChartHoverHelper? _overviewMemoryHover; - private Helpers.ChartHoverHelper? _overviewFileIoHover; - private Helpers.ChartHoverHelper? _overviewWaitStatsHover; private Helpers.ChartHoverHelper? _cpuHover; private Helpers.ChartHoverHelper? _memoryHover; private Helpers.ChartHoverHelper? _tempDbHover; @@ -203,10 +199,6 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe } /* Apply theme immediately so charts don't flash white before data loads */ - ApplyTheme(OverviewCpuChart); - ApplyTheme(OverviewMemoryChart); - ApplyTheme(OverviewFileIoChart); - ApplyTheme(OverviewWaitStatsChart); ApplyTheme(WaitStatsChart); ApplyTheme(QueryDurationTrendChart); ApplyTheme(ProcDurationTrendChart); @@ -233,10 +225,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe ApplyTheme(QueryHeatmapChart); /* Chart hover tooltips */ - _overviewCpuHover = new Helpers.ChartHoverHelper(OverviewCpuChart, "%"); - _overviewMemoryHover = new Helpers.ChartHoverHelper(OverviewMemoryChart, "MB"); - _overviewFileIoHover = new Helpers.ChartHoverHelper(OverviewFileIoChart, "ms"); - _overviewWaitStatsHover = new Helpers.ChartHoverHelper(OverviewWaitStatsChart, "ms/sec"); + CorrelatedLanes.Initialize(_dataService, _serverId); _waitStatsHover = new Helpers.ChartHoverHelper(WaitStatsChart, "ms/sec"); _perfmonHover = new Helpers.ChartHoverHelper(PerfmonChart, ""); _cpuHover = new Helpers.ChartHoverHelper(CpuChart, "%"); @@ -325,16 +314,6 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe Helpers.ContextMenuHelper.SetupChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends"); Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends"); Helpers.ContextMenuHelper.SetupChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends"); - /* Overview chart context menus */ - var ovCpuMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(OverviewCpuChart, "Overview_CPU"); - AddChartDrillDownMenuItem(OverviewCpuChart, ovCpuMenu, _overviewCpuHover, "Show Active Queries at This Time", OnCpuDrillDown); - var ovMemMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(OverviewMemoryChart, "Overview_Memory"); - AddChartDrillDownMenuItem(OverviewMemoryChart, ovMemMenu, _overviewMemoryHover, "Show Active Queries at This Time", OnMemoryDrillDown); - var ovIoMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(OverviewFileIoChart, "Overview_FileIO"); - AddChartDrillDownMenuItem(OverviewFileIoChart, ovIoMenu, _overviewFileIoHover, "Show Active Queries at This Time", OnCpuDrillDown); - var ovWaitMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(OverviewWaitStatsChart, "Overview_WaitStats"); - AddChartDrillDownMenuItem(OverviewWaitStatsChart, ovWaitMenu, _overviewWaitStatsHover, "Show Active Queries at This Time", OnCpuDrillDown); - var cpuMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CpuChart, "CPU_Usage"); AddChartDrillDownMenuItem(CpuChart, cpuMenu, _cpuHover, "Show Active Queries at This Time", OnCpuDrillDown); var memoryMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryChart, "Memory_Usage"); @@ -1114,24 +1093,12 @@ await System.Threading.Tasks.Task.WhenAll( } /// Tab 3 — CPU - /// Tab 0 — Overview (4 charts: CPU, Memory, File I/O, Wait Stats) + /// Tab 0 — Overview (Correlated Timeline Lanes) private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try { - var cpuTask = SafeQueryAsync(() => _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate)); - var memoryTask = SafeQueryAsync(() => _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate)); - var fileIoTask = SafeQueryAsync(() => _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate)); - - // Get top 5 wait types then fetch trends for each - var waitStats = await SafeQueryAsync(() => _dataService.GetWaitStatsAsync(_serverId, hoursBack, fromDate, toDate)); - var topWaits = waitStats.Take(5).Select(w => w.WaitType).ToList(); - await System.Threading.Tasks.Task.WhenAll(cpuTask, memoryTask, fileIoTask); - - UpdateOverviewCpuChart(cpuTask.Result); - UpdateOverviewMemoryChart(memoryTask.Result); - UpdateOverviewFileIoChart(fileIoTask.Result); - await UpdateOverviewWaitStatsChartAsync(topWaits, hoursBack, fromDate, toDate); + await CorrelatedLanes.RefreshAsync(hoursBack, fromDate, toDate); } catch (Exception ex) { @@ -1139,133 +1106,6 @@ private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, Da } } - private void UpdateOverviewCpuChart(List data) - { - ClearChart(OverviewCpuChart); - _overviewCpuHover?.Clear(); - ApplyTheme(OverviewCpuChart); - - if (data.Count == 0) { RefreshEmptyChart(OverviewCpuChart, "CPU Utilization", "CPU %"); return; } - - var times = data.Select(d => d.SampleTime.ToOADate()).ToArray(); - var sqlCpu = data.Select(d => (double)d.SqlServerCpu).ToArray(); - - var plot = OverviewCpuChart.Plot.Add.Scatter(times, sqlCpu); - plot.LegendText = "SQL CPU %"; - plot.Color = ScottPlot.Color.FromHex("#4FC3F7"); - _overviewCpuHover?.Add(plot, "SQL CPU %"); - - OverviewCpuChart.Plot.Axes.DateTimeTicksBottom(); - ReapplyAxisColors(OverviewCpuChart); - OverviewCpuChart.Plot.Title("CPU Utilization"); - OverviewCpuChart.Plot.YLabel("CPU %"); - OverviewCpuChart.Plot.Axes.SetLimitsY(0, 105); - ShowChartLegend(OverviewCpuChart); - OverviewCpuChart.Refresh(); - } - - private void UpdateOverviewMemoryChart(List data) - { - ClearChart(OverviewMemoryChart); - _overviewMemoryHover?.Clear(); - ApplyTheme(OverviewMemoryChart); - - if (data.Count == 0) { RefreshEmptyChart(OverviewMemoryChart, "Memory Utilization", "MB"); return; } - - var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var bufferPool = data.Select(d => d.BufferPoolMb).ToArray(); - var grants = data.Select(d => d.TotalGrantedMb).ToArray(); - - var bpPlot = OverviewMemoryChart.Plot.Add.Scatter(times, bufferPool); - bpPlot.LegendText = "Buffer Pool"; - bpPlot.Color = ScottPlot.Color.FromHex("#CE93D8"); - _overviewMemoryHover?.Add(bpPlot, "Buffer Pool"); - - var grantPlot = OverviewMemoryChart.Plot.Add.Scatter(times, grants); - grantPlot.LegendText = "Memory Grants"; - grantPlot.Color = ScottPlot.Color.FromHex("#FFB74D"); - _overviewMemoryHover?.Add(grantPlot, "Memory Grants"); - - OverviewMemoryChart.Plot.Axes.DateTimeTicksBottom(); - ReapplyAxisColors(OverviewMemoryChart); - OverviewMemoryChart.Plot.Title("Memory Utilization"); - OverviewMemoryChart.Plot.YLabel("MB"); - SetChartYLimitsWithLegendPadding(OverviewMemoryChart, 0, bufferPool.Max()); - ShowChartLegend(OverviewMemoryChart); - OverviewMemoryChart.Refresh(); - } - - private void UpdateOverviewFileIoChart(List data) - { - ClearChart(OverviewFileIoChart); - _overviewFileIoHover?.Clear(); - ApplyTheme(OverviewFileIoChart); - - if (data.Count == 0) { RefreshEmptyChart(OverviewFileIoChart, "File I/O Latency", "ms"); return; } - - // Aggregate across all databases/files per collection time - var grouped = data - .GroupBy(d => d.CollectionTime) - .OrderBy(g => g.Key) - .Select(g => new { Time = g.Key, ReadMs = g.Average(x => x.AvgReadLatencyMs), WriteMs = g.Average(x => x.AvgWriteLatencyMs) }) - .ToList(); - - var times = grouped.Select(d => d.Time.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var readMs = grouped.Select(d => d.ReadMs).ToArray(); - var writeMs = grouped.Select(d => d.WriteMs).ToArray(); - - var readPlot = OverviewFileIoChart.Plot.Add.Scatter(times, readMs); - readPlot.LegendText = "Read ms"; - readPlot.Color = ScottPlot.Color.FromHex("#81C784"); - _overviewFileIoHover?.Add(readPlot, "Read ms"); - - var writePlot = OverviewFileIoChart.Plot.Add.Scatter(times, writeMs); - writePlot.LegendText = "Write ms"; - writePlot.Color = ScottPlot.Color.FromHex("#FFB74D"); - _overviewFileIoHover?.Add(writePlot, "Write ms"); - - OverviewFileIoChart.Plot.Axes.DateTimeTicksBottom(); - ReapplyAxisColors(OverviewFileIoChart); - OverviewFileIoChart.Plot.Title("File I/O Latency"); - OverviewFileIoChart.Plot.YLabel("Latency (ms)"); - var maxVal = Math.Max(readMs.DefaultIfEmpty(0).Max(), writeMs.DefaultIfEmpty(0).Max()); - SetChartYLimitsWithLegendPadding(OverviewFileIoChart, 0, maxVal); - ShowChartLegend(OverviewFileIoChart); - OverviewFileIoChart.Refresh(); - } - - private async System.Threading.Tasks.Task UpdateOverviewWaitStatsChartAsync( - List topWaits, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - ClearChart(OverviewWaitStatsChart); - _overviewWaitStatsHover?.Clear(); - ApplyTheme(OverviewWaitStatsChart); - - if (topWaits.Count == 0) { RefreshEmptyChart(OverviewWaitStatsChart, "Wait Statistics", "ms/sec"); return; } - - var colors = new[] { "#4FC3F7", "#81C784", "#FFB74D", "#CE93D8", "#E57373" }; - for (int i = 0; i < Math.Min(topWaits.Count, 5); i++) - { - var trend = await _dataService.GetWaitStatsTrendAsync(_serverId, topWaits[i], hoursBack, fromDate, toDate); - if (trend.Count < 2) continue; - - var times = trend.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var values = trend.Select(d => d.WaitTimeMsPerSecond).ToArray(); - - var plot = OverviewWaitStatsChart.Plot.Add.Scatter(times, values); - plot.LegendText = topWaits[i]; - plot.Color = ScottPlot.Color.FromHex(colors[i]); - _overviewWaitStatsHover?.Add(plot, topWaits[i]); - } - - OverviewWaitStatsChart.Plot.Axes.DateTimeTicksBottom(); - ReapplyAxisColors(OverviewWaitStatsChart); - OverviewWaitStatsChart.Plot.Title("Wait Statistics"); - OverviewWaitStatsChart.Plot.YLabel("Wait Time (ms/sec)"); - ShowChartLegend(OverviewWaitStatsChart); - OverviewWaitStatsChart.Refresh(); - } - private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -3849,6 +3689,8 @@ private void OnThemeChanged(string _) chart.Refresh(); } } + + CorrelatedLanes.ReapplyTheme(); } private static IEnumerable GetAllCharts(DependencyObject root) @@ -5247,10 +5089,6 @@ public void DisposeChartHelpers() { _waitStatsHover?.Dispose(); _perfmonHover?.Dispose(); - _overviewCpuHover?.Dispose(); - _overviewMemoryHover?.Dispose(); - _overviewFileIoHover?.Dispose(); - _overviewWaitStatsHover?.Dispose(); _cpuHover?.Dispose(); _memoryHover?.Dispose(); _tempDbHover?.Dispose(); diff --git a/Lite/Helpers/CorrelatedCrosshairManager.cs b/Lite/Helpers/CorrelatedCrosshairManager.cs new file mode 100644 index 00000000..da7267cb --- /dev/null +++ b/Lite/Helpers/CorrelatedCrosshairManager.cs @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + * + * SYNC WARNING: Dashboard has a matching copy at Dashboard/Helpers/CorrelatedCrosshairManager.cs. + * Changes here must be mirrored there. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Helpers; + +/// +/// Synchronizes vertical crosshair lines across multiple ScottPlot charts. +/// When the user hovers over any lane, all lanes show a VLine at the same X (time) +/// coordinate and value labels update to show each lane's value at that time. +/// +internal sealed class CorrelatedCrosshairManager : IDisposable +{ + private readonly List _lanes = new(); + private readonly Popup _tooltip; + private readonly TextBlock _tooltipText; + private DateTime _lastUpdate; + private bool _isRefreshing; + + public CorrelatedCrosshairManager() + { + _tooltipText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 13 + }; + + _tooltip = new Popup + { + Placement = PlacementMode.Relative, + IsHitTestVisible = false, + AllowsTransparency = true, + Child = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(8, 4, 8, 4), + Child = _tooltipText + } + }; + } + + /// + /// Registers a chart lane for crosshair synchronization. + /// + public void AddLane(ScottPlot.WPF.WpfPlot chart, string label, string unit, TextBlock valueLabel) + { + var lane = new LaneInfo + { + Chart = chart, + Label = label, + Unit = unit, + ValueLabel = valueLabel + }; + + chart.MouseMove += (s, e) => OnMouseMove(lane, e); + chart.MouseLeave += (s, e) => OnMouseLeave(); + + _lanes.Add(lane); + } + + /// + /// Sets a single data series for a lane (most lanes have one series). + /// + public void SetLaneData(ScottPlot.WPF.WpfPlot chart, double[] times, double[] values, + bool isEventBased = false) + { + var lane = _lanes.Find(l => l.Chart == chart); + if (lane == null) return; + + lane.Series.Clear(); + lane.Series.Add(new DataSeries + { + Name = lane.Label, + Times = times, + Values = values, + IsEventBased = isEventBased + }); + } + + /// + /// Adds a named data series to a lane (for lanes with multiple overlaid series). + /// Call SetLaneData first to clear, then AddLaneSeries for additional series. + /// + public void AddLaneSeries(ScottPlot.WPF.WpfPlot chart, string name, string unit, + double[] times, double[] values, bool isEventBased = false) + { + var lane = _lanes.Find(l => l.Chart == chart); + if (lane == null) return; + + lane.Series.Add(new DataSeries + { + Name = name, + Unit = unit, + Times = times, + Values = values, + IsEventBased = isEventBased + }); + } + + /// + /// Clears data and VLines. Call before re-populating charts. + /// + public void PrepareForRefresh() + { + _isRefreshing = true; + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + lane.Series.Clear(); + lane.VLine = null; + } + } + + /// + /// Creates fresh VLine plottables on each lane's chart. + /// Must be called AFTER chart data is populated. + /// + public void ReattachVLines() + { + foreach (var lane in _lanes) + { + var vline = lane.Chart.Plot.Add.VerticalLine(0); + vline.Color = ScottPlot.Color.FromHex("#FFFFFF").WithAlpha(100); + vline.LineWidth = 1; + vline.LinePattern = ScottPlot.LinePattern.Dashed; + vline.IsVisible = false; + lane.VLine = vline; + } + _isRefreshing = false; + } + + private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) + { + if (_isRefreshing || sourceLane.VLine == null) return; + + var now = DateTime.UtcNow; + if ((now - _lastUpdate).TotalMilliseconds < 16) return; + _lastUpdate = now; + + var pos = e.GetPosition(sourceLane.Chart); + var dpi = VisualTreeHelper.GetDpi(sourceLane.Chart); + var pixel = new ScottPlot.Pixel( + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); + var mouseCoords = sourceLane.Chart.Plot.GetCoordinates(pixel); + double xValue = mouseCoords.X; + + var tooltipLines = new List(); + var time = DateTime.FromOADate(xValue); + var displayTime = ServerTimeHelper.ConvertForDisplay(time, ServerTimeHelper.CurrentDisplayMode); + tooltipLines.Add(displayTime.ToString("yyyy-MM-dd HH:mm:ss")); + + foreach (var lane in _lanes) + { + if (lane.VLine == null) continue; + + lane.VLine.IsVisible = true; + lane.VLine.X = xValue; + + if (lane.Series.Count == 1) + { + // Single series — use lane label and unit + var series = lane.Series[0]; + double? value = FindNearestValue(series, xValue); + + if (value.HasValue) + { + lane.ValueLabel.Text = $"{value.Value:N1} {lane.Unit}"; + tooltipLines.Add($"{lane.Label}: {value.Value:N1} {lane.Unit}"); + } + else + { + lane.ValueLabel.Text = ""; + tooltipLines.Add($"{lane.Label}: —"); + } + } + else if (lane.Series.Count > 1) + { + // Multiple series — show each with its own name + var valueParts = new List(); + foreach (var series in lane.Series) + { + double? value = FindNearestValue(series, xValue); + string unit = series.Unit ?? lane.Unit; + if (value.HasValue) + { + valueParts.Add($"{value.Value:N0}"); + tooltipLines.Add($"{series.Name}: {value.Value:N0} {unit}"); + } + else + { + tooltipLines.Add($"{series.Name}: —"); + } + } + lane.ValueLabel.Text = valueParts.Count > 0 ? string.Join("/", valueParts) : ""; + } + else + { + lane.ValueLabel.Text = ""; + tooltipLines.Add($"{lane.Label}: —"); + } + + lane.Chart.Refresh(); + } + + _tooltipText.Text = string.Join("\n", tooltipLines); + _tooltip.PlacementTarget = sourceLane.Chart; + _tooltip.HorizontalOffset = pos.X + 15; + _tooltip.VerticalOffset = pos.Y + 15; + _tooltip.IsOpen = true; + } + + private static double? FindNearestValue(DataSeries series, double targetX) + { + if (series.Times == null || series.Values == null || series.Times.Length == 0) + return null; + + var times = series.Times; + var values = series.Values; + + int lo = 0, hi = times.Length - 1; + while (lo < hi) + { + int mid = (lo + hi) / 2; + if (times[mid] < targetX) + lo = mid + 1; + else + hi = mid; + } + + int best = lo; + if (lo > 0 && Math.Abs(times[lo - 1] - targetX) < Math.Abs(times[lo] - targetX)) + best = lo - 1; + + double val = values[best]; + if (double.IsNaN(val)) return null; + + if (series.IsEventBased) + { + double oneMinute = 1.0 / 1440.0; + if (Math.Abs(times[best] - targetX) > oneMinute) + return 0; + } + + return val; + } + + private void OnMouseLeave() + { + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + if (lane.VLine != null) + lane.VLine.IsVisible = false; + lane.ValueLabel.Text = ""; + lane.Chart.Refresh(); + } + } + + public void Dispose() + { + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + lane.Series.Clear(); + lane.VLine = null; + } + _lanes.Clear(); + } + + private class DataSeries + { + public string Name { get; set; } = ""; + public string? Unit { get; set; } + public double[]? Times { get; set; } + public double[]? Values { get; set; } + public bool IsEventBased { get; set; } + } + + private class LaneInfo + { + public ScottPlot.WPF.WpfPlot Chart { get; set; } = null!; + public string Label { get; set; } = ""; + public string Unit { get; set; } = ""; + public ScottPlot.Plottables.VerticalLine? VLine { get; set; } + public TextBlock ValueLabel { get; set; } = null!; + public List Series { get; set; } = new(); + } +} diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index ee9037cf..73556eb5 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -564,7 +564,7 @@ GROUP BY DATE_TRUNC('minute', event_time) } /// - /// Gets deadlock trend (count of deadlocks per hour bucket). + /// Gets deadlock trend (count of deadlocks per minute bucket). /// public async Task> GetDeadlockTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { @@ -579,13 +579,13 @@ public async Task> GetDeadlockTrendAsync(int serverId, int hour deadlock_count FROM ( SELECT - DATE_TRUNC('hour', deadlock_time) AS bucket, + DATE_TRUNC('minute', deadlock_time) AS bucket, COUNT(*) AS deadlock_count FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 - GROUP BY DATE_TRUNC('hour', deadlock_time) + GROUP BY DATE_TRUNC('minute', deadlock_time) ) sub ORDER BY bucket"; diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 7ebca428..6fef57ab 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -150,6 +150,55 @@ FROM raw return items; } + /// + /// Gets total wait time trend across all wait types as a single aggregated time-series. + /// Used by the correlated timeline lanes for a single-line wait stats overview. + /// + public async Task> GetTotalWaitTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetTotalWaitTrendAsync", "wait_stats total trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH per_collection AS +( + SELECT + collection_time, + SUM(delta_wait_time_ms) AS total_delta_ms, + date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time +) +SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second +FROM per_collection +ORDER BY collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new WaitStatsTrendPoint + { + CollectionTime = reader.GetDateTime(0), + WaitTimeMsPerSecond = reader.IsDBNull(1) ? 0 : reader.GetDouble(1) + }); + } + + return items; + } + /// /// Gets the latest poison wait deltas for alert checking. /// Returns entries where delta_waiting_tasks > 0 with computed avg ms per wait. From b6c9f3ed4ace0c4be5291acade6ece449d36d61e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:32:11 -0400 Subject: [PATCH 2/2] Port correlated timeline lanes to Dashboard (#688) Ports the Lite correlated lanes feature to the Dashboard app: - Replaces Server Trends 2x2 grid with 5 synchronized lanes - Adds GetTotalWaitStatsTrendAsync (aggregate wait ms/sec trend) - Adds GetDeadlockTrendAsync (per-minute deadlock counts) - All queries read from existing collected data, no new collectors Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CorrelatedTimelineLanesControl.xaml | 100 +++++ .../CorrelatedTimelineLanesControl.xaml.cs | 335 +++++++++++++++ .../Controls/ResourceMetricsContent.xaml | 68 +-- .../Controls/ResourceMetricsContent.xaml.cs | 386 +----------------- .../Helpers/CorrelatedCrosshairManager.cs | 309 ++++++++++++++ .../DatabaseService.QueryPerformance.cs | 62 +++ .../DatabaseService.ResourceMetrics.cs | 171 ++++++++ 7 files changed, 983 insertions(+), 448 deletions(-) create mode 100644 Dashboard/Controls/CorrelatedTimelineLanesControl.xaml create mode 100644 Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs create mode 100644 Dashboard/Helpers/CorrelatedCrosshairManager.cs diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml new file mode 100644 index 00000000..46b68159 --- /dev/null +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs new file mode 100644 index 00000000..94aa4291 --- /dev/null +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Dashboard. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + * + * SYNC WARNING: Lite has a matching copy at Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs. + * Changes here must be mirrored there. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls; + +public partial class CorrelatedTimelineLanesControl : UserControl +{ + private DatabaseService? _dataService; + private CorrelatedCrosshairManager? _crosshairManager; + private bool _isRefreshing; + + public CorrelatedTimelineLanesControl() + { + InitializeComponent(); + Unloaded += (_, _) => _crosshairManager?.Dispose(); + } + + /// + /// Initializes the control with the data service. + /// Must be called before RefreshAsync. + /// + public void Initialize(DatabaseService dataService) + { + _dataService = dataService; + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + TabHelpers.ApplyThemeToChart(chart); + // Disable zoom/pan/drag but keep mouse events for crosshair + chart.UserInputProcessor.UserActionResponses.Clear(); + } + + _crosshairManager = new CorrelatedCrosshairManager(); + _crosshairManager.AddLane(CpuChart, "CPU", "%", CpuValueLabel); + _crosshairManager.AddLane(WaitStatsChart, "Wait Stats", "ms/sec", WaitStatsValueLabel); + _crosshairManager.AddLane(BlockingChart, "Blocking", "events", BlockingValueLabel); + _crosshairManager.AddLane(MemoryChart, "Memory", "MB", MemoryValueLabel); + _crosshairManager.AddLane(FileIoChart, "I/O Latency", "ms", FileIoValueLabel); + } + + /// + /// Refreshes all lane data for the given time range. + /// + public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + if (_dataService == null || _isRefreshing) return; + _isRefreshing = true; + + try + { + _crosshairManager?.PrepareForRefresh(); + + var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); + var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); + var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); + var deadlockTask = _dataService.GetDeadlockTrendAsync(hoursBack, fromDate, toDate); + var memoryTask = _dataService.GetMemoryStatsAsync(hoursBack, fromDate, toDate); + var fileIoTask = _dataService.GetFileIoLatencyTimeSeriesAsync(false, hoursBack, fromDate, toDate); + + try + { + await Task.WhenAll(cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fileIoTask); + } + catch (Exception ex) + { + Debug.WriteLine($"CorrelatedLanes: Data fetch failed: {ex.Message}"); + } + + if (cpuTask.IsCompletedSuccessfully) + UpdateLane(CpuChart, "CPU %", + cpuTask.Result.Select(d => (d.SampleTime.ToOADate(), (double)d.SqlServerCpuUtilization)).ToList(), + "#4FC3F7", 0, 105); + else + ShowEmpty(CpuChart, "CPU %"); + + if (waitTask.IsCompletedSuccessfully) + UpdateLane(WaitStatsChart, "Wait ms/sec", + waitTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.WaitTimeMsPerSecond)).ToList(), + "#FFB74D"); + else + ShowEmpty(WaitStatsChart, "Wait ms/sec"); + + try + { + var blockingData = blockingTask.IsCompletedSuccessfully + ? blockingTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.ToOADate(), (double)g.Sum(x => x.BlockedCount))) + .ToList() + : new List<(double, double)>(); + var deadlockData = deadlockTask.IsCompletedSuccessfully + ? deadlockTask.Result + .Select(d => (d.CollectionTime.ToOADate(), (double)d.BlockedCount)) + .ToList() + : new List<(double, double)>(); + UpdateBlockingLane(blockingData, deadlockData); + } + catch (Exception ex) + { + Debug.WriteLine($"CorrelatedLanes: Blocking lane failed: {ex}"); + ShowEmpty(BlockingChart, "Blocking & Deadlocking"); + } + + if (memoryTask.IsCompletedSuccessfully) + UpdateLane(MemoryChart, "Memory MB", + memoryTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.TotalMemoryMb)).ToList(), + "#CE93D8"); + else + ShowEmpty(MemoryChart, "Memory MB"); + + if (fileIoTask.IsCompletedSuccessfully) + { + var ioGrouped = fileIoTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.ToOADate(), (double)g.Average(x => x.ReadLatencyMs))) + .ToList(); + UpdateLane(FileIoChart, "I/O ms", ioGrouped, "#81C784"); + } + else + ShowEmpty(FileIoChart, "I/O ms"); + + _crosshairManager?.ReattachVLines(); + SyncXAxes(hoursBack, fromDate, toDate); + } + finally + { + _isRefreshing = false; + } + } + + private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, + List<(double Time, double Value)> deadlockData) + { + ClearChart(BlockingChart); + TabHelpers.ApplyThemeToChart(BlockingChart); + + // Register blocking and deadlock as separate named series for the tooltip + var blockTimes = blockingData.Select(d => d.Time).ToArray(); + var blockValues = blockingData.Select(d => d.Value).ToArray(); + var deadTimes = deadlockData.Select(d => d.Time).ToArray(); + var deadValues = deadlockData.Select(d => d.Value).ToArray(); + + // First series clears any previous data + _crosshairManager?.SetLaneData(BlockingChart, blockTimes, blockValues, isEventBased: true); + // Rename the auto-created series and add the second + _crosshairManager?.AddLaneSeries(BlockingChart, "Deadlocks", "events", + deadTimes, deadValues, isEventBased: true); + + if (blockingData.Count == 0 && deadlockData.Count == 0) + { + ShowEmpty(BlockingChart, "Block/Dead"); + return; + } + + double barWidth = 30.0 / 86400.0; + double maxCount = 0; + + // Blocking bars — red + if (blockingData.Count > 0) + { + var bars = blockingData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth, + FillColor = ScottPlot.Color.FromHex("#E57373"), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, blockingData.Max(d => d.Value)); + } + + // Deadlock bars — yellow/amber, slightly narrower so both are visible + if (deadlockData.Count > 0) + { + var bars = deadlockData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth * 0.6, + FillColor = ScottPlot.Color.FromHex("#FFD54F"), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, deadlockData.Max(d => d.Value)); + } + + BlockingChart.Plot.Axes.DateTimeTicksBottom(); + BlockingChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + TabHelpers.ReapplyAxisColors(BlockingChart); + + BlockingChart.Plot.Title(""); + BlockingChart.Plot.YLabel(""); + BlockingChart.Plot.Legend.IsVisible = false; + BlockingChart.Plot.Axes.Margins(bottom: 0); + BlockingChart.Plot.Axes.SetLimitsY(0, Math.Max(maxCount * 1.3, 2)); + + BlockingChart.Refresh(); + } + + private void UpdateLane(ScottPlot.WPF.WpfPlot chart, string title, + List<(double Time, double Value)> data, string colorHex, + double? yMin = null, double? yMax = null) + { + ClearChart(chart); + TabHelpers.ApplyThemeToChart(chart); + + if (data.Count == 0) + { + ShowEmpty(chart, title); + return; + } + + var times = data.Select(d => d.Time).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + var scatter = chart.Plot.Add.Scatter(times, values); + scatter.Color = ScottPlot.Color.FromHex(colorHex); + scatter.MarkerSize = 0; + scatter.LineWidth = 1.5f; + scatter.LegendText = title; + scatter.ConnectStyle = ScottPlot.ConnectStyle.Straight; + + _crosshairManager?.SetLaneData(chart, times, values); + + chart.Plot.Axes.DateTimeTicksBottom(); + // Hide bottom tick labels on all lanes except the last (File I/O) + if (chart != FileIoChart) + chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + + TabHelpers.ReapplyAxisColors(chart); + + // Compact layout: hide Y label, minimize title, no legend + chart.Plot.Title(""); + chart.Plot.YLabel(""); + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + + if (yMin.HasValue && yMax.HasValue) + chart.Plot.Axes.SetLimitsY(yMin.Value, yMax.Value); + else + { + var maxVal = data.Max(d => d.Value); + var minVal = data.Min(d => d.Value); + var padding = Math.Max((maxVal - minVal) * 0.1, 1); + chart.Plot.Axes.SetLimitsY(Math.Max(0, minVal - padding), maxVal + padding); + } + + chart.Refresh(); + } + + /// + /// Sets identical X-axis limits across all lanes. + /// + private void SyncXAxes(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime xStart, xEnd; + if (fromDate.HasValue && toDate.HasValue) + { + xStart = fromDate.Value; + xEnd = toDate.Value; + } + else + { + xEnd = ServerTimeHelper.ServerNow; + xStart = xEnd.AddHours(-hoursBack); + } + + double xMin = xStart.ToOADate(); + double xMax = xEnd.ToOADate(); + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Refresh(); + } + } + + private static void ClearChart(ScottPlot.WPF.WpfPlot chart) + { + chart.Reset(); + chart.Plot.Clear(); + } + + private static void ShowEmpty(ScottPlot.WPF.WpfPlot chart, string title) + { + TabHelpers.ReapplyAxisColors(chart); + var text = chart.Plot.Add.Text($"{title}\nNo Data", 0, 0); + text.LabelFontColor = ScottPlot.Color.FromHex("#888888"); + text.LabelFontSize = 12; + text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + chart.Plot.HideGrid(); + chart.Plot.Axes.SetLimitsX(-1, 1); + chart.Plot.Axes.SetLimitsY(-1, 1); + chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Legend.IsVisible = false; + chart.Refresh(); + } + + /// + /// Reapplies theme to all lane charts (call on theme change). + /// + public void ReapplyTheme() + { + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + TabHelpers.ApplyThemeToChart(chart); + chart.Refresh(); + } + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml index 03142ca0..4067d08f 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml +++ b/Dashboard/Controls/ResourceMetricsContent.xaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + xmlns:local="clr-namespace:PerformanceMonitorDashboard.Controls" mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="1200"> @@ -25,72 +26,9 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index bea433e4..4884c6cd 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -118,10 +118,6 @@ private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, private Helpers.ChartHoverHelper? _waitStatsHover; private Helpers.ChartHoverHelper? _tempdbStatsHover; private Helpers.ChartHoverHelper? _tempDbLatencyHover; - private Helpers.ChartHoverHelper? _serverTrendsCpuHover; - private Helpers.ChartHoverHelper? _serverTrendsTempdbHover; - private Helpers.ChartHoverHelper? _serverTrendsMemoryHover; - private Helpers.ChartHoverHelper? _serverTrendsPerfmonHover; // Filter state dictionaries for each DataGrid // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) // Must store and remove these by reference before creating new ones @@ -148,10 +144,6 @@ public ResourceMetricsContent() TabHelpers.ApplyThemeToChart(FileIoWriteThroughputChart); TabHelpers.ApplyThemeToChart(PerfmonCountersChart); TabHelpers.ApplyThemeToChart(WaitStatsDetailChart); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsCpuChart); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsTempdbChart); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsMemoryChart); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsPerfmonChart); _sessionStatsHover = new Helpers.ChartHoverHelper(SessionStatsChart, "sessions"); _latchStatsHover = new Helpers.ChartHoverHelper(LatchStatsChart, "ms/sec"); @@ -164,10 +156,6 @@ public ResourceMetricsContent() _waitStatsHover = new Helpers.ChartHoverHelper(WaitStatsDetailChart, "ms/sec"); _tempdbStatsHover = new Helpers.ChartHoverHelper(TempdbStatsChart, "MB"); _tempDbLatencyHover = new Helpers.ChartHoverHelper(TempDbLatencyChart, "ms"); - _serverTrendsCpuHover = new Helpers.ChartHoverHelper(ServerUtilTrendsCpuChart, "%"); - _serverTrendsTempdbHover = new Helpers.ChartHoverHelper(ServerUtilTrendsTempdbChart, "MB"); - _serverTrendsMemoryHover = new Helpers.ChartHoverHelper(ServerUtilTrendsMemoryChart, "MB"); - _serverTrendsPerfmonHover = new Helpers.ChartHoverHelper(ServerUtilTrendsPerfmonChart, "/sec"); } private void OnLoaded(object sender, RoutedEventArgs e) @@ -188,6 +176,7 @@ private void OnThemeChanged(string _) chart.Refresh(); } } + CorrelatedLanes.ReapplyTheme(); } private void SetupChartContextMenus() @@ -214,16 +203,6 @@ private void SetupChartContextMenus() TabHelpers.SetupChartContextMenu(FileIoWriteThroughputChart, "UserDB_Write_Throughput", "collect.file_io_stats"); TabHelpers.SetupChartContextMenu(TempDbLatencyChart, "TempDB_Latency", "collect.file_io_stats"); - // Server Utilization Trends charts - var cpuTrendsMenu = TabHelpers.SetupChartContextMenu(ServerUtilTrendsCpuChart, "Server_CPU_Trends", "collect.cpu_utilization_stats"); - AddDrillDown(ServerUtilTrendsCpuChart, cpuTrendsMenu, () => _serverTrendsCpuHover, "Show Active Queries at This Time", "CPU"); - var tempDbTrendsMenu = TabHelpers.SetupChartContextMenu(ServerUtilTrendsTempdbChart, "Server_TempDB_Trends", "collect.tempdb_stats"); - AddDrillDown(ServerUtilTrendsTempdbChart, tempDbTrendsMenu, () => _serverTrendsTempdbHover, "Show Active Queries at This Time", "TempDB"); - var memTrendsMenu = TabHelpers.SetupChartContextMenu(ServerUtilTrendsMemoryChart, "Server_Memory_Trends", "collect.memory_stats"); - AddDrillDown(ServerUtilTrendsMemoryChart, memTrendsMenu, () => _serverTrendsMemoryHover, "Show Active Queries at This Time", "Memory"); - var perfmonTrendsMenu = TabHelpers.SetupChartContextMenu(ServerUtilTrendsPerfmonChart, "Server_Perfmon_Trends", "collect.perfmon_stats"); - AddDrillDown(ServerUtilTrendsPerfmonChart, perfmonTrendsMenu, () => _serverTrendsPerfmonHover, "Show Active Queries at This Time", "Perfmon"); - // Perfmon Counters chart TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats"); @@ -238,6 +217,7 @@ private void SetupChartContextMenus() public void Initialize(DatabaseService databaseService) { _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); + CorrelatedLanes.Initialize(databaseService); } /// @@ -1047,32 +1027,9 @@ private async Task LoadFileIoThroughputChartsAsync() private async Task RefreshServerTrendsAsync() { if (_databaseService == null) return; - try { - var cpuTask = _databaseService.GetCpuSpikesAsync(_serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - var tempdbTask = _databaseService.GetTempdbStatsAsync(_serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - var memoryTask = _databaseService.GetMemoryStatsAsync(_serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - var perfmonTask = _databaseService.GetPerfmonStatsFilteredAsync( - new[] { "Batch Requests/sec", "SQL Compilations/sec", "SQL Re-Compilations/sec", "Optimizer Statistics" }, - _serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - - await Task.WhenAll(cpuTask, tempdbTask, memoryTask, perfmonTask); - - LoadServerTrendsCpuChart(await cpuTask, _serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - LoadServerTrendsTempdbChart(await tempdbTask, _serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - LoadServerTrendsMemoryChart(await memoryTask, _serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - LoadServerTrendsPerfmonChart(await perfmonTask, _serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); - - try - { - var pressure = await _databaseService.GetCpuPressureAsync(); - UpdateCpuSchedulerStatus(pressure); - } - catch (Exception pressureEx) - { - Logger.Error($"Error loading CPU scheduler pressure: {pressureEx.Message}", pressureEx); - } + await CorrelatedLanes.RefreshAsync(_serverTrendsHoursBack, _serverTrendsFromDate, _serverTrendsToDate); } catch (Exception ex) { @@ -1080,343 +1037,6 @@ private async Task RefreshServerTrendsAsync() } } - private void UpdateCpuSchedulerStatus(CpuPressureItem? pressure) - { - if (pressure == null) - { - CpuSchedulerStatusText.Text = ""; - return; - } - - CpuSchedulerStatusText.Inlines.Clear(); - - var summary = $"Schedulers: {pressure.TotalSchedulers} | " + - $"Workers: {pressure.TotalWorkers:N0}/{pressure.MaxWorkers:N0} ({pressure.WorkerUtilizationPercent:F1}%) | " + - $"Runnable: {pressure.TotalRunnableTasks} ({pressure.AvgRunnableTasksPerScheduler:F2}/sched) | " + - $"Active: {pressure.TotalActiveRequests} | " + - $"Queued: {pressure.TotalQueuedRequests} | "; - - CpuSchedulerStatusText.Inlines.Add(new Run(summary)); - - var levelText = pressure.PressureLevel; - var levelRun = new Run(levelText); - - if (levelText.Contains("CRITICAL") || levelText.Contains("HIGH")) - { - levelRun.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x44, 0x44)); - levelRun.FontWeight = FontWeights.Bold; - } - else if (levelText.Contains("MEDIUM")) - { - levelRun.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xA5, 0x00)); - levelRun.FontWeight = FontWeights.Bold; - } - - CpuSchedulerStatusText.Inlines.Add(levelRun); - } - - private void LoadServerTrendsCpuChart(IEnumerable cpuData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(ServerUtilTrendsCpuChart, out var existingCpuPanel) && existingCpuPanel != null) - { - ServerUtilTrendsCpuChart.Plot.Axes.Remove(existingCpuPanel); - _legendPanels[ServerUtilTrendsCpuChart] = null; - } - ServerUtilTrendsCpuChart.Plot.Clear(); - _serverTrendsCpuHover?.Clear(); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsCpuChart); - - var dataList = cpuData?.OrderBy(d => d.EventTime).ToList() ?? new List(); - if (dataList.Count > 0) - { - // SQL CPU series - var (sqlXs, sqlYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.EventTime), - dataList.Select(d => (double)d.SqlServerCpu)); - var sqlScatter = ServerUtilTrendsCpuChart.Plot.Add.Scatter(sqlXs, sqlYs); - sqlScatter.LineWidth = 2; - sqlScatter.MarkerSize = 5; - sqlScatter.Color = TabHelpers.ChartColors[0]; - sqlScatter.LegendText = "SQL CPU"; - _serverTrendsCpuHover?.Add(sqlScatter, "SQL CPU"); - - // Other CPU series - var (otherXs, otherYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.EventTime), - dataList.Select(d => (double)d.OtherProcessCpu)); - var otherScatter = ServerUtilTrendsCpuChart.Plot.Add.Scatter(otherXs, otherYs); - otherScatter.LineWidth = 2; - otherScatter.MarkerSize = 5; - otherScatter.Color = TabHelpers.ChartColors[2]; - otherScatter.LegendText = "Other CPU"; - _serverTrendsCpuHover?.Add(otherScatter, "Other CPU"); - - _legendPanels[ServerUtilTrendsCpuChart] = ServerUtilTrendsCpuChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - ServerUtilTrendsCpuChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = ServerUtilTrendsCpuChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - ServerUtilTrendsCpuChart.Plot.Axes.DateTimeTicksBottom(); - ServerUtilTrendsCpuChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(ServerUtilTrendsCpuChart); - ServerUtilTrendsCpuChart.Plot.YLabel("CPU %"); - TabHelpers.LockChartVerticalAxis(ServerUtilTrendsCpuChart); - ServerUtilTrendsCpuChart.Refresh(); - } - - private void LoadServerTrendsTempdbChart(IEnumerable tempdbData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(ServerUtilTrendsTempdbChart, out var existingTrendsTempdbPanel) && existingTrendsTempdbPanel != null) - { - ServerUtilTrendsTempdbChart.Plot.Axes.Remove(existingTrendsTempdbPanel); - _legendPanels[ServerUtilTrendsTempdbChart] = null; - } - ServerUtilTrendsTempdbChart.Plot.Clear(); - _serverTrendsTempdbHover?.Clear(); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsTempdbChart); - - var dataList = tempdbData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - if (dataList.Count >= 1) - { - var userTimePoints = dataList.Select(d => d.CollectionTime); - var userValues = dataList.Select(d => (double)(d.UserObjectReservedPageCount * 8 / 1024)); - var (userXs, userYs) = TabHelpers.FillTimeSeriesGaps(userTimePoints, userValues); - - var versionTimePoints = dataList.Select(d => d.CollectionTime); - var versionValues = dataList.Select(d => (double)(d.VersionStoreReservedPageCount * 8 / 1024)); - var (versionXs, versionYs) = TabHelpers.FillTimeSeriesGaps(versionTimePoints, versionValues); - - var userScatter = ServerUtilTrendsTempdbChart.Plot.Add.Scatter(userXs, userYs); - userScatter.LineWidth = 2; - userScatter.MarkerSize = 5; - userScatter.Color = TabHelpers.ChartColors[1]; - userScatter.LegendText = "User Objects"; - _serverTrendsTempdbHover?.Add(userScatter, "User Objects"); - - var versionScatter = ServerUtilTrendsTempdbChart.Plot.Add.Scatter(versionXs, versionYs); - versionScatter.LineWidth = 2; - versionScatter.MarkerSize = 5; - versionScatter.Color = TabHelpers.ChartColors[2]; - versionScatter.LegendText = "Version Store"; - _serverTrendsTempdbHover?.Add(versionScatter, "Version Store"); - - _legendPanels[ServerUtilTrendsTempdbChart] = ServerUtilTrendsTempdbChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - ServerUtilTrendsTempdbChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = ServerUtilTrendsTempdbChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - ServerUtilTrendsTempdbChart.Plot.Axes.DateTimeTicksBottom(); - ServerUtilTrendsTempdbChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(ServerUtilTrendsTempdbChart); - ServerUtilTrendsTempdbChart.Plot.YLabel("MB"); - TabHelpers.LockChartVerticalAxis(ServerUtilTrendsTempdbChart); - ServerUtilTrendsTempdbChart.Refresh(); - } - - private void LoadServerTrendsMemoryChart(IEnumerable memoryData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(ServerUtilTrendsMemoryChart, out var existingMemoryPanel) && existingMemoryPanel != null) - { - ServerUtilTrendsMemoryChart.Plot.Axes.Remove(existingMemoryPanel); - _legendPanels[ServerUtilTrendsMemoryChart] = null; - } - ServerUtilTrendsMemoryChart.Plot.Clear(); - _serverTrendsMemoryHover?.Clear(); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsMemoryChart); - - var dataList = memoryData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - if (dataList.Count >= 1) - { - var bufferTimePoints = dataList.Select(d => d.CollectionTime); - var bufferValues = dataList.Select(d => (double)d.BufferPoolMb); - var (bufferXs, bufferYs) = TabHelpers.FillTimeSeriesGaps(bufferTimePoints, bufferValues); - - var cacheTimePoints = dataList.Select(d => d.CollectionTime); - var cacheValues = dataList.Select(d => (double)d.PlanCacheMb); - var (cacheXs, cacheYs) = TabHelpers.FillTimeSeriesGaps(cacheTimePoints, cacheValues); - - // DEBUG: Log the last data points to diagnose the "drop to 0" issue - if (bufferXs.Length > 0) - { - var lastTime = DateTime.FromOADate(bufferXs[^1]); - var lastValue = bufferYs[^1]; - Logger.Info($"Memory chart: Last buffer point = {lastTime:yyyy-MM-dd HH:mm:ss}, Value = {lastValue}. Array length = {bufferXs.Length}"); - Logger.Info($"Memory chart: rangeStart = {rangeStart:yyyy-MM-dd HH:mm:ss}, rangeEnd = {rangeEnd:yyyy-MM-dd HH:mm:ss}"); - - // Check for any zero values in the array - var zeroIndices = bufferYs.Select((v, i) => new { Value = v, Index = i }) - .Where(x => x.Value == 0) - .Select(x => x.Index) - .ToList(); - if (zeroIndices.Count > 0) - { - Logger.Warning($"Memory chart: Found {zeroIndices.Count} zero values at indices: {string.Join(", ", zeroIndices.Take(10))}"); - foreach (var idx in zeroIndices.Take(5)) - { - var zeroTime = DateTime.FromOADate(bufferXs[idx]); - Logger.Warning($" Zero at index {idx}: Time = {zeroTime:yyyy-MM-dd HH:mm:ss}"); - } - } - - // Log first and last 3 points - Logger.Info($"Memory chart: First 3 points:"); - for (int i = 0; i < Math.Min(3, bufferXs.Length); i++) - { - Logger.Info($" [{i}] Time = {DateTime.FromOADate(bufferXs[i]):HH:mm:ss}, Value = {bufferYs[i]}"); - } - Logger.Info($"Memory chart: Last 3 points:"); - for (int i = Math.Max(0, bufferXs.Length - 3); i < bufferXs.Length; i++) - { - Logger.Info($" [{i}] Time = {DateTime.FromOADate(bufferXs[i]):HH:mm:ss}, Value = {bufferYs[i]}"); - } - } - - var bufferScatter = ServerUtilTrendsMemoryChart.Plot.Add.Scatter(bufferXs, bufferYs); - bufferScatter.LineWidth = 2; - bufferScatter.MarkerSize = 5; - bufferScatter.Color = TabHelpers.ChartColors[4]; - bufferScatter.LegendText = "Buffer Pool"; - _serverTrendsMemoryHover?.Add(bufferScatter, "Buffer Pool"); - - var cacheScatter = ServerUtilTrendsMemoryChart.Plot.Add.Scatter(cacheXs, cacheYs); - cacheScatter.LineWidth = 2; - cacheScatter.MarkerSize = 5; - cacheScatter.Color = TabHelpers.ChartColors[5]; - cacheScatter.LegendText = "Plan Cache"; - _serverTrendsMemoryHover?.Add(cacheScatter, "Plan Cache"); - - _legendPanels[ServerUtilTrendsMemoryChart] = ServerUtilTrendsMemoryChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - ServerUtilTrendsMemoryChart.Plot.Legend.FontSize = 12; - - // Limit X-axis to actual data range to prevent ScottPlot from extrapolating beyond data - if (bufferXs.Length > 0) - { - var oldXMax = xMax; - xMax = Math.Min(xMax, bufferXs[^1]); - Logger.Info($"Memory chart: xMax changed from {DateTime.FromOADate(oldXMax):HH:mm:ss} to {DateTime.FromOADate(xMax):HH:mm:ss}"); - } - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = ServerUtilTrendsMemoryChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - ServerUtilTrendsMemoryChart.Plot.Axes.DateTimeTicksBottom(); - ServerUtilTrendsMemoryChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(ServerUtilTrendsMemoryChart); - ServerUtilTrendsMemoryChart.Plot.YLabel("MB"); - TabHelpers.LockChartVerticalAxis(ServerUtilTrendsMemoryChart); - ServerUtilTrendsMemoryChart.Refresh(); - } - - private void LoadServerTrendsPerfmonChart(IEnumerable perfmonData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(ServerUtilTrendsPerfmonChart, out var existingPerfmonTrendsPanel) && existingPerfmonTrendsPanel != null) - { - ServerUtilTrendsPerfmonChart.Plot.Axes.Remove(existingPerfmonTrendsPanel); - _legendPanels[ServerUtilTrendsPerfmonChart] = null; - } - ServerUtilTrendsPerfmonChart.Plot.Clear(); - _serverTrendsPerfmonHover?.Clear(); - TabHelpers.ApplyThemeToChart(ServerUtilTrendsPerfmonChart); - - var allData = (perfmonData ?? Enumerable.Empty()).ToList(); - - // Counters to display - var countersToShow = new[] { - ("Batch Requests/sec", TabHelpers.ChartColors[0]), - ("SQL Compilations/sec", TabHelpers.ChartColors[2]), - ("SQL Re-Compilations/sec", TabHelpers.ChartColors[3]), - ("Optimizer Statistics", TabHelpers.ChartColors[1]) - }; - - // Get all time points across all counters for gap filling - int linesAdded = 0; - foreach (var (counterName, color) in countersToShow) - { - var counterData = allData - .Where(d => d.CounterName == counterName) - .GroupBy(d => d.CollectionTime) - .Select(g => new { Time = g.Key, Value = g.Sum(x => x.CntrValuePerSecond ?? x.CntrValueDelta ?? x.CntrValue) }) - .OrderBy(d => d.Time) - .ToList(); - - if (counterData.Count >= 1) - { - var timePoints = counterData.Select(d => d.Time); - var values = counterData.Select(d => (double)d.Value); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = ServerUtilTrendsPerfmonChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = color; - scatter.LegendText = counterName.Replace("/sec", "", StringComparison.Ordinal); - _serverTrendsPerfmonHover?.Add(scatter, counterName); - linesAdded++; - } - } - - if (linesAdded > 0) - { - _legendPanels[ServerUtilTrendsPerfmonChart] = ServerUtilTrendsPerfmonChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - ServerUtilTrendsPerfmonChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = ServerUtilTrendsPerfmonChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - ServerUtilTrendsPerfmonChart.Plot.Axes.DateTimeTicksBottom(); - ServerUtilTrendsPerfmonChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(ServerUtilTrendsPerfmonChart); - ServerUtilTrendsPerfmonChart.Plot.YLabel("Per Second"); - TabHelpers.LockChartVerticalAxis(ServerUtilTrendsPerfmonChart); - ServerUtilTrendsPerfmonChart.Refresh(); - } - #endregion #region Context Menu Handlers diff --git a/Dashboard/Helpers/CorrelatedCrosshairManager.cs b/Dashboard/Helpers/CorrelatedCrosshairManager.cs new file mode 100644 index 00000000..7da54004 --- /dev/null +++ b/Dashboard/Helpers/CorrelatedCrosshairManager.cs @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + * + * SYNC WARNING: Lite has a matching copy at Lite/Helpers/CorrelatedCrosshairManager.cs. + * Changes here must be mirrored there. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Helpers; + +/// +/// Synchronizes vertical crosshair lines across multiple ScottPlot charts. +/// When the user hovers over any lane, all lanes show a VLine at the same X (time) +/// coordinate and value labels update to show each lane's value at that time. +/// +internal sealed class CorrelatedCrosshairManager : IDisposable +{ + private readonly List _lanes = new(); + private readonly Popup _tooltip; + private readonly TextBlock _tooltipText; + private DateTime _lastUpdate; + private bool _isRefreshing; + + public CorrelatedCrosshairManager() + { + _tooltipText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 13 + }; + + _tooltip = new Popup + { + Placement = PlacementMode.Relative, + IsHitTestVisible = false, + AllowsTransparency = true, + Child = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(8, 4, 8, 4), + Child = _tooltipText + } + }; + } + + /// + /// Registers a chart lane for crosshair synchronization. + /// + public void AddLane(ScottPlot.WPF.WpfPlot chart, string label, string unit, TextBlock valueLabel) + { + var lane = new LaneInfo + { + Chart = chart, + Label = label, + Unit = unit, + ValueLabel = valueLabel + }; + + chart.MouseMove += (s, e) => OnMouseMove(lane, e); + chart.MouseLeave += (s, e) => OnMouseLeave(); + + _lanes.Add(lane); + } + + /// + /// Sets a single data series for a lane (most lanes have one series). + /// + public void SetLaneData(ScottPlot.WPF.WpfPlot chart, double[] times, double[] values, + bool isEventBased = false) + { + var lane = _lanes.Find(l => l.Chart == chart); + if (lane == null) return; + + lane.Series.Clear(); + lane.Series.Add(new DataSeries + { + Name = lane.Label, + Times = times, + Values = values, + IsEventBased = isEventBased + }); + } + + /// + /// Adds a named data series to a lane (for lanes with multiple overlaid series). + /// Call SetLaneData first to clear, then AddLaneSeries for additional series. + /// + public void AddLaneSeries(ScottPlot.WPF.WpfPlot chart, string name, string unit, + double[] times, double[] values, bool isEventBased = false) + { + var lane = _lanes.Find(l => l.Chart == chart); + if (lane == null) return; + + lane.Series.Add(new DataSeries + { + Name = name, + Unit = unit, + Times = times, + Values = values, + IsEventBased = isEventBased + }); + } + + /// + /// Clears data and VLines. Call before re-populating charts. + /// + public void PrepareForRefresh() + { + _isRefreshing = true; + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + lane.Series.Clear(); + lane.VLine = null; + } + } + + /// + /// Creates fresh VLine plottables on each lane's chart. + /// Must be called AFTER chart data is populated. + /// + public void ReattachVLines() + { + foreach (var lane in _lanes) + { + var vline = lane.Chart.Plot.Add.VerticalLine(0); + vline.Color = ScottPlot.Color.FromHex("#FFFFFF").WithAlpha(100); + vline.LineWidth = 1; + vline.LinePattern = ScottPlot.LinePattern.Dashed; + vline.IsVisible = false; + lane.VLine = vline; + } + _isRefreshing = false; + } + + private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) + { + if (_isRefreshing || sourceLane.VLine == null) return; + + var now = DateTime.UtcNow; + if ((now - _lastUpdate).TotalMilliseconds < 16) return; + _lastUpdate = now; + + var pos = e.GetPosition(sourceLane.Chart); + var dpi = VisualTreeHelper.GetDpi(sourceLane.Chart); + var pixel = new ScottPlot.Pixel( + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); + var mouseCoords = sourceLane.Chart.Plot.GetCoordinates(pixel); + double xValue = mouseCoords.X; + + var tooltipLines = new List(); + var time = DateTime.FromOADate(xValue); + var displayTime = ServerTimeHelper.ConvertForDisplay(time, ServerTimeHelper.CurrentDisplayMode); + tooltipLines.Add(displayTime.ToString("yyyy-MM-dd HH:mm:ss")); + + foreach (var lane in _lanes) + { + if (lane.VLine == null) continue; + + lane.VLine.IsVisible = true; + lane.VLine.X = xValue; + + if (lane.Series.Count == 1) + { + // Single series — use lane label and unit + var series = lane.Series[0]; + double? value = FindNearestValue(series, xValue); + + if (value.HasValue) + { + lane.ValueLabel.Text = $"{value.Value:N1} {lane.Unit}"; + tooltipLines.Add($"{lane.Label}: {value.Value:N1} {lane.Unit}"); + } + else + { + lane.ValueLabel.Text = ""; + tooltipLines.Add($"{lane.Label}: —"); + } + } + else if (lane.Series.Count > 1) + { + // Multiple series — show each with its own name + var valueParts = new List(); + foreach (var series in lane.Series) + { + double? value = FindNearestValue(series, xValue); + string unit = series.Unit ?? lane.Unit; + if (value.HasValue) + { + valueParts.Add($"{value.Value:N0}"); + tooltipLines.Add($"{series.Name}: {value.Value:N0} {unit}"); + } + else + { + tooltipLines.Add($"{series.Name}: —"); + } + } + lane.ValueLabel.Text = valueParts.Count > 0 ? string.Join("/", valueParts) : ""; + } + else + { + lane.ValueLabel.Text = ""; + tooltipLines.Add($"{lane.Label}: —"); + } + + lane.Chart.Refresh(); + } + + _tooltipText.Text = string.Join("\n", tooltipLines); + _tooltip.PlacementTarget = sourceLane.Chart; + _tooltip.HorizontalOffset = pos.X + 15; + _tooltip.VerticalOffset = pos.Y + 15; + _tooltip.IsOpen = true; + } + + private static double? FindNearestValue(DataSeries series, double targetX) + { + if (series.Times == null || series.Values == null || series.Times.Length == 0) + return null; + + var times = series.Times; + var values = series.Values; + + int lo = 0, hi = times.Length - 1; + while (lo < hi) + { + int mid = (lo + hi) / 2; + if (times[mid] < targetX) + lo = mid + 1; + else + hi = mid; + } + + int best = lo; + if (lo > 0 && Math.Abs(times[lo - 1] - targetX) < Math.Abs(times[lo] - targetX)) + best = lo - 1; + + double val = values[best]; + if (double.IsNaN(val)) return null; + + if (series.IsEventBased) + { + double oneMinute = 1.0 / 1440.0; + if (Math.Abs(times[best] - targetX) > oneMinute) + return 0; + } + + return val; + } + + private void OnMouseLeave() + { + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + if (lane.VLine != null) + lane.VLine.IsVisible = false; + lane.ValueLabel.Text = ""; + lane.Chart.Refresh(); + } + } + + public void Dispose() + { + _tooltip.IsOpen = false; + foreach (var lane in _lanes) + { + lane.Series.Clear(); + lane.VLine = null; + } + _lanes.Clear(); + } + + private class DataSeries + { + public string Name { get; set; } = ""; + public string? Unit { get; set; } + public double[]? Times { get; set; } + public double[]? Values { get; set; } + public bool IsEventBased { get; set; } + } + + private class LaneInfo + { + public ScottPlot.WPF.WpfPlot Chart { get; set; } = null!; + public string Label { get; set; } = ""; + public string Unit { get; set; } = ""; + public ScottPlot.Plottables.VerticalLine? VLine { get; set; } + public TextBlock ValueLabel { get; set; } = null!; + public List Series { get; set; } = new(); + } +} diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index 1d88ab9b..2e162eb6 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -3587,6 +3587,68 @@ ORDER BY return items; } + public async Task> GetDeadlockTrendAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query; + if (fromDate.HasValue && toDate.HasValue) + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + CollectionTime = DATEADD(MINUTE, DATEDIFF(MINUTE, 0, d.event_date), 0), + DatabaseName = N'', + BlockedCount = COUNT(*) + FROM collect.deadlocks AS d + WHERE d.event_date >= @from_date + AND d.event_date <= @to_date + GROUP BY + DATEADD(MINUTE, DATEDIFF(MINUTE, 0, d.event_date), 0) + ORDER BY + CollectionTime;"; + } + else + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + CollectionTime = DATEADD(MINUTE, DATEDIFF(MINUTE, 0, d.event_date), 0), + DatabaseName = N'', + BlockedCount = COUNT(*) + FROM collect.deadlocks AS d + WHERE d.event_date >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + GROUP BY + DATEADD(MINUTE, DATEDIFF(MINUTE, 0, d.event_date), 0) + ORDER BY + CollectionTime;"; + } + + using var command = new SqlCommand(query, connection); + command.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 BlockedSessionTrendItem + { + CollectionTime = reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + BlockedCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture) + }); + } + + return items; + } + private static string GetHeatmapMetricExpr(Models.HeatmapMetric metric) => metric switch { Models.HeatmapMetric.Duration => "(qs.total_elapsed_time_delta / 1000.0) / NULLIF(qs.execution_count_delta, 0)", diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.cs b/Dashboard/Services/DatabaseService.ResourceMetrics.cs index 49120927..b4492329 100644 --- a/Dashboard/Services/DatabaseService.ResourceMetrics.cs +++ b/Dashboard/Services/DatabaseService.ResourceMetrics.cs @@ -2223,5 +2223,176 @@ ORDER BY return items; } + + public async Task> GetTotalWaitStatsTrendAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query; + if (fromDate.HasValue && toDate.HasValue) + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + WITH wait_deltas AS + ( + SELECT + collection_time = ws.collection_time, + wait_type = ws.wait_type, + wait_time_ms_delta = + ws.wait_time_ms - LAG(ws.wait_time_ms, 1, ws.wait_time_ms) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + signal_wait_time_ms_delta = + ws.signal_wait_time_ms - LAG(ws.signal_wait_time_ms, 1, ws.signal_wait_time_ms) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + interval_seconds = + DATEDIFF + ( + SECOND, + LAG(ws.collection_time, 1, ws.collection_time) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + ws.collection_time + ) + FROM collect.wait_stats AS ws + WHERE ws.collection_time >= @from_date + AND ws.collection_time <= @to_date + ) + SELECT + wd.collection_time, + wait_type = N'Total', + wait_time_ms_per_second = + SUM + ( + CASE + WHEN wd.interval_seconds > 0 + THEN CAST(CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds AS decimal(18, 4)) + ELSE 0 + END + ), + signal_wait_time_ms_per_second = + SUM + ( + CASE + WHEN wd.interval_seconds > 0 + THEN CAST(CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds AS decimal(18, 4)) + ELSE 0 + END + ) + FROM wait_deltas AS wd + WHERE wd.wait_time_ms_delta >= 0 + GROUP BY + wd.collection_time + ORDER BY + wd.collection_time ASC;"; + } + else + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + WITH wait_deltas AS + ( + SELECT + collection_time = ws.collection_time, + wait_type = ws.wait_type, + wait_time_ms_delta = + ws.wait_time_ms - LAG(ws.wait_time_ms, 1, ws.wait_time_ms) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + signal_wait_time_ms_delta = + ws.signal_wait_time_ms - LAG(ws.signal_wait_time_ms, 1, ws.signal_wait_time_ms) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + interval_seconds = + DATEDIFF + ( + SECOND, + LAG(ws.collection_time, 1, ws.collection_time) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), + ws.collection_time + ) + FROM collect.wait_stats AS ws + WHERE ws.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + ) + SELECT + wd.collection_time, + wait_type = N'Total', + wait_time_ms_per_second = + SUM + ( + CASE + WHEN wd.interval_seconds > 0 + THEN CAST(CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds AS decimal(18, 4)) + ELSE 0 + END + ), + signal_wait_time_ms_per_second = + SUM + ( + CASE + WHEN wd.interval_seconds > 0 + THEN CAST(CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds AS decimal(18, 4)) + ELSE 0 + END + ) + FROM wait_deltas AS wd + WHERE wd.wait_time_ms_delta >= 0 + GROUP BY + wd.collection_time + ORDER BY + wd.collection_time ASC;"; + } + + using var command = new SqlCommand(query, connection); + command.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 WaitStatsDataPoint + { + CollectionTime = reader.GetDateTime(0), + WaitType = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + WaitTimeMsPerSecond = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2), CultureInfo.InvariantCulture), + SignalWaitTimeMsPerSecond = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3), CultureInfo.InvariantCulture) + }); + } + + return items; + } } }