From f1c8160d78ddbd8178ee09ff4040bd722c6c56c0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:20:09 -0400 Subject: [PATCH] Fix Overview crosshair disappearing after tab switches / layout passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the control wired `Unloaded += ...Dispose()` on the crosshair manager, and WPF fires Unloaded for transient reasons (tab virtualization, layout rebuilds, etc.), not just when the control is actually going away. Dispose() clears the manager's lane list, after which ReattachVLines runs over an empty list and the crosshair is gone permanently. Changes: - Remove the Unloaded → Dispose() handler in both Lite and Dashboard copies. The manager holds only managed state (a Popup + lane references) — GC will clean it up with the control. - Remove the now-redundant `_isRefreshing` flag from CorrelatedCrosshairManager. The `lane.VLine == null` check in OnMouseMove is a sufficient "not ready" guard and is self-healing once VLines are recreated. - Wrap ReattachVLines in a try/finally on the control side, with a new idempotent EnsureVLinesAttached() safety net that only creates VLines for lanes where they're still null. - Make CreateVLine catch per-lane exceptions so one failing chart can't prevent the others from recovering. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CorrelatedTimelineLanesControl.xaml.cs | 20 ++++++++- .../Helpers/CorrelatedCrosshairManager.cs | 43 ++++++++++++++++--- .../CorrelatedTimelineLanesControl.xaml.cs | 13 +++++- Lite/Helpers/CorrelatedCrosshairManager.cs | 43 ++++++++++++++++--- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 7d8f878f..7beb4696 100644 --- a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -31,7 +31,12 @@ public partial class CorrelatedTimelineLanesControl : UserControl public CorrelatedTimelineLanesControl() { InitializeComponent(); - Unloaded += (_, _) => _crosshairManager?.Dispose(); + /* 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 + until the ServerTab is rebuilt. The manager holds only managed state + (a Popup + lane references) — letting GC clean it up with the control + is fine. */ } /// @@ -69,6 +74,9 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.PrepareForRefresh(); + try + { + var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); @@ -225,8 +233,18 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); } + /* VLines must be re-attached before SyncXAxes so they're part of + the render set when the chart refreshes. */ _crosshairManager?.ReattachVLines(); SyncXAxes(hoursBack, fromDate, toDate); + } + finally + { + /* Safety net: if something threw between PrepareForRefresh() and the + ReattachVLines() call above, VLines are still null. EnsureVLinesAttached + creates them only for lanes where VLine is null, so it's idempotent. */ + _crosshairManager?.EnsureVLinesAttached(); + } } /// diff --git a/Dashboard/Helpers/CorrelatedCrosshairManager.cs b/Dashboard/Helpers/CorrelatedCrosshairManager.cs index 80e2ec7d..a13efb2d 100644 --- a/Dashboard/Helpers/CorrelatedCrosshairManager.cs +++ b/Dashboard/Helpers/CorrelatedCrosshairManager.cs @@ -33,7 +33,6 @@ internal sealed class CorrelatedCrosshairManager : IDisposable private readonly Popup _tooltip; private readonly TextBlock _tooltipText; private DateTime _lastUpdate; - private bool _isRefreshing; public CorrelatedCrosshairManager() { @@ -144,10 +143,11 @@ public void SetComparisonLabel(string label) /// /// Clears data and VLines. Call before re-populating charts. + /// The OnMouseMove guard relies on lane.VLine == null to detect "not ready", + /// so this is self-healing: once ReattachVLines runs, crosshairs resume. /// public void PrepareForRefresh() { - _isRefreshing = true; _tooltip.IsOpen = false; _comparisonLabel = null; foreach (var lane in _lanes) @@ -162,25 +162,54 @@ public void PrepareForRefresh() /// /// Creates fresh VLine plottables on each lane's chart. - /// Must be called AFTER chart data is populated. + /// Must be called AFTER chart data is populated. Safe to call in a finally + /// block — if chart state is invalid, a failure on one lane won't prevent + /// the others from recovering. /// public void ReattachVLines() { foreach (var lane in _lanes) { - var vline = lane.Chart.Plot.Add.VerticalLine(0); + lane.VLine = CreateVLine(lane.Chart); + } + } + + /// + /// Creates VLines only for lanes that don't already have one. Idempotent — + /// safe to call from a finally block as a recovery path after an exception + /// in the main refresh flow. + /// + public void EnsureVLinesAttached() + { + foreach (var lane in _lanes) + { + if (lane.VLine != null) continue; + lane.VLine = CreateVLine(lane.Chart); + } + } + + private static ScottPlot.Plottables.VerticalLine? CreateVLine(ScottPlot.WPF.WpfPlot chart) + { + try + { + var vline = 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; + return vline; + } + catch + { + /* If attach fails, return null so OnMouseMove skips this lane. + Next refresh will try again. */ + return null; } - _isRefreshing = false; } private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) { - if (_isRefreshing || sourceLane.VLine == null) return; + if (sourceLane.VLine == null) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 16) return; diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs index beb4c572..2b73b71a 100644 --- a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -31,7 +31,12 @@ public partial class CorrelatedTimelineLanesControl : UserControl public CorrelatedTimelineLanesControl() { InitializeComponent(); - Unloaded += (_, _) => _crosshairManager?.Dispose(); + /* 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 + until the ServerTab is rebuilt. The manager holds only managed state + (a Popup + lane references) — letting GC clean it up with the control + is fine. */ } /// @@ -203,11 +208,17 @@ await Task.WhenAll(cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fi _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); } + /* VLines must be re-attached before SyncXAxes so they're part of + the render set when the chart refreshes. */ _crosshairManager?.ReattachVLines(); SyncXAxes(hoursBack, fromDate, toDate, utcOffset); } finally { + /* Safety net: if something threw between PrepareForRefresh() and the + ReattachVLines() call above, VLines are still null. EnsureVLinesAttached + creates them only for lanes where VLine is null, so it's idempotent. */ + _crosshairManager?.EnsureVLinesAttached(); _isRefreshing = false; } } diff --git a/Lite/Helpers/CorrelatedCrosshairManager.cs b/Lite/Helpers/CorrelatedCrosshairManager.cs index 75bce9bb..00098b4f 100644 --- a/Lite/Helpers/CorrelatedCrosshairManager.cs +++ b/Lite/Helpers/CorrelatedCrosshairManager.cs @@ -33,7 +33,6 @@ internal sealed class CorrelatedCrosshairManager : IDisposable private readonly Popup _tooltip; private readonly TextBlock _tooltipText; private DateTime _lastUpdate; - private bool _isRefreshing; public CorrelatedCrosshairManager() { @@ -144,10 +143,11 @@ public void SetComparisonLabel(string label) /// /// Clears data and VLines. Call before re-populating charts. + /// The OnMouseMove guard relies on lane.VLine == null to detect "not ready", + /// so this is self-healing: once ReattachVLines runs, crosshairs resume. /// public void PrepareForRefresh() { - _isRefreshing = true; _tooltip.IsOpen = false; _comparisonLabel = null; foreach (var lane in _lanes) @@ -162,25 +162,54 @@ public void PrepareForRefresh() /// /// Creates fresh VLine plottables on each lane's chart. - /// Must be called AFTER chart data is populated. + /// Must be called AFTER chart data is populated. Safe to call in a finally + /// block — if chart state is invalid, a failure on one lane won't prevent + /// the others from recovering. /// public void ReattachVLines() { foreach (var lane in _lanes) { - var vline = lane.Chart.Plot.Add.VerticalLine(0); + lane.VLine = CreateVLine(lane.Chart); + } + } + + /// + /// Creates VLines only for lanes that don't already have one. Idempotent — + /// safe to call from a finally block as a recovery path after an exception + /// in the main refresh flow. + /// + public void EnsureVLinesAttached() + { + foreach (var lane in _lanes) + { + if (lane.VLine != null) continue; + lane.VLine = CreateVLine(lane.Chart); + } + } + + private static ScottPlot.Plottables.VerticalLine? CreateVLine(ScottPlot.WPF.WpfPlot chart) + { + try + { + var vline = 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; + return vline; + } + catch + { + /* If attach fails, return null so OnMouseMove skips this lane. + Next refresh will try again. */ + return null; } - _isRefreshing = false; } private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) { - if (_isRefreshing || sourceLane.VLine == null) return; + if (sourceLane.VLine == null) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 16) return;