Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #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
/// <see cref="PerformanceMonitor.Common.JobHistoryCap"/>. Lite's UI is WPF (<c>PresentationFramework</c>) and
/// cannot be instantiated here, so this pins the SOURCE for the call and the loading element the same way
/// <c>ViewerPerfmonShapingParityTests</c> pins Viewer/Lite parity — a source scan, not a rendered control.
/// The label text itself is pinned once, off this call path, by <c>JobHistoryCapLabelTests</c>.
/// </summary>
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);
}
}
5 changes: 4 additions & 1 deletion Darling/Darling.Tests/ScopedLoadOrderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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");

/// <summary>A XAML-generated control and one of the UI properties a load paints into.</summary>
private const string PaintTarget =
Expand Down
12 changes: 9 additions & 3 deletions Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public partial class JobHistoryTab : UserControl
{
private ViewerDataService? _dataService;
private DataGridFilterManager<ViewerJobHistoryRow>? _filterManager;
private readonly PerformanceMonitor.Ui.ScopedLoadGenerations _loads = new();
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
private DateTime? _lastRefreshed;
Expand Down Expand Up @@ -68,6 +69,8 @@ private async Task LoadJobsAsync()
return;
}

var gen = _loads.Claim(nameof(LoadJobsAsync));

NoJobsMessage.Visibility = Visibility.Collapsed;
LoadingMessage.Visibility = Visibility.Visible;

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down
8 changes: 8 additions & 0 deletions Lite/Controls/JobHistoryTab.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>

<!-- Loading State (#4478): shown while a read is in flight, so an empty grid does not read as
"no jobs in the last 24 hours" for the several seconds a fleet-wide read takes. -->
<TextBlock x:Name="LoadingMessage"
Text="Loading job history…"
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Grid>
</UserControl>
24 changes: 22 additions & 2 deletions Lite/Controls/JobHistoryTab.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public async void RefreshJobs()
await LoadJobsAsync();
}

/// <summary>The read's row cap (#4478) — the 2,000 <see cref="LoadJobsAsync"/> passes to
/// <see cref="LocalDataService.GetJobHistoryAsync"/>.</summary>
private const int RowCap = 2000;

private async System.Threading.Tasks.Task LoadJobsAsync()
{
if (_dataService == null) return;
Expand All @@ -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
Expand Down Expand Up @@ -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<JobHistoryRow> 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;
}
}

Expand Down
Loading