From 0004d3c72e15a27204dc0845ccd411328155d0ef Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:59:40 -0400 Subject: [PATCH 1/3] Keep Viewer charts and grids readable at 1366x768 (#6652) --- .../ViewerBlockingSourceLabelTests.cs | 2 +- Darling/Darling.Tests/ViewerShortPlotTests.cs | 113 +++++++++ .../CorrelatedTimelineLanesControl.xaml | 18 +- .../CorrelatedTimelineLanesControl.xaml.cs | 2 + .../ViewerServerTab.Blocking.cs | 8 +- .../ViewerServerTab.Charts.cs | 2 +- .../ViewerServerTab.QueryTrends.cs | 12 +- .../ViewerServerTab.xaml | 234 +++++++++--------- .../ViewerServerTab.xaml.cs | 5 + PerformanceMonitor.Ui/ChartAxisTitle.cs | 107 ++++++++ PerformanceMonitor.Ui/ChartStyle.cs | 11 + PerformanceMonitor.Ui/MinimumTickGenerator.cs | 64 +++++ 12 files changed, 446 insertions(+), 132 deletions(-) create mode 100644 Darling/Darling.Tests/ViewerShortPlotTests.cs create mode 100644 PerformanceMonitor.Ui/ChartAxisTitle.cs create mode 100644 PerformanceMonitor.Ui/MinimumTickGenerator.cs diff --git a/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs b/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs index 59e75bfa47..404fd702d3 100644 --- a/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs +++ b/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs @@ -75,7 +75,7 @@ public void TheCharts_PutTheLabelOnTheAxisOnlyWhenTheyDrawRows() var duration = Between(Tab, "private void RenderBlockingDurationChart(", "private void RenderBlockingTotalDurationChart("); var total = Between(Tab, "private void RenderBlockingTotalDurationChart(", "private void RenderDeadlockWaitChart("); - Assert.Single(Occurrences(trend, "BlockingSourceLabel.For(\"Blocking Incidents\", data.Select(d => d.Source))")); + Assert.Single(Occurrences(trend, "BlockingSourceLabel.For(\"Blocking events\", data.Select(d => d.Source))")); Assert.Single(Occurrences(duration, "BlockingSourceLabel.For(\"Block Duration (ms)\", data.Select(d => d.Source))")); Assert.Single(Occurrences(total, "BlockingSourceLabel.For(\"Total Block Duration (ms)\", data.Select(d => d.Source))")); /* The zero-row branch returns before it: nothing answered, so nothing is named. */ diff --git a/Darling/Darling.Tests/ViewerShortPlotTests.cs b/Darling/Darling.Tests/ViewerShortPlotTests.cs new file mode 100644 index 0000000000..31f3ea35b3 --- /dev/null +++ b/Darling/Darling.Tests/ViewerShortPlotTests.cs @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using PerformanceMonitor.Ui; +using ScottPlot; +using Xunit; + +namespace Darling.Tests; + +/// +/// #6652: at 1366x768 the Viewer's stacked charts get short plots. A long left-axis title was cut at both ends (the unit +/// lost) and the y axis could show only "0". The title fit and the two-tick floor are the pure rules behind the fix. +/// +public sealed class ViewerShortPlotTests +{ + // The plot height a chart row at its minimum leaves for the rotated title. + private static double MinRowTitleSpace => ChartLayout.MinPlotHeight - ChartAxisTitle.ReservedHeight; + + public static TheoryData LongTitles => new() + { + "Lock Wait Time (ms/sec)", + "Total Block Duration (ms)", + "Total Deadlock Wait (ms)", + "Total Wait Duration (ms)", + "Avg Wait Time (ms/wait)", + "Read Throughput (MB/s)", + "Write Throughput (MB/s)", + "Pressure Events per Hour", + "Slowest run per bucket (ms)", + "Plan Cache Size (MB)", + }; + + [Theory] + [MemberData(nameof(LongTitles))] + public void LongTitle_FitsTheMinimumRowAndKeepsItsUnit(string title) + { + var (text, size) = ChartAxisTitle.Fit(title, MinRowTitleSpace); + + Assert.InRange(size, ChartAxisTitle.MinFontSize, ChartAxisTitle.BaseFontSize); + Assert.True(text.Length * 0.6 * size <= MinRowTitleSpace + 0.5, $"'{text}' at {size}pt is longer than {MinRowTitleSpace}px"); + if (title.EndsWith(')')) + { + Assert.EndsWith(title[title.LastIndexOf('(')..], text); + } + } + + [Fact] + public void ShortTitle_IsLeftAlone() + { + Assert.Equal(("CPU %", ChartAxisTitle.BaseFontSize), ChartAxisTitle.Fit("CPU %", MinRowTitleSpace)); + } + + [Fact] + public void LongSourceNote_IsTrimmedNotTreatedAsAUnit() + { + var (text, _) = ChartAxisTitle.Fit("Blocking events (blocked-process-report)", MinRowTitleSpace); + Assert.StartsWith("Blocking", text); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + [InlineData(double.NaN)] + public void UnknownSpace_ReturnsTheTitleUnchanged(double available) + { + Assert.Equal(("Lock wait ms/s", ChartAxisTitle.BaseFontSize), ChartAxisTitle.Fit("Lock wait ms/s", available)); + } + + [Fact] + public void AllZeroAxis_GetsAtLeastTwoLabelledTicks() + { + var ticks = MinimumTickGenerator.EnsureMinimum(System.Array.Empty(), 0, 1); + + Assert.True(ticks.Count(t => t.IsMajor) >= 2); + Assert.Contains(ticks, t => t.Label == "0"); + Assert.Contains(ticks, t => t.Label == "1"); + } + + [Fact] + public void OnlyZeroTick_GetsTheTopOfTheAxisAdded() + { + var ticks = MinimumTickGenerator.EnsureMinimum(new[] { Tick.Major(0, "0") }, 0, 1.15); + + Assert.Equal(new[] { 0.0, 1.15 }, ticks.Select(t => t.Position).ToArray()); + Assert.Equal("1.15", ticks[1].Label); + } + + [Fact] + public void EnoughTicks_AreReturnedUntouched() + { + var given = new[] { Tick.Major(0, "0"), Tick.Major(50, "50"), Tick.Major(100, "100") }; + Assert.Same(given, MinimumTickGenerator.EnsureMinimum(given, 0, 105)); + } + + [Fact] + public void ZeroWidthRange_StillGetsTwoTicks() + { + var ticks = MinimumTickGenerator.EnsureMinimum(System.Array.Empty(), 0, 0); + Assert.Equal(new[] { 0.0, 1.0 }, ticks.Select(t => t.Position).ToArray()); + } + + [Fact] + public void MinimumPlotHeight_IsTallEnoughForTicks() + { + Assert.InRange(ChartLayout.MinPlotHeight, 140, 200); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml index e7456eaa50..b4d24194cf 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml @@ -1,16 +1,19 @@ + xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"> - + + + - - - - - + + + + + @@ -88,4 +91,5 @@ + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs index f8a2c69e81..4aba558caa 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs @@ -57,6 +57,8 @@ public partial class CorrelatedTimelineLanesControl : UserControl public CorrelatedTimelineLanesControl() { InitializeComponent(); + // #6652: the lanes scroll at small sizes; the wheel over a lane stays that lane's zoom. + PerformanceMonitor.Ui.ChartLayout.KeepWheelOnPlot(CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart); /* No Unloaded → Dispose() handler: WPF fires Unloaded for transient reasons (tab virtualization, layout rebuilds) and Dispose() clears the crosshair manager's lane list, permanently breaking the crosshair diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs index bf5273b02d..4fb4c1c320 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs @@ -506,7 +506,7 @@ private void RenderLockWaitTrendChart(List data) LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); ReapplyAxisColors(LockWaitTrendChart); - LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + LockWaitTrendChart.Plot.YLabel("Lock wait ms/s"); SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, 1); ShowChartLegend(LockWaitTrendChart); LockWaitTrendChart.Refresh(); @@ -534,7 +534,7 @@ private void RenderLockWaitTrendChart(List data) LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); ReapplyAxisColors(LockWaitTrendChart); - LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + LockWaitTrendChart.Plot.YLabel("Lock wait ms/s"); SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, globalMax > 0 ? globalMax : 1); ShowChartLegend(LockWaitTrendChart); LockWaitTrendChart.Refresh(); @@ -563,7 +563,7 @@ private void RenderBlockingTrendChart(List data) BlockingTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); ReapplyAxisColors(BlockingTrendChart); - BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + BlockingTrendChart.Plot.YLabel("Blocking events"); SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1); ShowChartLegend(BlockingTrendChart); BlockingTrendChart.Refresh(); @@ -605,7 +605,7 @@ private void RenderBlockingTrendChart(List data) BlockingTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); ReapplyAxisColors(BlockingTrendChart); - BlockingTrendChart.Plot.YLabel(PerformanceMonitor.Ui.BlockingSourceLabel.For("Blocking Incidents", data.Select(d => d.Source))); + BlockingTrendChart.Plot.YLabel(PerformanceMonitor.Ui.BlockingSourceLabel.For("Blocking events", data.Select(d => d.Source))); SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, data.Max(d => d.Count)); ShowChartLegend(BlockingTrendChart); BlockingTrendChart.Refresh(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs index 275b2fe336..72c5e66464 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs @@ -324,7 +324,7 @@ private void RenderTempDbFileIoChart(List data, DateTime sta TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); TempDbFileIoChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); ReapplyAxisColors(TempDbFileIoChart); - TempDbFileIoChart.Plot.YLabel("tempdb File I/O Latency (ms)"); + TempDbFileIoChart.Plot.YLabel("tempdb latency ms"); SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10); ShowChartLegend(TempDbFileIoChart); TempDbFileIoChart.Refresh(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs index 230f034eec..618611d10e 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs @@ -227,7 +227,7 @@ private void UpdateQueryDurationTrendChart(QueryTrendSeries series, DateTime sta ClearChart(QueryDurationTrendChart); ApplyTheme(QueryDurationTrendChart); - if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; } + if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration ms/s"); return; } ShowTrendCoverage(QueryDurationTrendChart, series); var rangeStart = startUtc.ToOADate(); @@ -246,7 +246,7 @@ private void UpdateQueryDurationTrendChart(QueryTrendSeries series, DateTime sta QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); QueryDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); ReapplyAxisColors(QueryDurationTrendChart); - QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + QueryDurationTrendChart.Plot.YLabel("Duration ms/s"); SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max()); ShowChartLegend(QueryDurationTrendChart); QueryDurationTrendChart.Refresh(); @@ -258,7 +258,7 @@ private void UpdateProcDurationTrendChart(QueryTrendSeries series, DateTime star ClearChart(ProcDurationTrendChart); ApplyTheme(ProcDurationTrendChart); - if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; } + if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration ms/s"); return; } ShowTrendCoverage(ProcDurationTrendChart, series); var rangeStart = startUtc.ToOADate(); @@ -277,7 +277,7 @@ private void UpdateProcDurationTrendChart(QueryTrendSeries series, DateTime star ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); ProcDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); ReapplyAxisColors(ProcDurationTrendChart); - ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + ProcDurationTrendChart.Plot.YLabel("Duration ms/s"); SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max()); ShowChartLegend(ProcDurationTrendChart); ProcDurationTrendChart.Refresh(); @@ -289,7 +289,7 @@ private void UpdateQueryStoreDurationTrendChart(QueryStoreTrendSeries series, Da ClearChart(QueryStoreDurationTrendChart); ApplyTheme(QueryStoreDurationTrendChart); - if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; } + if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration ms/s"); return; } ShowTrendCoverage(QueryStoreDurationTrendChart, series); var rangeStart = startUtc.ToOADate(); @@ -308,7 +308,7 @@ private void UpdateQueryStoreDurationTrendChart(QueryStoreTrendSeries series, Da QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone); QueryStoreDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); ReapplyAxisColors(QueryStoreDurationTrendChart); - QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + QueryStoreDurationTrendChart.Plot.YLabel("Duration ms/s"); SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max()); ShowChartLegend(QueryStoreDurationTrendChart); QueryStoreDurationTrendChart.Refresh(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index 0635bb0cb9..ff0885f37f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -647,10 +647,11 @@ - + + - - + + @@ -669,6 +670,7 @@ + + - - + + @@ -2524,6 +2527,7 @@ + @@ -2546,11 +2550,12 @@ space usage (user/internal/version-store), total allocated size, and per-file I/O latency. --> - + + - - - + + + @@ -2565,6 +2570,7 @@ + + - + - + - + @@ -2616,6 +2623,7 @@ + - -