diff --git a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs
index ba34e104e0..00079697fd 100644
--- a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs
@@ -301,12 +301,12 @@ index rather than the tool list learns the row grew. */
///
/// The three grids that render this row grew the same two trailing columns: the web tile's
/// QS_HEALTH_COLUMNS ends with the two payload keys (formatless, so the renderer prints a null as
- /// the page's dash), and both WPF Query Store grids end with Capture Mode then Wait Stats
- /// Capture, bound to the display properties that render a null as the same glyph — and the two XAML
+ /// the page's dash), and both WPF Query Store grids end with Capture Mode, Wait Stats
+ /// Capture then Collected (#4966, bound to CaptureTimeLocal), bound to the display properties that render a null as the same glyph — and the two XAML
/// grids carry the identical binding sequence, the way every twinned grid in the two front ends does.
///
[Fact]
- public void QueryStoreHealth_EveryGrid_EndsWithTheTwoCaptureModeColumns()
+ public void QueryStoreHealth_EveryGrid_EndsWithTheTwoCaptureModeColumnsThenCollected()
{
var js = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js");
var marker = "const QS_HEALTH_COLUMNS = [";
@@ -325,8 +325,9 @@ public void QueryStoreHealth_EveryGrid_EndsWithTheTwoCaptureModeColumns()
var viewerBindings = GridBindings(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml"));
var liteBindings = GridBindings(RepoFile.ReadRepoFile("Lite", "Controls", "ServerTab.xaml"));
Assert.Equal(viewerBindings, liteBindings);
- Assert.Equal("CaptureModeDisplay", viewerBindings[^2]);
- Assert.Equal("WaitStatsCaptureModeDisplay", viewerBindings[^1]);
+ Assert.Equal("CaptureModeDisplay", viewerBindings[^3]);
+ Assert.Equal("WaitStatsCaptureModeDisplay", viewerBindings[^2]);
+ Assert.Equal("CaptureTimeLocal", viewerBindings[^1]);
foreach (var xaml in new[] { RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml"), RepoFile.ReadRepoFile("Lite", "Controls", "ServerTab.xaml") })
{
diff --git a/Darling/Darling.Tests/ViewerConfigGridCollectedColumnTests.cs b/Darling/Darling.Tests/ViewerConfigGridCollectedColumnTests.cs
new file mode 100644
index 0000000000..ecfbe4ce7b
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerConfigGridCollectedColumnTests.cs
@@ -0,0 +1,80 @@
+/*
+ * 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.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// #4966: the viewer's five Configuration grids show the newest snapshot, so each ends with a Collected column that says when
+/// that snapshot was captured, the same last column Lite's twin grids carry. The column shows the time to the second in the display
+/// zone and sorts by the stored UTC instant.
+///
+[Collection("viewer-time-statics")]
+public sealed class ViewerConfigGridCollectedColumnTests
+{
+ [Theory]
+ [InlineData("ServerConfigGrid")]
+ [InlineData("DatabaseConfigGrid")]
+ [InlineData("DatabaseScopedConfigGrid")]
+ [InlineData("QueryStoreHealthGrid")]
+ [InlineData("TraceFlagsGrid")]
+ public void EachConfigGrid_EndsWithACollectedColumn_SortingOnTheStoredInstant(string gridName)
+ {
+ var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml");
+ var start = xaml.IndexOf($"x:Name=\"{gridName}\"", StringComparison.Ordinal);
+ Assert.True(start >= 0, $"no {gridName} in the viewer XAML");
+ var end = xaml.IndexOf("", start, StringComparison.Ordinal);
+ Assert.True(end > start, $"{gridName} is unterminated");
+ var grid = xaml[start..end];
+
+ var columns = Regex.Matches(grid, @"]*Binding=""\{Binding ([A-Za-z]+)\}""[^>]*>").ToList();
+ var last = columns[^1];
+ Assert.Equal("CaptureTimeLocal", last.Groups[1].Value);
+ Assert.Contains("SortMemberPath=\"CaptureTime\"", last.Value, StringComparison.Ordinal);
+ Assert.Contains("DarlingCurrentConfigReader.TraceFlagsSql and Lite's twin carry the same test.
///
public const string TraceFlagsSql = """
- SELECT trace_flag, status, is_global, is_session
+ SELECT trace_flag, status, is_global, is_session, capture_time
FROM v_trace_flags
WHERE server_id = $1
AND capture_time = (SELECT MAX(capture_time) FROM v_trace_flags WHERE server_id = $1)
@@ -120,7 +121,8 @@ public async Task> GetLatestServerConfigAsync(int serverId
ValueConfigured = reader.IsDBNull(1) ? 0 : reader.GetInt64(1),
ValueInUse = reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
IsDynamic = !reader.IsDBNull(3) && reader.GetBoolean(3),
- IsAdvanced = !reader.IsDBNull(4) && reader.GetBoolean(4)
+ IsAdvanced = !reader.IsDBNull(4) && reader.GetBoolean(4),
+ CaptureTime = reader.GetDateTime(5)
});
}
@@ -172,6 +174,7 @@ DatabaseConfigSql binds to the same properties — see the SELECT's byte-identic
IsAcceleratedDatabaseRecoveryOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal),
IsMemoryOptimizedEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal),
IsOptimizedLockingOn = reader.IsDBNull(++ordinal) ? null : reader.GetBoolean(ordinal),
+ CaptureTime = reader.GetDateTime(++ordinal),
});
}
@@ -195,7 +198,8 @@ public async Task> GetLatestDatabaseScopedConfigAs
DatabaseName = reader.GetString(0),
ConfigurationName = reader.GetString(1),
Value = reader.IsDBNull(2) ? "" : reader.GetString(2),
- ValueForSecondary = reader.IsDBNull(3) ? "" : reader.GetString(3)
+ ValueForSecondary = reader.IsDBNull(3) ? "" : reader.GetString(3),
+ CaptureTime = reader.GetDateTime(4)
});
}
@@ -231,6 +235,7 @@ public async Task> GetLatestQueryStoreHealthAsync(int
and the display properties render it as the grid's absence glyph rather than as "". */
QueryCaptureMode = reader.IsDBNull(10) ? null : reader.GetString(10),
WaitStatsCaptureMode = reader.IsDBNull(11) ? null : reader.GetString(11),
+ CaptureTime = reader.GetDateTime(12),
});
}
@@ -253,7 +258,8 @@ public async Task> GetLatestTraceFlagsAsync(int serverId, Can
TraceFlag = reader.GetInt32(0),
Status = !reader.IsDBNull(1) && reader.GetBoolean(1),
IsGlobal = !reader.IsDBNull(2) && reader.GetBoolean(2),
- IsSession = !reader.IsDBNull(3) && reader.GetBoolean(3)
+ IsSession = !reader.IsDBNull(3) && reader.GetBoolean(3),
+ CaptureTime = reader.GetDateTime(4)
});
}
@@ -266,6 +272,12 @@ public async Task> GetLatestTraceFlagsAsync(int serverId, Can
public class ServerConfigRow
{
+ /// #4966: when this row's snapshot was captured (UTC); every row of one snapshot shares it. The grid's Collected column sorts on it.
+ public DateTime CaptureTime { get; set; }
+
+ /// #4966: to the second, in the display zone (the grid's Collected column).
+ public string CaptureTimeLocal => ViewerTimeHelper.FormatForDisplay(CaptureTime, "yyyy-MM-dd HH:mm:ss");
+
public string ConfigurationName { get; set; } = "";
public long ValueConfigured { get; set; }
public long ValueInUse { get; set; }
@@ -278,6 +290,12 @@ public class ServerConfigRow
public class DatabaseConfigRow
{
+ /// #4966: when this row's snapshot was captured (UTC); every row of one snapshot shares it. The grid's Collected column sorts on it.
+ public DateTime CaptureTime { get; set; }
+
+ /// #4966: to the second, in the display zone (the grid's Collected column).
+ public string CaptureTimeLocal => ViewerTimeHelper.FormatForDisplay(CaptureTime, "yyyy-MM-dd HH:mm:ss");
+
public string DatabaseName { get; set; } = "";
public string StateDesc { get; set; } = "";
public int CompatibilityLevel { get; set; }
@@ -350,6 +368,12 @@ public class DatabaseConfigRow
///
public class QueryStoreHealthRow
{
+ /// #4966: when this row's snapshot was captured (UTC); every row of one snapshot shares it. The grid's Collected column sorts on it.
+ public DateTime CaptureTime { get; set; }
+
+ /// #4966: to the second, in the display zone (the grid's Collected column).
+ public string CaptureTimeLocal => ViewerTimeHelper.FormatForDisplay(CaptureTime, "yyyy-MM-dd HH:mm:ss");
+
public string DatabaseName { get; set; } = "";
public string ActualState { get; set; } = "";
public string DesiredState { get; set; } = "";
@@ -391,6 +415,12 @@ public class QueryStoreHealthRow
public class DatabaseScopedConfigRow
{
+ /// #4966: when this row's snapshot was captured (UTC); every row of one snapshot shares it. The grid's Collected column sorts on it.
+ public DateTime CaptureTime { get; set; }
+
+ /// #4966: to the second, in the display zone (the grid's Collected column).
+ public string CaptureTimeLocal => ViewerTimeHelper.FormatForDisplay(CaptureTime, "yyyy-MM-dd HH:mm:ss");
+
public string DatabaseName { get; set; } = "";
public string ConfigurationName { get; set; } = "";
public string Value { get; set; } = "";
@@ -399,6 +429,12 @@ public class DatabaseScopedConfigRow
public class TraceFlagRow
{
+ /// #4966: when this row's snapshot was captured (UTC); every row of one snapshot shares it. The grid's Collected column sorts on it.
+ public DateTime CaptureTime { get; set; }
+
+ /// #4966: to the second, in the display zone (the grid's Collected column).
+ public string CaptureTimeLocal => ViewerTimeHelper.FormatForDisplay(CaptureTime, "yyyy-MM-dd HH:mm:ss");
+
public int TraceFlag { get; set; }
public bool Status { get; set; }
public bool IsGlobal { get; set; }
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index e741c8e413..4506135a06 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -2907,6 +2907,9 @@
+
+
+
@@ -3000,6 +3003,9 @@
+
+
+
@@ -3018,6 +3024,9 @@
+
+
+
@@ -3050,6 +3059,9 @@
+
+
+
@@ -3096,6 +3108,9 @@
+
+
+
diff --git a/Lite.Tests/GridTimeColumnSortMemberTests.cs b/Lite.Tests/GridTimeColumnSortMemberTests.cs
index f532948640..5a3c2cbf28 100644
--- a/Lite.Tests/GridTimeColumnSortMemberTests.cs
+++ b/Lite.Tests/GridTimeColumnSortMemberTests.cs
@@ -78,6 +78,16 @@ public void Dispose()
{ "Controls/ServerTab.xaml", "BlockedProcessReportGrid", "EventTimeLocal", Name(typeof(BlockedProcessReportRow)), "EventTime" },
{ "Controls/ServerTab.xaml", "DeadlockGrid", "DeadlockTimeLocal", Name(typeof(DeadlockProcessDetail)), "DeadlockTime" },
{ "Controls/ServerTab.xaml", "AutomaticTuningGrid", "CollectionTimeLocal", Name(typeof(AutomaticTuningRow)), "CollectionTime" },
+ /* #4966: the Collected column of every grid that draws the newest snapshot: the snapshot's collection (or capture) time, sorted by the stored instant. */
+ { "Controls/ServerTab.xaml", "CpuSchedulerGrid", "CollectionTimeLocal", Name(typeof(CpuSchedulerGridRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "LatchStatsGrid", "CollectionTimeLocal", Name(typeof(LatchStatsSnapshotRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "SpinlockStatsGrid", "CollectionTimeLocal", Name(typeof(SpinlockStatsSnapshotRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "ServerConfigGrid", "CaptureTimeLocal", Name(typeof(ServerConfigRow)), "CaptureTime" },
+ { "Controls/ServerTab.xaml", "DatabaseConfigGrid", "CaptureTimeLocal", Name(typeof(DatabaseConfigRow)), "CaptureTime" },
+ { "Controls/ServerTab.xaml", "DatabaseScopedConfigGrid", "CaptureTimeLocal", Name(typeof(DatabaseScopedConfigRow)), "CaptureTime" },
+ { "Controls/ServerTab.xaml", "QueryStoreHealthGrid", "CaptureTimeLocal", Name(typeof(QueryStoreHealthRow)), "CaptureTime" },
+ { "Controls/ServerTab.xaml", "TraceFlagsGrid", "CaptureTimeLocal", Name(typeof(TraceFlagRow)), "CaptureTime" },
+ { "Controls/ServerTab.xaml", "RunningJobsGrid", "CollectionTimeLocal", Name(typeof(RunningJobRow)), "CollectionTime" },
{ "Controls/ServerTab.xaml", "SchedulerIssuesGrid", "EventTimeLocal", Name(typeof(SchedulerIssueRow)), "EventTime" },
{ "Controls/ServerTab.xaml", "SevereErrorsGrid", "EventTimeLocal", Name(typeof(SevereErrorRow)), "EventTime" },
{ "Controls/ServerTab.xaml", "MemoryConditionsGrid", "EventTimeLocal", Name(typeof(MemoryConditionsRow)), "EventTime" },
diff --git a/Lite.Tests/LiteCpuSessionsPlanCacheTrendBucketingTests.cs b/Lite.Tests/LiteCpuSessionsPlanCacheTrendBucketingTests.cs
index 28e3b3de8a..86a8055421 100644
--- a/Lite.Tests/LiteCpuSessionsPlanCacheTrendBucketingTests.cs
+++ b/Lite.Tests/LiteCpuSessionsPlanCacheTrendBucketingTests.cs
@@ -187,6 +187,26 @@ public async Task SessionStatsTrend_MergedBucket_AveragesGauges_AttributionColum
Assert.Equal(45, point.TopHostConnections);
}
+ /// #4966: the summary strip's Collected time is the NEWEST collection in the last bucket (the one its Top Application and
+ /// Top Host come from), not the bucket's grid time and not its oldest collection.
+ [Fact]
+ public async Task SessionStatsTrend_MergedBucket_CarriesTheNewestCollectionsOwnTime()
+ {
+ var end = new DateTime(2026, 3, 20, 0, 0, 0);
+ var start = end.AddDays(-8);
+ var t1 = start.AddHours(1);
+ var t2 = t1.AddSeconds(60);
+
+ await SeedSessionAsync(t1, total: 100, running: 5, topApp: "OlderApp", topAppConns: 40, topHost: "OlderHost", topHostConns: 30);
+ await SeedSessionAsync(t2, total: 120, running: 9, topApp: "NewerApp", topAppConns: 55, topHost: "NewerHost", topHostConns: 45);
+
+ var trend = await _dataService.GetSessionStatsAsync(ServerId, fromDate: start, toDate: end);
+
+ var point = Assert.Single(trend);
+ Assert.Equal(t2, point.LatestCollectionTime);
+ Assert.NotEqual(t2, point.CollectionTime);
+ }
+
[Fact]
public async Task PlanCacheTrend_SevenDayWindow_ReturnsAtMostBudgetRows()
{
diff --git a/Lite.Tests/SnapshotCollectedTimeTests.cs b/Lite.Tests/SnapshotCollectedTimeTests.cs
new file mode 100644
index 0000000000..f5f63aa1b7
--- /dev/null
+++ b/Lite.Tests/SnapshotCollectedTimeTests.cs
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4966: a surface that draws the NEWEST snapshot (of the whole store, or of the selected range) says when that snapshot
+/// was collected, so a reader can tell a stale snapshot from a fresh one. A grid does it with a Collected column,
+/// the way the Automatic Tuning grid does: the row's collection time in the display zone, to the second, sorted by the
+/// stored instant (the XAML side of that is pinned in , whose table lists
+/// every such column). A summary strip does it with Collected and the time at its end.
+///
+/// The tests here pin the text each row type words, that rows from different snapshots each show their own time, and
+/// where each summary strip's figure comes from and sits.
+///
+/* Installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics; joins the
+ collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class SnapshotCollectedTimeTests : IDisposable
+{
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ }
+
+ private static string Name(Type type) => type.AssemblyQualifiedName!;
+
+ /// (the row type, the text property its Collected column binds, the stored UTC instant it is worded from).
+ public static TheoryData SnapshotRows => new()
+ {
+ { Name(typeof(CpuSchedulerGridRow)), "CollectionTimeLocal", "CollectionTime" },
+ { Name(typeof(LatchStatsSnapshotRow)), "CollectionTimeLocal", "CollectionTime" },
+ { Name(typeof(SpinlockStatsSnapshotRow)), "CollectionTimeLocal", "CollectionTime" },
+ { Name(typeof(ServerConfigRow)), "CaptureTimeLocal", "CaptureTime" },
+ { Name(typeof(DatabaseConfigRow)), "CaptureTimeLocal", "CaptureTime" },
+ { Name(typeof(DatabaseScopedConfigRow)), "CaptureTimeLocal", "CaptureTime" },
+ { Name(typeof(QueryStoreHealthRow)), "CaptureTimeLocal", "CaptureTime" },
+ { Name(typeof(TraceFlagRow)), "CaptureTimeLocal", "CaptureTime" },
+ { Name(typeof(RunningJobRow)), "CollectionTimeLocal", "CollectionTime" },
+ };
+
+ private static object NewRow(string rowTypeName, string storedProperty, DateTime storedUtc)
+ {
+ var type = Type.GetType(rowTypeName, throwOnError: true)!;
+ var row = Activator.CreateInstance(type)!;
+ var stored = type.GetProperty(storedProperty, BindingFlags.Public | BindingFlags.Instance);
+ Assert.True(stored is { CanWrite: true }, $"{type.Name} has no settable {storedProperty}.");
+ stored!.SetValue(row, storedUtc);
+ return row;
+ }
+
+ private static string TextOf(object row, string textProperty)
+ {
+ var property = row.GetType().GetProperty(textProperty, BindingFlags.Public | BindingFlags.Instance);
+ Assert.True(property != null, $"{row.GetType().Name} has no public property {textProperty}, so its Collected column has no text to show.");
+ return (string)property!.GetValue(row)!;
+ }
+
+ ///
+ /// The text is the stored instant in the display zone, to the second: 16:27:06Z is 12:27:06 on a US Eastern clock in
+ /// October (UTC-4) and 16:27:06 in UTC mode. The seconds are non-zero on purpose, so a format that drops them fails.
+ ///
+ [Theory]
+ [MemberData(nameof(SnapshotRows))]
+ public void TheCollectedText_IsTheStoredInstant_InTheDisplayZone_ToTheSecond(string rowTypeName, string textProperty, string storedProperty)
+ {
+ var row = NewRow(rowTypeName, storedProperty, DisplayZoneFixtures.At(2026, 10, 3, 16, 27, 6));
+ ServerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-10-03 12:27:06", TextOf(row, textProperty));
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-10-03 16:27:06", TextOf(row, textProperty));
+ }
+
+ ///
+ /// The second 01:30 of the autumn change day (06:30:05Z on a US Eastern clock) keeps its -05:00 suffix, which a wall
+ /// clock round trip would lose: the text is worded from the stored instant, not from a server wall clock.
+ ///
+ [Theory]
+ [MemberData(nameof(SnapshotRows))]
+ public void TheCollectedText_OfTheRepeatedHour_KeepsItsOffset(string rowTypeName, string textProperty, string storedProperty)
+ {
+ var row = NewRow(rowTypeName, storedProperty, DisplayZoneFixtures.At(2026, 11, 1, 6, 30, 5));
+ ServerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300);
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ Assert.Equal("2026-11-01 01:30:05 -05:00", TextOf(row, textProperty));
+ }
+
+ ///
+ /// A surface whose rows come from different snapshots shows each row's own time: the text is worded per row, from the
+ /// row's own stored instant, and not from a time the surface holds once.
+ ///
+ [Theory]
+ [MemberData(nameof(SnapshotRows))]
+ public void RowsFromDifferentSnapshots_EachShowTheirOwnTime(string rowTypeName, string textProperty, string storedProperty)
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ var older = NewRow(rowTypeName, storedProperty, DisplayZoneFixtures.At(2026, 10, 3, 9, 15, 30));
+ var newer = NewRow(rowTypeName, storedProperty, DisplayZoneFixtures.At(2026, 10, 3, 16, 27, 6));
+
+ Assert.Equal("2026-10-03 09:15:30", TextOf(older, textProperty));
+ Assert.Equal("2026-10-03 16:27:06", TextOf(newer, textProperty));
+ }
+
+ ///
+ /// The three summary strips end with the snapshot's time: a "Collected" label and a named value, last in the strip,
+ /// and the code that fills the strip words that value from the snapshot's own collection time. The Session Stats strip
+ /// reads the NEWEST collection inside the last bucket (the one its Top Application and Top Host come from), not the
+ /// bucket's grid time.
+ ///
+ [Theory]
+ [InlineData("MemoryCollectedText", "SqlMemoryModelText", "Collected")]
+ [InlineData("PlanCacheCollectedText", "PlanCacheRecommendationText", "Collected:")]
+ [InlineData("SessionStatsCollectedText", "SessionStatsDatabasesText", "Collected:")]
+ public void ASummaryStrip_EndsWithItsSnapshotsCollectedTime(string valueName, string previousLastValueName, string label)
+ {
+ var xaml = ReadRepoFile("Lite/Controls/ServerTab.xaml");
+
+ var valueAt = xaml.IndexOf($"x:Name=\"{valueName}\"", StringComparison.Ordinal);
+ var previousAt = xaml.IndexOf($"x:Name=\"{previousLastValueName}\"", StringComparison.Ordinal);
+ Assert.True(valueAt > 0, $"{valueName} is not in ServerTab.xaml.");
+ Assert.True(previousAt > 0 && previousAt < valueAt, $"{valueName} does not come after {previousLastValueName}, so it is not at the end of its strip.");
+
+ /* Between the strip's old last value and the new one there is only the label of the new one. */
+ var between = xaml[previousAt..valueAt];
+ Assert.Contains($"Text=\"{label}\"", between);
+ Assert.DoesNotContain("x:Name=", between[(between.IndexOf('>') + 1)..]);
+
+ /* Nothing follows the new value inside its strip: the next element is a close tag. */
+ var afterValue = Regex.Match(xaml[valueAt..], @"/>\s*(?<[^>]*>)");
+ Assert.True(afterValue.Success && afterValue.Groups["next"].Value.StartsWith("", StringComparison.Ordinal),
+ $"{valueName} is followed by {afterValue.Groups["next"].Value}, not the end of its strip.");
+ }
+
+ [Theory]
+ [InlineData("Lite/Controls/ServerTab.Charts.cs", @"MemoryCollectedText\.Text\s*=\s*SnapshotCollectedText\(\s*stats\?\.CollectionTime\b")]
+ [InlineData("Lite/Controls/ServerTab.PlanCache.cs", @"PlanCacheCollectedText\.Text\s*=\s*SnapshotCollectedText\(\s*summary\.CollectionTime\b")]
+ [InlineData("Lite/Controls/ServerTab.SessionStats.cs", @"SessionStatsCollectedText\.Text\s*=\s*SnapshotCollectedText\(\s*data\?\.LatestCollectionTime\b")]
+ [InlineData("Lite/Controls/ServerTab.CpuScheduler.cs", @"CpuSchedulerGrid\.ItemsSource\s*=\s*CpuSchedulerGridRow\.Build\(")]
+ public void TheCodeThatFillsASurface_WordsTheTimeFromTheSnapshotsOwnCollectionTime(string file, string expected)
+ {
+ var code = ReadRepoFile(file);
+ Assert.True(Regex.IsMatch(code, expected), $"{file} does not match /{expected}/.");
+ }
+
+ /// The summary strips word the time with the tab's zone, to the second; a missing snapshot shows the strip's own empty marker, not a time.
+ [Fact]
+ public void TheStripText_IsTheSnapshotsTime_InTheGivenZone_ToTheSecond_AndTheEmptyMarkerWithoutOne()
+ {
+ Assert.Equal("2026-10-03 12:27:06", ServerTab.SnapshotCollectedText(DisplayZoneFixtures.At(2026, 10, 3, 16, 27, 6), DisplayZoneFixtures.Eastern, "--"));
+ Assert.Equal("2026-10-03 16:27:06", ServerTab.SnapshotCollectedText(DisplayZoneFixtures.At(2026, 10, 3, 16, 27, 6), TimeZoneInfo.Utc, "--"));
+ Assert.Equal("2026-11-01 01:30:05 -05:00", ServerTab.SnapshotCollectedText(DisplayZoneFixtures.At(2026, 11, 1, 6, 30, 5), DisplayZoneFixtures.Eastern, "--"));
+ Assert.Equal("--", ServerTab.SnapshotCollectedText(null, DisplayZoneFixtures.Eastern, "--"));
+ Assert.Equal("N/A", ServerTab.SnapshotCollectedText(null, TimeZoneInfo.Utc, "N/A"));
+ }
+
+ /// Every row of the CPU Scheduler grid carries the snapshot's collection time; a window with no snapshot has no rows, so no time.
+ [Fact]
+ public void TheCpuSchedulerGrid_CarriesTheSnapshotsTimeOnEveryRow_AndNothingWithoutASnapshot()
+ {
+ Assert.Empty(CpuSchedulerGridRow.Build(null));
+
+ var collected = DisplayZoneFixtures.At(2026, 10, 3, 16, 27, 6);
+ var rows = CpuSchedulerGridRow.Build(new CpuSchedulerSnapshot { CollectionTime = collected, SchedulerCount = 8 });
+ Assert.NotEmpty(rows);
+ Assert.All(rows, r => Assert.Equal(collected, r.CollectionTime));
+ Assert.Contains(rows, r => r.Metric == "Schedulers" && r.Value == "8");
+ }
+
+ /* Locate the repo from this file: no build-output copying. */
+ private static string ReadRepoFile(string relative, [CallerFilePath] string thisFile = "")
+ {
+ var dir = Path.GetDirectoryName(thisFile)!;
+ while (dir is not null && !File.Exists(Path.Combine(dir, relative)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.True(dir is not null, $"{relative} was not found above {thisFile}.");
+ return File.ReadAllText(Path.Combine(dir!, relative));
+ }
+}
diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs
index 52682da13e..40a5ceae8d 100644
--- a/Lite/Controls/ServerTab.Charts.cs
+++ b/Lite/Controls/ServerTab.Charts.cs
@@ -37,6 +37,9 @@ one panel never names a figure one way and shows its neighbor the other. */
PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition);
AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition);
+ /* #4966: the strip draws the newest snapshot of the whole store, so it says when that snapshot was collected. */
+ MemoryCollectedText.Text = SnapshotCollectedText(stats?.CollectionTime, GetPickerZone(), "--");
+
if (stats == null)
{
PhysicalMemoryText.Text = "--";
@@ -69,6 +72,14 @@ private static string FormatMb(double mb)
return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB";
}
+ ///
+ /// #4966: the figure at the end of a summary strip that draws the newest snapshot: when that snapshot was collected, in the
+ /// tab's display zone , to the second (the way the Automatic Tuning grid words its Collected column).
+ /// (the strip's own no-value marker) when there is no snapshot, so an empty strip shows no time.
+ ///
+ internal static string SnapshotCollectedText(DateTime? collectionTimeUtc, TimeZoneInfo zone, string emptyText) =>
+ collectionTimeUtc.HasValue ? DisplayZone.Format(collectionTimeUtc.Value, zone, "yyyy-MM-dd HH:mm:ss") : emptyText;
+
///
/// A Memory Overview page-file figure. On an Azure SQL Database the memory collector has no page-file source and
/// stores 0 in both page-file columns, which is an unknown and not a size, so the figure reads n/a there. Anywhere
diff --git a/Lite/Controls/ServerTab.CpuScheduler.cs b/Lite/Controls/ServerTab.CpuScheduler.cs
index a9e8f12b07..2ae8b27bf0 100644
--- a/Lite/Controls/ServerTab.CpuScheduler.cs
+++ b/Lite/Controls/ServerTab.CpuScheduler.cs
@@ -62,7 +62,7 @@ private async System.Threading.Tasks.Task RefreshCpuSchedulerAsync(int hoursBack
await System.Threading.Tasks.Task.WhenAll(trendTask, snapshotTask);
UpdateCpuSchedulerChart(trendTask.Result, hoursBack, fromDate, toDate);
- CpuSchedulerGrid.ItemsSource = CpuSchedulerMetrics.BuildMetrics(snapshotTask.Result);
+ CpuSchedulerGrid.ItemsSource = CpuSchedulerGridRow.Build(snapshotTask.Result);
var gap = CpuSchedulerGapNote(_server.DisplayName, _isAzureSqlDatabase);
CpuSchedulerNoDataMessage.Text = gap ?? "";
diff --git a/Lite/Controls/ServerTab.PlanCache.cs b/Lite/Controls/ServerTab.PlanCache.cs
index d046a236fe..2131b7f611 100644
--- a/Lite/Controls/ServerTab.PlanCache.cs
+++ b/Lite/Controls/ServerTab.PlanCache.cs
@@ -126,6 +126,7 @@ private void RenderPlanCacheSummary(PlanCacheSummary summary)
PlanCacheBloatLevelText.Text = "--";
PlanCacheBloatLevelText.Foreground = System.Windows.Media.Brushes.Gray;
PlanCacheRecommendationText.Text = "";
+ PlanCacheCollectedText.Text = "--";
return;
}
@@ -137,6 +138,8 @@ private void RenderPlanCacheSummary(PlanCacheSummary summary)
PlanCacheRecommendationText.Text = bloat.Recommendation;
PlanCacheTotalPlansText.Text = summary.TotalPlans.ToString("N0");
+ /* #4966: the summary totals are one snapshot (the newest in the range); say when it was collected. */
+ PlanCacheCollectedText.Text = SnapshotCollectedText(summary.CollectionTime, GetPickerZone(), "--");
/* oldest_plan_create_time comes from a DMV (sys.dm_exec_query_stats.creation_time) in the monitored
server's local clock, which the viewer — having no per-server offset for this DMV column —
diff --git a/Lite/Controls/ServerTab.SessionStats.cs b/Lite/Controls/ServerTab.SessionStats.cs
index 3306c1e10c..ff2b693e2e 100644
--- a/Lite/Controls/ServerTab.SessionStats.cs
+++ b/Lite/Controls/ServerTab.SessionStats.cs
@@ -95,6 +95,8 @@ private void UpdateSessionStatsSummary(SessionStatsPoint? data)
SessionStatsTopAppText.Text = topApp;
SessionStatsTopHostText.Text = topHost;
SessionStatsDatabasesText.Text = databases;
+ /* #4966: the newest collection in the last bucket (where Top Application and Top Host come from), not the bucket's grid time. */
+ SessionStatsCollectedText.Text = SnapshotCollectedText(data?.LatestCollectionTime, GetPickerZone(), "N/A");
}
/// Tears down the Session Stats hover helper (mirrors the other tabs' dispose) so its tooltip
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 9604793e2c..bdcdcbd999 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -1550,6 +1550,10 @@
+
+
+
+
@@ -2121,6 +2125,9 @@
+
+
+
@@ -2154,6 +2161,9 @@
+
+
+
@@ -2250,6 +2260,9 @@
+
+
+
@@ -2271,6 +2284,9 @@
+
+
+
@@ -2306,6 +2322,9 @@
+
+
+
@@ -2358,6 +2377,9 @@
+
+
+
@@ -2438,6 +2460,9 @@
+
+
+
@@ -2479,6 +2504,9 @@
+
+
+
@@ -2514,6 +2542,7 @@
+
@@ -2542,7 +2571,9 @@
report.plan_cache_bloat parity): a client-side CASE on single-use/total plans. -->
-
+
+
+
@@ -2569,7 +2600,9 @@
-
+
+
+
diff --git a/Lite/Services/LocalDataService.Config.cs b/Lite/Services/LocalDataService.Config.cs
index 417430351a..d3c2aa3c44 100644
--- a/Lite/Services/LocalDataService.Config.cs
+++ b/Lite/Services/LocalDataService.Config.cs
@@ -270,6 +270,10 @@ public class ServerConfigRow
{
/// The connect-time capture this row belongs to (#3541 A10); every row of one snapshot shares it.
public DateTime CaptureTime { get; set; }
+
+ /// #4966: when this row's snapshot was captured, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ). Config is captured on connect, so this is how old the shown value can be.
+ public string CaptureTimeLocal => ServerTimeHelper.FormatServerTime(CaptureTime);
public string ConfigurationName { get; set; } = "";
public long ValueConfigured { get; set; }
public long ValueInUse { get; set; }
@@ -284,6 +288,10 @@ public class DatabaseConfigRow
{
/// The connect-time capture this row belongs to (#3541 A10).
public DateTime CaptureTime { get; set; }
+
+ /// #4966: when this row's snapshot was captured, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ). Config is captured on connect, so this is how old the shown value can be.
+ public string CaptureTimeLocal => ServerTimeHelper.FormatServerTime(CaptureTime);
public string DatabaseName { get; set; } = "";
public string StateDesc { get; set; } = "";
public int CompatibilityLevel { get; set; }
@@ -358,6 +366,10 @@ public class QueryStoreHealthRow
{
/// The hourly capture this row belongs to (#3541 A10).
public DateTime CaptureTime { get; set; }
+
+ /// #4966: when this row's snapshot was captured, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ). Query Store health is captured hourly, so this is how old the shown value can be.
+ public string CaptureTimeLocal => ServerTimeHelper.FormatServerTime(CaptureTime);
public string DatabaseName { get; set; } = "";
public string ActualState { get; set; } = "";
public string DesiredState { get; set; } = "";
@@ -401,6 +413,10 @@ public class DatabaseScopedConfigRow
{
/// The connect-time capture this row belongs to (#3541 A10).
public DateTime CaptureTime { get; set; }
+
+ /// #4966: when this row's snapshot was captured, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ). Config is captured on connect, so this is how old the shown value can be.
+ public string CaptureTimeLocal => ServerTimeHelper.FormatServerTime(CaptureTime);
public string DatabaseName { get; set; } = "";
public string ConfigurationName { get; set; } = "";
public string Value { get; set; } = "";
@@ -411,6 +427,10 @@ public class TraceFlagRow
{
/// The connect-time capture this row belongs to (#3541 A10).
public DateTime CaptureTime { get; set; }
+
+ /// #4966: when this row's snapshot was captured, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ). Config is captured on connect, so this is how old the shown value can be.
+ public string CaptureTimeLocal => ServerTimeHelper.FormatServerTime(CaptureTime);
public int TraceFlag { get; set; }
public bool Status { get; set; }
public bool IsGlobal { get; set; }
diff --git a/Lite/Services/LocalDataService.CpuScheduler.cs b/Lite/Services/LocalDataService.CpuScheduler.cs
index 1ce2d16682..68152942e8 100644
--- a/Lite/Services/LocalDataService.CpuScheduler.cs
+++ b/Lite/Services/LocalDataService.CpuScheduler.cs
@@ -264,3 +264,36 @@ public class CpuSchedulerSnapshot : ICpuSchedulerSnapshot
public int OfflineCpuCount { get; set; }
public bool OfflineCpuWarning { get; set; }
}
+
+///
+/// One row of the CPU Scheduler latest-snapshot grid (#4966): the shared metric row plus when the snapshot it reads was
+/// collected. The shared leaves the time out on purpose (each app keeps its own
+/// time handling), so Lite adds it here: every row of one snapshot carries that snapshot's collection time, in the display
+/// zone to the second as , and the column sorts by .
+///
+public sealed class CpuSchedulerGridRow
+{
+ public string Metric { get; set; } = "";
+ public string Value { get; set; } = "";
+ public bool IsWarning { get; set; }
+ public DateTime CollectionTime { get; set; }
+
+ public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime);
+
+ /// The grid's rows for the snapshot; no rows (and so no time) for a window that holds no snapshot.
+ public static System.Collections.Generic.List Build(CpuSchedulerSnapshot? snapshot)
+ {
+ var rows = new System.Collections.Generic.List();
+ if (snapshot is null)
+ {
+ return rows;
+ }
+
+ foreach (var metric in PerformanceMonitor.Common.CpuSchedulerMetrics.BuildMetrics(snapshot))
+ {
+ rows.Add(new CpuSchedulerGridRow { Metric = metric.Metric, Value = metric.Value, IsWarning = metric.IsWarning, CollectionTime = snapshot.CollectionTime });
+ }
+
+ return rows;
+ }
+}
diff --git a/Lite/Services/LocalDataService.LatchSpinlock.cs b/Lite/Services/LocalDataService.LatchSpinlock.cs
index d3f81f3422..4866a97d69 100644
--- a/Lite/Services/LocalDataService.LatchSpinlock.cs
+++ b/Lite/Services/LocalDataService.LatchSpinlock.cs
@@ -362,6 +362,10 @@ public class LatchStatsSnapshotRow
/// FileIoStatsRow / resource-semaphore idiom).
public bool IsUnknowable => SampleIntervalSeconds == 0;
+ /// #4966: when this row's snapshot was collected, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ), so a reader can tell a stale latest-snapshot grid from a fresh one.
+ public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime);
+
/// The grid's Interval (sec) cell — see .
public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds);
}
@@ -399,6 +403,10 @@ public class SpinlockStatsSnapshotRow
/// True when the row's deltas are the unknowable marker — a stored interval of exactly 0.
public bool IsUnknowable => SampleIntervalSeconds == 0;
+ /// #4966: when this row's snapshot was collected, to the second, in the display zone (the grid's Collected column,
+ /// which sorts by ), so a reader can tell a stale latest-snapshot grid from a fresh one.
+ public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime);
+
/// The grid's Interval (sec) cell — see .
public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds);
}
diff --git a/Lite/Services/LocalDataService.RunningJobs.cs b/Lite/Services/LocalDataService.RunningJobs.cs
index bee3bdec15..60806c5365 100644
--- a/Lite/Services/LocalDataService.RunningJobs.cs
+++ b/Lite/Services/LocalDataService.RunningJobs.cs
@@ -205,6 +205,13 @@ public class RunningJobRow
/// honours.
public string StartTimeLocal => ServerTimeHelper.FormatServerClock(StartTime);
+ /// #4966: when the snapshot this row belongs to was collected, to the second, in the display zone. The Running Jobs
+ /// grid draws only the newest snapshot, so this is what tells a stale grid from a fresh one. collection_time is the
+ /// collector's own UTC stamp, so it is worded from the instant (,
+ /// like the Automatic Tuning grid's Collected column), unlike , which is the server's wall clock.
+ /// The column sorts by .
+ public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime);
+
public string CurrentDurationFormatted => FormatDuration(CurrentDurationSeconds);
public string AvgDurationFormatted => FormatDuration(AvgDurationSeconds);
public string P95DurationFormatted => FormatDuration(P95DurationSeconds);
diff --git a/Lite/Services/LocalDataService.SessionStats.cs b/Lite/Services/LocalDataService.SessionStats.cs
index 38a36ac3eb..9710a9703d 100644
--- a/Lite/Services/LocalDataService.SessionStats.cs
+++ b/Lite/Services/LocalDataService.SessionStats.cs
@@ -53,7 +53,7 @@ public async Task> GetSessionStatsAsync(int serverId, in
var rows = new List<(DateTime BucketStart, int Total, int Running, int Sleeping, int Background, int Dormant,
int Idle, int WaitingForMemory, int DatabasesWithConnections, string? TopAppName, int? TopAppConnections,
- string? TopHostName, int? TopHostConnections, DateTime FirstCollectionTime, long CollectionCount)>();
+ string? TopHostName, int? TopHostConnections, DateTime FirstCollectionTime, long CollectionCount, DateTime LatestCollectionTime)>();
var everyBucketSingleton = true;
using var reader = await command.ExecuteReaderAsync();
@@ -80,7 +80,8 @@ public async Task> GetSessionStatsAsync(int serverId, in
reader.IsDBNull(11) ? null : reader.GetString(11),
reader.IsDBNull(12) ? null : reader.GetInt32(12),
reader.GetDateTime(13),
- collectionCount));
+ collectionCount,
+ reader.GetDateTime(15)));
}
var items = new List(rows.Count);
@@ -100,7 +101,8 @@ public async Task> GetSessionStatsAsync(int serverId, in
TopApplicationName = row.TopAppName,
TopApplicationConnections = row.TopAppConnections,
TopHostName = row.TopHostName,
- TopHostConnections = row.TopHostConnections
+ TopHostConnections = row.TopHostConnections,
+ LatestCollectionTime = row.LatestCollectionTime
});
}
@@ -160,6 +162,7 @@ latest AS
(
SELECT
bucket_start,
+ collection_time AS latest_collection_time,
top_application_name,
top_application_connections,
top_host_name,
@@ -182,7 +185,8 @@ QUALIFY ROW_NUMBER() OVER (PARTITION BY bucket_start ORDER BY collection_time DE
latest.top_host_name,
latest.top_host_connections,
agg.first_collection_time,
- agg.collection_count
+ agg.collection_count,
+ latest.latest_collection_time
FROM agg
JOIN latest ON latest.bucket_start = agg.bucket_start
ORDER BY agg.bucket_start";
@@ -206,4 +210,9 @@ public class SessionStatsPoint : ISessionStatsPoint
public int? TopApplicationConnections { get; set; }
public string? TopHostName { get; set; }
public int? TopHostConnections { get; set; }
+
+ /// #4966: the time of the NEWEST physical collection inside this point's bucket, the one and
+ /// come from. is the bucket's grid time (or its first collection), so the
+ /// summary strip's Collected figure reads this one.
+ public DateTime LatestCollectionTime { get; set; }
}