diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 7d8f878..7beb469 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 80e2ec7..a13efb2 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 beb4c57..2b73b71 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 75bce9b..00098b4 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;