From 5e49b10a451cdea30c6fb593855204142dbb2d47 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:29:15 -0500 Subject: [PATCH] Add average ms per wait chart toggle to both apps (#22) Wait stats chart now has a Metric dropdown to switch between: - Wait Time (ms/sec): existing per-second rate (default) - Avg Wait Time (ms/wait): delta_wait_time_ms / delta_waiting_tasks Shows which waits are individually painful vs just high volume. Both Dashboard and Lite get the toggle, hover tooltip unit updates to match, and Lite summary grid gains AvgWaitMsPerTask property. Co-Authored-By: Claude Opus 4.6 --- .../Controls/ResourceMetricsContent.xaml | 9 +++- .../Controls/ResourceMetricsContent.xaml.cs | 19 +++++-- Dashboard/Helpers/ChartHoverHelper.cs | 4 +- Dashboard/Models/WaitStatsDataPoint.cs | 1 + .../DatabaseService.ResourceMetrics.cs | 54 ++++++++++++++++++- Lite/Controls/ServerTab.xaml | 15 +++++- Lite/Controls/ServerTab.xaml.cs | 18 +++++-- Lite/Helpers/ChartHoverHelper.cs | 4 +- Lite/Services/LocalDataService.WaitStats.cs | 10 +++- 9 files changed, 118 insertions(+), 16 deletions(-) diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml index 9ea43c3e..9694d495 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml +++ b/Dashboard/Controls/ResourceMetricsContent.xaml @@ -142,7 +142,14 @@ - + + + + + + + + diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index 604aaff0..8d986440 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -1880,6 +1880,12 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) await UpdateWaitStatsDetailChartAsync(); } + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_allWaitStatsDetailData != null) + LoadWaitStatsDetailChart(_allWaitStatsDetailData, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); + } + private void RefreshWaitTypeListOrder() { if (_waitTypeItems == null) return; @@ -2050,9 +2056,12 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB WaitStatsDetailChart.Plot.Axes.Remove(existingWaitStatsPanel); _legendPanels[WaitStatsDetailChart] = null; } + bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; + WaitStatsDetailChart.Plot.Clear(); TabHelpers.ApplyDarkModeToChart(WaitStatsDetailChart); _waitStatsHover?.Clear(); + if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; if (data == null || data.Count == 0 || _waitTypeItems == null) { @@ -2067,7 +2076,6 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB WaitStatsDetailChart.Refresh(); return; } - var colors = TabHelpers.ChartColors; // Get all time points across all wait types for gap filling @@ -2080,7 +2088,8 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB .GroupBy(d => d.CollectionTime) .Select(g => new { CollectionTime = g.Key, - WaitTimeMsPerSecond = g.Sum(x => x.WaitTimeMsPerSecond) + WaitTimeMsPerSecond = g.Sum(x => x.WaitTimeMsPerSecond), + AvgMsPerWait = g.Average(x => x.AvgMsPerWait) }) .OrderBy(d => d.CollectionTime) .ToList(); @@ -2088,7 +2097,9 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB if (waitTypeData.Count >= 1) { var timePoints = waitTypeData.Select(d => d.CollectionTime); - var values = waitTypeData.Select(d => (double)d.WaitTimeMsPerSecond); + var values = useAvgPerWait + ? waitTypeData.Select(d => (double)d.AvgMsPerWait) + : waitTypeData.Select(d => (double)d.WaitTimeMsPerSecond); var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); var scatter = WaitStatsDetailChart.Plot.Add.Scatter(xs, ys); @@ -2124,7 +2135,7 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB WaitStatsDetailChart.Plot.Axes.DateTimeTicksBottom(); WaitStatsDetailChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(WaitStatsDetailChart); - WaitStatsDetailChart.Plot.YLabel("Wait Time (ms/sec)"); + WaitStatsDetailChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); WaitStatsDetailChart.Plot.HideGrid(); TabHelpers.LockChartVerticalAxis(WaitStatsDetailChart); WaitStatsDetailChart.Refresh(); diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index 747c5d55..048e0c37 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -18,7 +18,7 @@ internal sealed class ChartHoverHelper private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); private readonly Popup _popup; private readonly TextBlock _text; - private readonly string _unit; + private string _unit; private DateTime _lastUpdate; public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) @@ -53,6 +53,8 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) chart.MouseLeave += OnMouseLeave; } + public string Unit { get => _unit; set => _unit = value; } + public void Clear() => _scatters.Clear(); public void Add(ScottPlot.Plottables.Scatter scatter, string label) => diff --git a/Dashboard/Models/WaitStatsDataPoint.cs b/Dashboard/Models/WaitStatsDataPoint.cs index 5471e88c..eba716b2 100644 --- a/Dashboard/Models/WaitStatsDataPoint.cs +++ b/Dashboard/Models/WaitStatsDataPoint.cs @@ -16,5 +16,6 @@ public class WaitStatsDataPoint public string WaitType { get; set; } = string.Empty; public decimal WaitTimeMsPerSecond { get; set; } public decimal SignalWaitTimeMsPerSecond { get; set; } + public decimal AvgMsPerWait { get; set; } } } diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.cs b/Dashboard/Services/DatabaseService.ResourceMetrics.cs index 2d2fb6f8..5bead59f 100644 --- a/Dashboard/Services/DatabaseService.ResourceMetrics.cs +++ b/Dashboard/Services/DatabaseService.ResourceMetrics.cs @@ -1723,6 +1723,14 @@ PARTITION BY ORDER BY ws.collection_time ), + waiting_tasks_delta = + ws.waiting_tasks_count - LAG(ws.waiting_tasks_count, 1, ws.waiting_tasks_count) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), interval_seconds = DATEDIFF ( @@ -1754,6 +1762,12 @@ ELSE 0 WHEN wd.interval_seconds > 0 THEN CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds ELSE 0 + END, + avg_ms_per_wait = + CASE + WHEN wd.waiting_tasks_delta > 0 + THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.waiting_tasks_delta + ELSE 0 END FROM wait_deltas AS wd WHERE wd.wait_time_ms_delta > 0 @@ -1788,6 +1802,14 @@ PARTITION BY ORDER BY ws.collection_time ), + waiting_tasks_delta = + ws.waiting_tasks_count - LAG(ws.waiting_tasks_count, 1, ws.waiting_tasks_count) OVER + ( + PARTITION BY + ws.wait_type + ORDER BY + ws.collection_time + ), interval_seconds = DATEDIFF ( @@ -1818,6 +1840,12 @@ ELSE 0 WHEN wd.interval_seconds > 0 THEN CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds ELSE 0 + END, + avg_ms_per_wait = + CASE + WHEN wd.waiting_tasks_delta > 0 + THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.waiting_tasks_delta + ELSE 0 END FROM wait_deltas AS wd WHERE wd.wait_time_ms_delta > 0 @@ -1840,7 +1868,8 @@ ORDER BY 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) + SignalWaitTimeMsPerSecond = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3), CultureInfo.InvariantCulture), + AvgMsPerWait = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4), CultureInfo.InvariantCulture) }); } @@ -1890,6 +1919,12 @@ ORDER BY ws.collection_time PARTITION BY ws.wait_type ORDER BY ws.collection_time ), + waiting_tasks_delta = + ws.waiting_tasks_count - LAG(ws.waiting_tasks_count, 1, ws.waiting_tasks_count) OVER + ( + PARTITION BY ws.wait_type + ORDER BY ws.collection_time + ), interval_seconds = DATEDIFF(SECOND, LAG(ws.collection_time, 1, ws.collection_time) OVER ( @@ -1911,6 +1946,10 @@ THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds signal_wait_time_ms_per_second = CASE WHEN wd.interval_seconds > 0 THEN CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds + ELSE 0 END, + avg_ms_per_wait = + CASE WHEN wd.waiting_tasks_delta > 0 + THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.waiting_tasks_delta ELSE 0 END FROM wait_deltas AS wd WHERE wd.wait_time_ms_delta > 0 @@ -1939,6 +1978,12 @@ ORDER BY ws.collection_time PARTITION BY ws.wait_type ORDER BY ws.collection_time ), + waiting_tasks_delta = + ws.waiting_tasks_count - LAG(ws.waiting_tasks_count, 1, ws.waiting_tasks_count) OVER + ( + PARTITION BY ws.wait_type + ORDER BY ws.collection_time + ), interval_seconds = DATEDIFF(SECOND, LAG(ws.collection_time, 1, ws.collection_time) OVER ( @@ -1959,6 +2004,10 @@ THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds signal_wait_time_ms_per_second = CASE WHEN wd.interval_seconds > 0 THEN CAST(wd.signal_wait_time_ms_delta AS decimal(19, 4)) / wd.interval_seconds + ELSE 0 END, + avg_ms_per_wait = + CASE WHEN wd.waiting_tasks_delta > 0 + THEN CAST(wd.wait_time_ms_delta AS decimal(19, 4)) / wd.waiting_tasks_delta ELSE 0 END FROM wait_deltas AS wd WHERE wd.wait_time_ms_delta > 0 @@ -1982,7 +2031,8 @@ WHERE wd.wait_time_ms_delta > 0 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) + SignalWaitTimeMsPerSecond = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3), CultureInfo.InvariantCulture), + AvgMsPerWait = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4), CultureInfo.InvariantCulture) }); } diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 192b3a8a..c99a7afe 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -145,7 +145,20 @@ - + + + + + + + + + + + + + + diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 4e046652..0d16810a 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -1139,6 +1139,11 @@ private void WaitTypeClearAll_Click(object sender, RoutedEventArgs e) _ = UpdateWaitStatsChartFromPickerAsync(); } + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _ = UpdateWaitStatsChartFromPickerAsync(); + } + private void WaitType_CheckChanged(object sender, RoutedEventArgs e) { if (_isUpdatingWaitTypeSelection) return; @@ -1158,6 +1163,9 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync() if (selected.Count == 0) { WaitStatsChart.Refresh(); return; } + bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; + if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; + var hoursBack = GetHoursBack(); DateTime? fromDate = null; DateTime? toDate = null; @@ -1179,14 +1187,16 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync() if (trend.Count == 0) continue; var times = trend.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); - var waitTime = trend.Select(t => t.WaitTimeMsPerSecond).ToArray(); + var values = useAvgPerWait + ? trend.Select(t => t.AvgMsPerWait).ToArray() + : trend.Select(t => t.WaitTimeMsPerSecond).ToArray(); - var plot = WaitStatsChart.Plot.Add.Scatter(times, waitTime); + var plot = WaitStatsChart.Plot.Add.Scatter(times, values); plot.LegendText = selected[i].DisplayName; plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); _waitStatsHover?.Add(plot, selected[i].DisplayName); - if (waitTime.Length > 0) globalMax = Math.Max(globalMax, waitTime.Max()); + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); } WaitStatsChart.Plot.Axes.DateTimeTicksBottom(); @@ -1203,7 +1213,7 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync() } WaitStatsChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); ReapplyAxisColors(WaitStatsChart); - WaitStatsChart.Plot.YLabel("Wait Time (ms/sec)"); + WaitStatsChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); SetChartYLimitsWithLegendPadding(WaitStatsChart, 0, globalMax > 0 ? globalMax : 100); ShowChartLegend(WaitStatsChart); WaitStatsChart.Refresh(); diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 5c1e41a0..71a8fbb7 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -18,7 +18,7 @@ internal sealed class ChartHoverHelper private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); private readonly Popup _popup; private readonly TextBlock _text; - private readonly string _unit; + private string _unit; private DateTime _lastUpdate; public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) @@ -53,6 +53,8 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) chart.MouseLeave += OnMouseLeave; } + public string Unit { get => _unit; set => _unit = value; } + public void Clear() => _scatters.Clear(); public void Add(ScottPlot.Plottables.Scatter scatter, string label) => diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index b5678d77..b4c73d06 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -112,6 +112,7 @@ WITH raw AS collection_time, delta_wait_time_ms, delta_signal_wait_time_ms, + delta_waiting_tasks, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds FROM wait_stats WHERE server_id = $1 @@ -122,7 +123,8 @@ FROM wait_stats SELECT collection_time, CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second + CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, + CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait FROM raw ORDER BY collection_time"; @@ -139,7 +141,8 @@ FROM raw { CollectionTime = reader.GetDateTime(0), WaitTimeMsPerSecond = reader.IsDBNull(1) ? 0 : reader.GetDouble(1), - SignalWaitTimeMsPerSecond = reader.IsDBNull(2) ? 0 : reader.GetDouble(2) + SignalWaitTimeMsPerSecond = reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + AvgMsPerWait = reader.IsDBNull(3) ? 0 : reader.GetDouble(3) }); } @@ -155,6 +158,8 @@ public class WaitStatsRow public long TotalSignalWaitTimeMs { get; set; } public long ResourceWaitTimeMs => TotalWaitTimeMs - TotalSignalWaitTimeMs; public long SampleCount { get; set; } + public double AvgWaitMsPerTask => TotalWaitingTasks > 0 ? (double)TotalWaitTimeMs / TotalWaitingTasks : 0; + public string AvgWaitMsFormatted => AvgWaitMsPerTask < 0.1 ? "< 0.1 ms" : $"{AvgWaitMsPerTask:F1} ms"; public string TotalWaitTimeFormatted => FormatMs(TotalWaitTimeMs); public string SignalWaitTimeFormatted => FormatMs(TotalSignalWaitTimeMs); public string ResourceWaitTimeFormatted => FormatMs(ResourceWaitTimeMs); @@ -181,4 +186,5 @@ public class WaitStatsTrendPoint public DateTime CollectionTime { get; set; } public double WaitTimeMsPerSecond { get; set; } public double SignalWaitTimeMsPerSecond { get; set; } + public double AvgMsPerWait { get; set; } }