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 @@