diff --git a/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs b/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs new file mode 100644 index 000000000..cb9e4254c --- /dev/null +++ b/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs @@ -0,0 +1,65 @@ +/* + * 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; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4478's Lite half: Lite's Job History tab gets the same loading state and "showing the newest 2,000" +/// cap label that #4481 gave the Viewer's tab, through the shared +/// . Lite's UI is WPF (PresentationFramework) and +/// cannot be instantiated here, so this pins the SOURCE for the call and the loading element the same way +/// ViewerPerfmonShapingParityTests pins Viewer/Lite parity — a source scan, not a rendered control. +/// The label text itself is pinned once, off this call path, by JobHistoryCapLabelTests. +/// +public sealed class LiteJobHistoryLoadingCapTests +{ + [Fact] + public void LiteJobHistoryTab_CallsTheSharedCapLabel_AndDeclaresALoadingElement() + { + var cs = RepoFile.ReadRepoFile("Lite", "Controls", "JobHistoryTab.xaml.cs"); + var stripped = CSharpSourceWalker.StripCommentsAndStrings(cs); + + Assert.Contains("JobHistoryCap.Label(", stripped, StringComparison.Ordinal); + + /* The loading element is shown before the read starts and collapsed in a finally, so it clears on + every exit path (success, the caught exception, and a superseded/early return). */ + var head = stripped.IndexOf("private async System.Threading.Tasks.Task LoadJobsAsync()", StringComparison.Ordinal); + Assert.True(head >= 0, "LoadJobsAsync was not found in Lite/Controls/JobHistoryTab.xaml.cs"); + var open = stripped.IndexOf('{', head); + var body = CSharpSourceWalker.BraceBalanced(stripped, open); + + Assert.Contains("LoadingMessage.Visibility = Visibility.Visible;", body, StringComparison.Ordinal); + + /* #4478/#4488: an unconditional finally collapsed the loading message for a SUPERSEDED load too, so + the earlier of two overlapping reads could hide the newer load's indicator. The collapse on both + exit paths (success and the caught exception) now sits behind the same _loads.Superseded check as + every other paint in this method — asserting on that shape rather than on a bare finally, which is + exactly the shape that was wrong. */ + var collapseCount = Regex.Matches(body, @"LoadingMessage\s*\.\s*Visibility\s*=\s*Visibility\s*\.\s*Collapsed\s*;").Count; + Assert.True(collapseCount >= 2, "LoadJobsAsync should collapse the loading message on both the success and the caught-exception exit path"); + + var guardedCollapses = Regex.Matches( + body, + @"if\s*\(\s*_loads\s*\.\s*Superseded\s*\(\s*nameof\s*\(\s*LoadJobsAsync\s*\)\s*,\s*gen\s*\)\s*\)\s*return\s*;\s*\r?\n\s*\r?\n?\s*LoadingMessage\s*\.\s*Visibility\s*=\s*Visibility\s*\.\s*Collapsed\s*;").Count; + Assert.True( + guardedCollapses >= 2, + "LoadJobsAsync's loading-message collapse must sit behind its own supersession check, not run unconditionally for a superseded load"); + } + + [Fact] + public void LiteJobHistoryXaml_DeclaresTheLoadingElement() + { + var xaml = RepoFile.ReadRepoFile("Lite", "Controls", "JobHistoryTab.xaml"); + + Assert.Contains("x:Name=\"LoadingMessage\"", xaml, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ScopedLoadOrderingTests.cs b/Darling/Darling.Tests/ScopedLoadOrderingTests.cs index 88bb81f7a..8fda165ca 100644 --- a/Darling/Darling.Tests/ScopedLoadOrderingTests.cs +++ b/Darling/Darling.Tests/ScopedLoadOrderingTests.cs @@ -225,6 +225,9 @@ the grid they scope. */ new Site(s_finOps, "JobHistoryTab.xaml.cs", "Task", "LoadJobsAsync"), new Site(s_finOps, "JobHistoryTab.xaml.cs", "Task", "UpdateAgentStatusAsync"), + /* The Viewer's twin (#4478/#4488) — same fleet-wide read, same missing guard on its loading paint. */ + new Site(s_viewer, "JobHistoryTab.xaml.cs", "Task", "LoadJobsAsync"), + /* #2929's second and third sites, now ordered as well as scope-verified. Both twins, because the same-scope hole is identical in them and fixing one half is how a parity gap starts. */ new Site(s_liteWindows, "DatabaseStateOverridesWindow.xaml.cs", "Task", "LoadAsync"), @@ -521,7 +524,7 @@ public void NoSiteClaims_AboveAReturnThatPaintsNothing() // ── Site mechanics ──────────────────────────────────────────────────────────────────────── private static void AssertEverySiteIsStillHere() => - Assert.True(s_sites.Length == 27, $"{s_sites.Length} site(s) in the table; this pin covers 27"); + Assert.True(s_sites.Length == 28, $"{s_sites.Length} site(s) in the table; this pin covers 28"); /// A XAML-generated control and one of the UI properties a load paints into. private const string PaintTarget = diff --git a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs index af046c5c2..d0c2c0ac1 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs @@ -31,6 +31,7 @@ public partial class JobHistoryTab : UserControl { private ViewerDataService? _dataService; private DataGridFilterManager? _filterManager; + private readonly PerformanceMonitor.Ui.ScopedLoadGenerations _loads = new(); private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; private DateTime? _lastRefreshed; @@ -68,6 +69,8 @@ private async Task LoadJobsAsync() return; } + var gen = _loads.Claim(nameof(LoadJobsAsync)); + NoJobsMessage.Visibility = Visibility.Collapsed; LoadingMessage.Visibility = Visibility.Visible; @@ -78,6 +81,7 @@ private async Task LoadJobsAsync() var sinceUtc = DateTime.UtcNow.AddHours(-hoursBack); var all = await _dataService.GetJobHistoryAsync(sinceUtc, serverId, RowCap); + if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; /* Populate the Server / Category combos from the full (pre status/category) result, then apply Status + Category client-side — those must NOT go into the reader's window (they'd skew the @@ -118,13 +122,15 @@ the underlying read still hit the cap. */ UpdateStaleDataIndicator(); await UpdateAgentStatusAsync(serverId); + if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; + + LoadingMessage.Visibility = Visibility.Collapsed; } catch (Exception ex) { StatusChanged?.Invoke($"failed to load job history: {ex.Message}"); - } - finally - { + if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; + LoadingMessage.Visibility = Visibility.Collapsed; } } diff --git a/Lite/Controls/JobHistoryTab.xaml b/Lite/Controls/JobHistoryTab.xaml index 1d541e014..9918dfa07 100644 --- a/Lite/Controls/JobHistoryTab.xaml +++ b/Lite/Controls/JobHistoryTab.xaml @@ -206,6 +206,14 @@ FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/> + + + diff --git a/Lite/Controls/JobHistoryTab.xaml.cs b/Lite/Controls/JobHistoryTab.xaml.cs index d6dfaf128..d5a46254f 100644 --- a/Lite/Controls/JobHistoryTab.xaml.cs +++ b/Lite/Controls/JobHistoryTab.xaml.cs @@ -66,6 +66,10 @@ public async void RefreshJobs() await LoadJobsAsync(); } + /// The read's row cap (#4478) — the 2,000 passes to + /// . + private const int RowCap = 2000; + private async System.Threading.Tasks.Task LoadJobsAsync() { if (_dataService == null) return; @@ -74,12 +78,15 @@ private async System.Threading.Tasks.Task LoadJobsAsync() starting of two overlapping reads can land first. Same idiom as FinOpsTab's. */ var gen = _loads.Claim(nameof(LoadJobsAsync)); + NoJobsMessage.Visibility = Visibility.Collapsed; + LoadingMessage.Visibility = Visibility.Visible; + try { var hoursBack = GetSelectedHoursBack(); int? serverId = GetSelectedServerId(); - var all = await System.Threading.Tasks.Task.Run(() => _dataService.GetJobHistoryAsync(hoursBack, 2000, serverId)); + var all = await System.Threading.Tasks.Task.Run(() => _dataService.GetJobHistoryAsync(hoursBack, RowCap, serverId)); if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; /* #2126: rows carry the raw collected server name; swap in the operator's alias where the @@ -118,17 +125,30 @@ other tab. A server no longer in config keeps its raw name (the durable-record c var displayCount = JobHistoryDataGrid.ItemsSource is ICollection coll ? coll.Count : filtered.Count; NoJobsMessage.Visibility = displayCount == 0 ? Visibility.Visible : Visibility.Collapsed; - JobCountIndicator.Text = displayCount > 0 ? $"{displayCount} run(s)" : ""; + + /* The cap applies to the UNFILTERED read (all.Count), not the client-side-filtered display count: + a Status/Category filter narrowing the grid must not make the "newest 2,000" label disappear when + the underlying read still hit the cap. */ + var capLabel = JobHistoryCap.Label(all.Count, RowCap); + JobCountIndicator.Text = displayCount == 0 + ? "" + : capLabel.Length > 0 ? $"{displayCount} run(s) ({capLabel})" : $"{displayCount} run(s)"; AppLogger.Debug("JobHistory", $"Loaded {displayCount} job run(s) (query returned {all.Count}, hoursBack={hoursBack}, serverId={serverId?.ToString() ?? "all"})"); _lastRefreshed = DateTime.UtcNow; UpdateStaleDataIndicator(); await UpdateAgentStatusAsync(serverId); + if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; + + LoadingMessage.Visibility = Visibility.Collapsed; } catch (Exception ex) { AppLogger.Error("JobHistory", $"Failed to load job history: {ex.Message}"); + if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return; + + LoadingMessage.Visibility = Visibility.Collapsed; } }