diff --git a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs index 7006f40158..a2ecd7e659 100644 --- a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs +++ b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs @@ -812,6 +812,11 @@ private static Regex RenderCall(string renderer, string column) => /// is not optional: this scan keys on the renderer's NAME, so an unlisted one is invisible and its /// sites would leave the census as the price of being fixed. /// + /// FormatForDisplay is the text form of ForDisplay (#4766): the same naive-UTC + /// contract, plus the UTC offset in the repeated autumn hour. The grid and caption sites moved from + /// ForDisplay(x).ToString(format) to it, and \bForDisplay does not match inside its name, + /// so it is registered here or those sites would leave the census. + /// /// This list's completeness is the guard's own soft spot, and two checks cover it. /// derives the ALIASES — a static /// formatter over a DateTime reaching a renderer under a different name — and pins them at set @@ -822,6 +827,7 @@ private static Regex RenderCall(string renderer, string column) => private static readonly (string Renderer, ClockFrame Expects)[] Renderers = [ ("ForDisplay", ClockFrame.Utc), + ("FormatForDisplay", ClockFrame.Utc), ("FormatServerTime", ClockFrame.Utc), ("FormatStoredUtc", ClockFrame.Utc), ("FormatServerClock", ClockFrame.ServerLocal), @@ -832,7 +838,7 @@ private static readonly (string Renderer, ClockFrame Expects)[] Renderers = /// instead of being one. A wrapper hides the renderer's NAME from the /// render scan, which keys on it - so a column rendered through one is invisible to a census that /// calls itself closed. That is not hypothetical: the four plan_correction stamps reached - /// ForDisplay through ViewerDataService.PlanCorrection's Local(), and no scan + /// ForDisplay (now FormatForDisplay) through ViewerDataService.PlanCorrection's Local(), and no scan /// here could see which renderer they were getting. /// /// Declared with the renderer each one reaches and pinned at SET EQUALITY against the @@ -848,10 +854,13 @@ private static readonly (string Renderer, ClockFrame Expects)[] Renderers = /// private static readonly (string File, string Method, string Renderer)[] RenderWrappers = [ - ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs", "Local", "ForDisplay"), - ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "Local", "ForDisplay"), - ("Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs", "CollectionLocal", "ForDisplay"), - ("Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs", "Timestamp", "ForDisplay"), + ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs", "Local", "FormatForDisplay"), + ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "Local", "FormatForDisplay"), + /* #4766: the Default Trace row's bare renderer. A time read from the stored server wall clock cannot say which + pass of the repeated autumn hour it was in, so it goes through ForDisplay and never takes an offset. */ + ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "StoredWallClock", "ForDisplay"), + ("Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs", "CollectionLocal", "FormatForDisplay"), + ("Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs", "Timestamp", "FormatForDisplay"), ("Lite/Services/LocalDataService.ConfigChanges.cs", "Local", "FormatServerTime"), ("Lite/Services/LocalDataService.SystemEvents.cs", "Local", "FormatServerTime"), ]; @@ -1585,6 +1594,20 @@ private static readonly (string Name, string Why)[] NonRenderingCallsOnACensusCo + "and does not word one"), ]; + /// + /// Registered renderers that no code hands a census timestamp column. Declared with why, so the typo check + /// below (a registered renderer that reaches no census column is a mis-typed name) keeps its force for every + /// other renderer, and pinned both ways: a declared name that reaches a census column again is stale. + /// + private static readonly (string Renderer, string Why)[] RenderersWithNoCensusCall = + [ + ("ForDisplay", "#4766: every grid and caption site that worded a census column moved to FormatForDisplay, " + + "which adds the repeated-hour UTC offset. What still calls ForDisplay hands it a DateTime that stays a " + + "DateTime (a FinOps or Query Store row property a XAML StringFormat words, the time-range slicer's plain " + + "labels, the heatmap's ticks, a discontinuity marker): none of those is a census column. It stays " + + "registered, so a new ForDisplay(row.SomeColumn) is judged for its clock frame like any renderer's"), + ]; + /// /// The residual, with no shape assumption. Whatever its signature, no member may be handed a /// census timestamp column unless it is a registered renderer, a declared alias, or declared as not @@ -1654,12 +1677,27 @@ CollectorColumn declares. Their staleness is caught by is left unguarded by relaxing it here. The other two directions ARE asserted: */ /* a registered renderer that reaches no census column is a typo in the map, and it would take - its sites out of the judged population silently; */ + its sites out of the judged population silently - unless it is declared in RenderersWithNoCensusCall + with why; */ Assert.Equal( Array.Empty(), - Renderers.Select(r => r.Renderer).Where(name => !applied.ContainsKey(name)) + Renderers.Select(r => r.Renderer) + .Where(name => !applied.ContainsKey(name) && RenderersWithNoCensusCall.All(d => d.Renderer != name)) .OrderBy(x => x, StringComparer.Ordinal).ToArray()); + /* a declared one that reaches a census column again is an exemption asserting nothing, and a declared + name that is not a registered renderer is a mis-typed exemption; each carries its reasoning; */ + Assert.Equal( + Array.Empty(), + RenderersWithNoCensusCall.Select(d => d.Renderer).Where(name => applied.ContainsKey(name)) + .OrderBy(x => x, StringComparer.Ordinal).ToArray()); + Assert.All( + RenderersWithNoCensusCall, + d => Assert.Contains(Renderers, r => r.Renderer == d.Renderer)); + Assert.All( + RenderersWithNoCensusCall, + d => Assert.False(string.IsNullOrWhiteSpace(d.Why), $"{d.Renderer}: an exemption with no reasoning")); + /* and a declared non-renderer that no longer appears is an exemption asserting nothing, which is the direction an exemption list rots in. */ Assert.Equal( @@ -1978,6 +2016,9 @@ colliding collector exists asserts nothing until then. */ "cpuTask.Result.Select(d => ViewerTimeHelper.ForDisplay(d.SampleTime).ToOADate())"); Assert.DoesNotMatch(RenderCall("ForDisplay", "sample_time"), "ViewerTimeHelper.ForDisplay(s.SampleTimeUtc).ToOADate()"); Assert.DoesNotMatch(RenderCall("FormatServerClock", "sample_time"), "ViewerTimeHelper.ForDisplay(d.SampleTime)"); + /* The text renderer is its own name: it is found where it is called, and ForDisplay is not found inside it. */ + Assert.Matches(RenderCall("FormatForDisplay", "sample_time"), "ViewerTimeHelper.FormatForDisplay(d.SampleTime, \"s\")"); + Assert.DoesNotMatch(RenderCall("ForDisplay", "sample_time"), "ViewerTimeHelper.FormatForDisplay(d.SampleTime, \"s\")"); /* ProjectionAlias takes an alias that is the LAST column before FROM, with no comma after it. */ Assert.Equal( @@ -2011,15 +2052,16 @@ the strip is load-bearing rather than tidy. */ Assert.Matches(WrapperSignature, " /// public static string Local(DateTime? utc)"); Assert.DoesNotMatch(WrapperSignature, WithoutComments(" /// public static string Local(DateTime? utc)")); - /* And the two renderer families are distinguished, not merged: four names, two expectations. Three + /* And the two renderer families are distinguished, not merged: five names, two expectations. Four take naive UTC and exactly ONE takes the server's own clock — asserted as a count rather than left as a comment, because the defect class IS a value reaching the renderer for the other frame, and a second server-local renderer appearing unnoticed would split that side. */ - Assert.Equal(4, Renderers.Length); + Assert.Equal(5, Renderers.Length); Assert.Equal(2, Renderers.Select(r => r.Expects).Distinct().Count()); Assert.Equal(ClockFrame.ServerLocal, Renderers.Single(r => r.Renderer == "FormatServerClock").Expects); Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatServerTime").Expects); Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatStoredUtc").Expects); + Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatForDisplay").Expects); Assert.Single(Renderers, r => r.Expects == ClockFrame.ServerLocal); } diff --git a/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs b/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs index e556ebfc25..668fbd0de1 100644 --- a/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs +++ b/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs @@ -319,7 +319,8 @@ public void EveryMeasuredColumn_BindsANumber_WithTheAbsenceOnTheBinding() "RunsSlowestPct", "SlowestSharePct", "SlowestItemMsP50", "DominanceRatio", "PlansPerQueryP95", "PlansPerQueryMax", "NewPlansPerDay", "OneShotFraction", "DistinctPlans", "MaxPlansPerQuery", "StaleQueryThresholdDays", "PctOfCap", - "OptionsCaptured", + /* OptionsCaptured is not here since #4766: the column binds OptionsCapturedText (a XAML StringFormat cannot + carry the offset of the repeated autumn hour) and sorts by the DateTime; ViewerGridTimeTextZoneTests pins it. */ }) { var binding = Regex.Match(grid, @"\{Binding " + column + @"(?[^}]*)\}"); diff --git a/Darling/Darling.Tests/StoredServerClockRowTextTests.cs b/Darling/Darling.Tests/StoredServerClockRowTextTests.cs new file mode 100644 index 0000000000..4d0bef1b4b --- /dev/null +++ b/Darling/Darling.Tests/StoredServerClockRowTextTests.cs @@ -0,0 +1,258 @@ +/* + * 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.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4766: a time read from a STORED server wall-clock column prints as the plain wall time in the repeated autumn +/// hour, never with a UTC offset. +/// +/// Text that shows one instant appends the instant's offset there (ViewerTimeHelper.FormatForDisplay), and +/// that is only true for a real UTC instant. A job step, a Default Trace event or a next scheduled run comes in as the +/// server's own wall clock and goes to UTC through , which maps BOTH passes of a +/// repeated local time to the first ("A stored server-local time cannot say which one it was"). Formatted with the +/// offset, the second pass would print the first pass's: a step that ran at 01:30 the second time (06:30Z, -05:00) on a +/// US Eastern server printed "-04:00". US Eastern falls back on 2026-11-01 at 06:00Z. +/// +/* Serialized with the classes that flip the process-wide ViewerTimeHelper statics: every row renders through them. */ +[Collection("viewer-time-statics")] +public sealed class StoredServerClockRowTextTests +{ + private const string EasternWindowsId = "Eastern Standard Time"; + private const string Format = "yyyy-MM-dd HH:mm:ss"; + + private static DateTime Naive(int year, int month, int day, int hour, int minute = 0) => + new(year, month, day, hour, minute, 0, DateTimeKind.Unspecified); + + private static ServerClock Eastern => ServerClock.Resolve(EasternWindowsId, -300); + + /// Server display mode on and the invariant culture for its lifetime, for a test + /// that reads rows (a row renders its time text when it is built) and then asserts on them. Restores all three. + private sealed class ServerModeScope : IDisposable + { + private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode; + private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock; + private readonly System.Globalization.CultureInfo _savedCulture = System.Globalization.CultureInfo.CurrentCulture; + + public ServerModeScope(ServerClock clock) + { + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + ViewerTimeHelper.ActiveServerClock = clock; + System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture; + } + + public void Dispose() + { + ViewerTimeHelper.CurrentDisplayMode = _savedMode; + ViewerTimeHelper.ActiveServerClock = _savedClock; + System.Globalization.CultureInfo.CurrentCulture = _savedCulture; + } + } + + /// A system_health row: its event time is the XE @timestamp, a real UTC instant. + private static string TrueUtcRowText(DateTime naiveUtc) => + new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = naiveUtc }).EventTimeLocal; + + // ── Job History ── + + private static DataTable NewJobTable() + { + var table = new DataTable(); + table.Columns.Add("server_id", typeof(int)); + table.Columns.Add("server_name", typeof(string)); + table.Columns.Add("instance_id", typeof(long)); + table.Columns.Add("job_id", typeof(string)); + table.Columns.Add("job_name", typeof(string)); + table.Columns.Add("job_enabled", typeof(bool)); + table.Columns.Add("category_name", typeof(string)); + table.Columns.Add("step_id", typeof(int)); + table.Columns.Add("step_name", typeof(string)); + table.Columns.Add("run_status", typeof(int)); + table.Columns.Add("run_status_desc", typeof(string)); + table.Columns.Add("run_datetime_local", typeof(DateTime)); + table.Columns.Add("run_duration_seconds", typeof(long)); + table.Columns.Add("retries_attempted", typeof(int)); + table.Columns.Add("message", typeof(string)); + table.Columns.Add("last_success_run_local", typeof(DateTime)); + table.Columns.Add("is_long_running", typeof(bool)); + return table; + } + + private static ViewerJobHistoryRow ReadJobRow(DateTime runLocal, DateTime? lastSuccessLocal) + { + var table = NewJobTable(); + table.Rows.Add(1, "srv", 10L, "job", "Job", true, "cat", 0, "step", 1, "Succeeded", runLocal, 5L, 0, "ok", + lastSuccessLocal.HasValue ? lastSuccessLocal.Value : DBNull.Value, false); + + using var reader = table.CreateDataReader(); + Assert.True(reader.Read()); + return ViewerDataService.ReadJobHistoryRow(reader, new Dictionary { [1] = Eastern }); + } + + [Fact] + public void AJobStepReadFromTheStoredWallClock_PrintsNoOffset_InBothPassesOfTheRepeatedHour_WhileARealInstantStillDoes() + { + using var scope = new ServerModeScope(Eastern); + + /* The Agent history stores 01:30 for a step that ran in either pass; ToUtc reads both as 05:30Z. */ + var firstPass = ReadJobRow(Naive(2026, 11, 1, 1, 30), Naive(2026, 11, 1, 1, 45)); + var secondPass = ReadJobRow(Naive(2026, 11, 1, 1, 30), Naive(2026, 11, 1, 1, 45)); + Assert.Equal(Naive(2026, 11, 1, 5, 30), firstPass.RunDateTimeUtc); + Assert.Equal(Naive(2026, 11, 1, 5, 30), secondPass.RunDateTimeUtc); + + Assert.Equal("2026-11-01 01:30:00", firstPass.RunTimeLocal); + Assert.Equal("2026-11-01 01:30:00", secondPass.RunTimeLocal); + Assert.Equal("2026-11-01 01:45:00", firstPass.LastSuccessfulRunLocal); + Assert.Equal("2026-11-01 01:45:00", secondPass.LastSuccessfulRunLocal); + + /* A real instant in the same test keeps the offset: the two passes of 01:30 are 05:30Z and 06:30Z. */ + Assert.Equal("2026-11-01 01:30:00 -04:00", TrueUtcRowText(Naive(2026, 11, 1, 5, 30))); + Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30))); + } + + [Fact] + public void AJobStepOutsideTheRepeatedHour_ReadsAsTheDisplayTextItAlwaysDid() + { + using var scope = new ServerModeScope(Eastern); + + var beforeTheHour = ReadJobRow(Naive(2026, 11, 1, 0, 59), null); + var afterTheHour = ReadJobRow(Naive(2026, 11, 1, 2, 0), null); + var summer = ReadJobRow(Naive(2026, 7, 6, 12, 0), Naive(2026, 7, 6, 11, 0)); + + Assert.Equal("2026-11-01 00:59:00", beforeTheHour.RunTimeLocal); + Assert.Equal("2026-11-01 02:00:00", afterTheHour.RunTimeLocal); + Assert.Equal("2026-07-06 12:00:00", summer.RunTimeLocal); + Assert.Equal("2026-07-06 11:00:00", summer.LastSuccessfulRunLocal); + Assert.Equal("Never", beforeTheHour.LastSuccessfulRunLocal); + Assert.Equal("", new ViewerJobHistoryRow().RunTimeLocal); + } + + // ── Agent status ── + + private static async Task ReadAgentRowAsync(DateTime? nextRunLocal) + { + var table = new DataTable(); + table.Columns.Add("server_id", typeof(int)); + table.Columns.Add("server_name", typeof(string)); + table.Columns.Add("agent_running", typeof(bool)); + table.Columns.Add("agent_status_desc", typeof(string)); + table.Columns.Add("agent_startup_desc", typeof(string)); + table.Columns.Add("next_scheduled_run_local", typeof(DateTime)); + table.Rows.Add(1, "srv", true, "Running", "Auto", nextRunLocal.HasValue ? nextRunLocal.Value : DBNull.Value); + + using var reader = table.CreateDataReader(); + var rows = await ViewerDataService.ReadAgentStatusRowsAsync( + reader, new Dictionary { [1] = Eastern }, TestContext.Current.CancellationToken); + return Assert.Single(rows); + } + + [Fact] + public async Task ANextScheduledRunReadFromTheStoredWallClock_PrintsNoOffset_InTheRepeatedHour_WhileARealInstantStillDoes() + { + using var scope = new ServerModeScope(Eastern); + + var repeated = await ReadAgentRowAsync(Naive(2026, 11, 1, 1, 30)); + var ordinary = await ReadAgentRowAsync(Naive(2026, 11, 2, 8, 0)); + var none = await ReadAgentRowAsync(null); + + Assert.Equal("2026-11-01 01:30:00", repeated.NextScheduledRunLocal); + Assert.Equal("2026-11-02 08:00:00", ordinary.NextScheduledRunLocal); + Assert.Equal("None scheduled", none.NextScheduledRunLocal); + + Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30))); + } + + // ── Default Trace ── + + private static DataTable NewDefaultTraceTable() + { + var table = new DataTable(); + table.Columns.Add("event_time_local", typeof(DateTime)); + table.Columns.Add("event_name", typeof(string)); + table.Columns.Add("database_name", typeof(string)); + table.Columns.Add("object_name", typeof(string)); + table.Columns.Add("login_name", typeof(string)); + table.Columns.Add("host_name", typeof(string)); + table.Columns.Add("application_name", typeof(string)); + table.Columns.Add("spid", typeof(int)); + table.Columns.Add("duration_us", typeof(long)); + table.Columns.Add("integer_data", typeof(long)); + table.Columns.Add("severity", typeof(int)); + table.Columns.Add("error_number", typeof(int)); + table.Columns.Add("text_data", typeof(string)); + return table; + } + + [Fact] + public async Task ADefaultTraceEventReadFromTheStoredWallClock_PrintsNoOffset_InBothPassesOfTheRepeatedHour_WhileARealInstantStillDoes() + { + using var scope = new ServerModeScope(Eastern); + + /* Two auto-grows, one in each pass of the hour: the trace file stores 01:30 for both (the label rides in + database_name). */ + var table = NewDefaultTraceTable(); + foreach (var label in new[] { "first-pass", "second-pass" }) + { + table.Rows.Add(Naive(2026, 11, 1, 1, 30), "Data File Auto Grow", label, DBNull.Value, DBNull.Value, DBNull.Value, + DBNull.Value, 55, 1_500_000L, 256L, DBNull.Value, DBNull.Value, DBNull.Value); + } + + table.Rows.Add(Naive(2026, 11, 1, 2, 30), "Data File Auto Grow", "after", DBNull.Value, DBNull.Value, DBNull.Value, + DBNull.Value, 55, 1_500_000L, 256L, DBNull.Value, DBNull.Value, DBNull.Value); + + List rows; + using (var reader = table.CreateDataReader()) + { + rows = await ViewerDataService.ReadDefaultTraceEventsAsync( + reader, Eastern, Naive(2026, 11, 1, 0, 0), Naive(2026, 11, 1, 12, 0), TestContext.Current.CancellationToken); + } + + var byLabel = rows.ToDictionary(r => r.DatabaseName!); + Assert.Equal(3, byLabel.Count); + Assert.Equal(Naive(2026, 11, 1, 5, 30), byLabel["first-pass"].EventTimeUtc); + Assert.Equal(Naive(2026, 11, 1, 5, 30), byLabel["second-pass"].EventTimeUtc); + + Assert.Equal("2026-11-01 01:30:00", byLabel["first-pass"].EventTimeLocal); + Assert.Equal("2026-11-01 01:30:00", byLabel["second-pass"].EventTimeLocal); + Assert.Equal("2026-11-01 02:30:00", byLabel["after"].EventTimeLocal); + + /* A real instant in the same test keeps the offset. */ + Assert.Equal("2026-11-01 01:30:00 -04:00", TrueUtcRowText(Naive(2026, 11, 1, 5, 30))); + Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30))); + Assert.Equal("", new DefaultTraceEventRow( + null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, null, null, null, 20, 823, "x").EventTimeLocal); + } + + [Fact] + public void TheStoredWallClockRenderer_IsTheForDisplayText_AndTheRealInstantRendererDiffersFromItOnlyInTheRepeatedHour() + { + using var scope = new ServerModeScope(Eastern); + + foreach (var instant in new[] { Naive(2026, 11, 1, 4, 59), Naive(2026, 11, 1, 7, 0), Naive(2026, 7, 6, 16, 0) }) + { + Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString(Format), SystemEventRowFormat.StoredWallClock(instant)); + Assert.Equal(SystemEventRowFormat.Local(instant), SystemEventRowFormat.StoredWallClock(instant)); + } + + var secondPass = Naive(2026, 11, 1, 6, 30); + Assert.Equal("2026-11-01 01:30:00", SystemEventRowFormat.StoredWallClock(secondPass)); + Assert.Equal("2026-11-01 01:30:00 -05:00", SystemEventRowFormat.Local(secondPass)); + Assert.Equal("", SystemEventRowFormat.StoredWallClock(null)); + } +} diff --git a/Darling/Darling.Tests/ViewerAlertRowClockTests.cs b/Darling/Darling.Tests/ViewerAlertRowClockTests.cs new file mode 100644 index 0000000000..4d0fb7502e --- /dev/null +++ b/Darling/Darling.Tests/ViewerAlertRowClockTests.cs @@ -0,0 +1,186 @@ +/* + * 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.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4766: the viewer's Alerts History time and Manage Servers' "Last Collected" show one UTC instant, so in the repeated +/// autumn hour they carry the instant's UTC offset, as Lite's alert row does. Both used to build +/// ConvertToDisplay(...).ToString(format) themselves, so the alert at 05:30Z and the one at 06:30Z on 2026-11-01 +/// both read "01:30" on a US Eastern server. They now word the text through +/// , the twin of Lite's +/// ServerTimeHelper.FormatInstant, which the current-zone overload calls too, so there is one rule. +/// +/// US Eastern is the server: the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00) +/// and 06:30Z the second (-05:00). +/// +/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection + serializes it with every other class that does. */ +[Collection("viewer-time-statics")] +public sealed class ViewerAlertRowClockTests : IDisposable +{ + private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300); + + private static readonly ServerClock India = ServerClock.Resolve("India Standard Time", 330); + + private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode; + private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock; + private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture; + + public ViewerAlertRowClockTests() + { + /* The text runs on the current culture, as every grid's does; the invariant one fixes its separators. */ + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + } + + public void Dispose() + { + ViewerTimeHelper.CurrentDisplayMode = _savedMode; + ViewerTimeHelper.ActiveServerClock = _savedClock; + CultureInfo.CurrentCulture = _savedCulture; + } + + private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified); + + private static ViewerAlertRow Alert(DateTime alertTimeUtc, ServerClock? clock) => new() + { + AlertTime = alertTimeUtc, + ServerId = 1, + MetricName = "High CPU", + CurrentValue = 95, + ThresholdValue = 90, + AlertSent = false, + NotificationType = "tray", + Muted = false, + Clock = clock, + }; + + private static ManagedServerListItem Server(DateTime collectedUtc, ServerClock? clock) => + new(new MonitoredServerRow { ServerId = 1, Host = "SQL1", Name = "SQL1" }, isFavorite: false) + { + LastCollectedUtc = collectedUtc, + Clock = clock, + }; + + /// + /// The alert at 05:30Z reads 01:30 -04:00 and the one at 06:30Z reads 01:30 -05:00 in Server mode, the hour after + /// is the bare 02:30, and UTC mode is the stored instant with no offset. + /// + [Fact] + public void AlertRow_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode() + { + ViewerTimeHelper.ActiveServerClock = India; + var first = Alert(Utc(2026, 11, 1, 5, 30), Eastern); + var second = Alert(Utc(2026, 11, 1, 6, 30), Eastern); + var after = Alert(Utc(2026, 11, 1, 7, 30), Eastern); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal("2026-11-01 01:30:00 -04:00", first.TimeLocal); + Assert.Equal("2026-11-01 01:30:00 -05:00", second.TimeLocal); + Assert.Equal("2026-11-01 02:30:00", after.TimeLocal); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal("2026-11-01 05:30:00", first.TimeLocal); + Assert.Equal("2026-11-01 06:30:00", second.TimeLocal); + } + + /// A row built without a clock reads on the active server's, the fallback it had before. + [Fact] + public void AlertRow_WithNoClockStamped_TakesTheActiveServersClock() + { + ViewerTimeHelper.ActiveServerClock = Eastern; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + Assert.Equal("2026-11-01 01:30:00 -05:00", Alert(Utc(2026, 11, 1, 6, 30), clock: null).TimeLocal); + Assert.Equal("2026-07-01 08:00:00", Alert(Utc(2026, 7, 1, 12, 0), clock: null).TimeLocal); + } + + /// Manage Servers' Last Collected takes the same pair, in its own "yyyy-MM-dd HH:mm" shape. + [Fact] + public void ManageServersLastCollected_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode() + { + ViewerTimeHelper.ActiveServerClock = India; + var first = Server(Utc(2026, 11, 1, 5, 30), Eastern); + var second = Server(Utc(2026, 11, 1, 6, 30), Eastern); + var after = Server(Utc(2026, 11, 1, 7, 30), Eastern); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal("2026-11-01 01:30 -04:00", first.LastCollectedDisplay); + Assert.Equal("2026-11-01 01:30 -05:00", second.LastCollectedDisplay); + Assert.Equal("2026-11-01 02:30", after.LastCollectedDisplay); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal("2026-11-01 05:30", first.LastCollectedDisplay); + Assert.Equal("2026-11-01 06:30", second.LastCollectedDisplay); + } + + /// + /// The zone overload is the one rule: an explicit zone words the instant the same way the current-zone overload does + /// for that zone, UTC never takes an offset, and a wall time that happens once stays bare. + /// + [Fact] + public void FormatForDisplay_WithAZone_IsTheRuleTheCurrentZoneOverloadUses() + { + var eastern = ViewerTimeHelper.DisplayZoneFor(TimeDisplayMode.ServerTime, Eastern); + + Assert.Equal("2026-11-01 01:30 -04:00", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 5, 30), eastern, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-11-01 01:30 -05:00", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 6, 30), eastern, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-11-01 05:30", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 5, 30), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-07-01 10:30", ViewerTimeHelper.FormatForDisplay(Utc(2026, 7, 1, 14, 30), eastern, "yyyy-MM-dd HH:mm")); + + ViewerTimeHelper.ActiveServerClock = Eastern; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + foreach (var instant in new[] { Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Utc(2026, 7, 1, 14, 30) }) + { + Assert.Equal( + ViewerTimeHelper.FormatForDisplay(instant, ViewerTimeHelper.CurrentDisplayZone(), "yyyy-MM-dd HH:mm:ss"), + ViewerTimeHelper.FormatForDisplay(instant, "yyyy-MM-dd HH:mm:ss")); + } + } + + /// + /// The current-zone overload hands its work to the zone overload rather than wording the suffix itself, and the two + /// list rows call the zone overload with the zone they compute, so the rule stays in one place. + /// + [Fact] + public void TheOneRule_LivesInTheZoneOverload_AndTheRowsCallIt() + { + var helper = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerTimeHelper.cs", ThisFile())); + var current = ViewerTypedRangeTests.StripComments(FormatForDisplayMembers(helper, "string format) =>")); + Assert.Contains("FormatForDisplay(naiveUtc, CurrentDisplayZone(), format)", current); + Assert.Single(Regex.Matches(helper, @"AmbiguousOffsetSuffix\(")); + + var alert = ViewerTypedRangeTests.StripComments( + ViewerTypedRangeTests.MemberText(ViewerTypedRangeTests.ViewerSource("ViewerDataService.AlertHistory.cs", ThisFile()), "TimeLocal")); + var manage = ViewerTypedRangeTests.StripComments( + ViewerTypedRangeTests.MemberText(ViewerTypedRangeTests.ViewerSource("ManageServersWindow.xaml.cs", ThisFile()), "LastCollectedDisplay")); + Assert.Matches(@"ViewerTimeHelper\.FormatForDisplay\(\s*AlertTime,\s*ViewerTimeHelper\.DisplayZoneFor\(", alert); + Assert.Matches(@"ViewerTimeHelper\.FormatForDisplay\(\s*utc,\s*ViewerTimeHelper\.DisplayZoneFor\(", manage); + Assert.DoesNotContain(".ToString(", alert); + Assert.DoesNotContain(".ToString(", manage); + } + + /// The text of the two-argument (DateTime, string) overload, from its declaration to its semicolon. + private static string FormatForDisplayMembers(string helper, string signatureTail) + { + var at = helper.IndexOf("public static string FormatForDisplay(DateTime naiveUtc, " + signatureTail, StringComparison.Ordinal); + Assert.True(at >= 0, "the (DateTime, string) overload of FormatForDisplay moved"); + return helper.Substring(at, helper.IndexOf(';', at) - at + 1); + } + + private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile; +} diff --git a/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs b/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs index d75d4f0015..43b4bc6d43 100644 --- a/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs +++ b/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs @@ -89,7 +89,9 @@ public void NoDisplayTimeProjectionFeedsAChartX() foreach (var (file, code) in ViewerSources()) { - foreach (var call in Regex.Matches(code, @"\bForDisplay\s*\(").Cast()) + /* FormatForDisplay is the text form of ForDisplay (#4766): a statement that words a display time and also + plots a number or sets an axis limit is the same mistake, and the calls the sites moved to still count. */ + foreach (var call in Regex.Matches(code, @"\b(?:Format)?ForDisplay\s*\(").Cast()) { forDisplayCalls++; if (FeedsAChart.IsMatch(StatementAround(code, call.Index))) @@ -209,9 +211,12 @@ public void TheCsvExportWritesTheTimesTheChartLabelsShow() /* The export writes every point through the pure ChartCsvDataLine (ViewerChartContextMenuTests pins what it writes for a repeated hour), handing it the chart's X as plotted and the display zone; the seam applies - the zone to the instant. Neither the raw X nor a display-frame projection reaches FormatChartCsvLine. */ + the zone to the instant, and the UTC offset column is that same instant's offset. Neither the raw X nor a + display-frame projection reaches FormatChartCsvLine. */ Assert.Contains("ChartCsvDataLine(point.X, seriesName, point.Y, sep, zone)", code, StringComparison.Ordinal); - Assert.Contains("DisplayZone.ToDisplay(DateTime.FromOADate(plottedX), zone)", code, StringComparison.Ordinal); + Assert.Contains("var instant = DateTime.FromOADate(plottedX);", code, StringComparison.Ordinal); + Assert.Contains("DisplayZone.ToDisplay(instant, zone)", code, StringComparison.Ordinal); + Assert.Contains("DisplayZone.UtcOffsetText(instant, zone)", code, StringComparison.Ordinal); Assert.DoesNotContain("FormatChartCsvLine(DateTime.FromOADate(", code, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/ViewerContextMenuTests.cs b/Darling/Darling.Tests/ViewerContextMenuTests.cs index 516e7d07e7..a9a198d4a4 100644 --- a/Darling/Darling.Tests/ViewerContextMenuTests.cs +++ b/Darling/Darling.Tests/ViewerContextMenuTests.cs @@ -67,10 +67,10 @@ public void ChartMenuHeaderOrder_WithDataSource_AppendsShowDataSource() } [Theory] - [InlineData(",", "DateTime (UTC),Series,Value")] - [InlineData(";", "DateTime (UTC);Series;Value")] - [InlineData("\t", "DateTime (UTC)\tSeries\tValue")] - public void ChartCsvHeaderLine_IsDateTimeSeriesValue_InTheChosenSeparator(string separator, string expected) + [InlineData(",", "DateTime (UTC),Series,Value,UTC offset")] + [InlineData(";", "DateTime (UTC);Series;Value;UTC offset")] + [InlineData("\t", "DateTime (UTC)\tSeries\tValue\tUTC offset")] + public void ChartCsvHeaderLine_IsDateTimeSeriesValueUtcOffset_InTheChosenSeparator(string separator, string expected) { Assert.Equal(expected, ViewerServerTab.ChartCsvHeaderLine(separator, TimeZoneInfo.Utc)); } @@ -79,28 +79,28 @@ public void ChartCsvHeaderLine_IsDateTimeSeriesValue_InTheChosenSeparator(string public void ChartCsvHeaderLine_NamesTheZoneTheDataLinesAreWrittenIn() { /* The time column names the zone ChartCsvDataLine writes each time in (#4766), so a file opened later still - says which clock it is on. The same header text Lite's chart export writes. */ + says which clock it is on; the last column holds the UTC offset each row was written at. */ var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); var x = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate(); - Assert.Equal("DateTime (Eastern Standard Time),Series,Value", ViewerServerTab.ChartCsvHeaderLine(",", eastern)); - Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue", ViewerServerTab.ChartCsvHeaderLine("\t", eastern)); - Assert.Equal("2026-11-01 01:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern)); + Assert.Equal("DateTime (Eastern Standard Time),Series,Value,UTC offset", ViewerServerTab.ChartCsvHeaderLine(",", eastern)); + Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue\tUTC offset", ViewerServerTab.ChartCsvHeaderLine("\t", eastern)); + Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern)); /* A fixed-offset server clock is a zone too (Id "UTC+05:30"): the header names the offset its times are in. */ var offset = ServerClock.FixedOffset(330).AsTimeZone(); - Assert.Equal("DateTime (UTC+05:30);Series;Value", ViewerServerTab.ChartCsvHeaderLine(";", offset)); - Assert.Equal("2026-11-01 12:00:00;CPU %;42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ";", offset)); + Assert.Equal("DateTime (UTC+05:30);Series;Value;UTC offset", ViewerServerTab.ChartCsvHeaderLine(";", offset)); + Assert.Equal("2026-11-01 12:00:00;CPU %;42.5;+05:30", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ";", offset)); } [Fact] public void ChartCsvHeaderLine_QuotesAZoneNameContainingTheSeparator() { - /* A zone Id with the separator in it is quoted as any other cell would be, so the column count stays three. */ + /* A zone Id with the separator in it is quoted as any other cell would be, so the column count stays four. */ var zone = TimeZoneInfo.CreateCustomTimeZone("Custom, Zone", TimeSpan.Zero, "Custom", "Custom"); - Assert.Equal("\"DateTime (Custom, Zone)\",Series,Value", ViewerServerTab.ChartCsvHeaderLine(",", zone)); - Assert.Equal("DateTime (Custom, Zone);Series;Value", ViewerServerTab.ChartCsvHeaderLine(";", zone)); + Assert.Equal("\"DateTime (Custom, Zone)\",Series,Value,UTC offset", ViewerServerTab.ChartCsvHeaderLine(",", zone)); + Assert.Equal("DateTime (Custom, Zone);Series;Value;UTC offset", ViewerServerTab.ChartCsvHeaderLine(";", zone)); } [Fact] @@ -119,8 +119,65 @@ public void ChartCsvDataLine_ShowsTheChartInstantInTheDisplayZone() var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); var x = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate(); - Assert.Equal("2026-11-01 01:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern)); - Assert.Equal("2026-11-01 06:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", TimeZoneInfo.Utc)); + Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern)); + Assert.Equal("2026-11-01 06:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", TimeZoneInfo.Utc)); + } + + [Fact] + public void ChartCsvDataLine_TheTwoInstantsOfTheRepeatedHour_DifferOnlyInTheOffsetCell() + { + /* US Eastern fell back at 06:00Z on 2026-11-01, so 05:30Z is the FIRST 01:30 (-04:00) and 06:30Z the SECOND + (-05:00). The time cell reads 01:30 on both rows, as the chart's tick labels do (and stays one plain date + and time format), so the fourth cell is what names which instant a row is (#4766). */ + var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); + var first = ViewerServerTab.ChartCsvDataLine(new DateTime(2026, 11, 1, 5, 30, 0).ToOADate(), "CPU %", 42.5, ",", eastern); + var second = ViewerServerTab.ChartCsvDataLine(new DateTime(2026, 11, 1, 6, 30, 0).ToOADate(), "CPU %", 42.5, ",", eastern); + + Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-04:00", first); + Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", second); + Assert.Equal(first.Split(',')[..3], second.Split(',')[..3]); + } + + [Fact] + public void ChartCsvDataLine_WritesTheOffsetOnEveryRow_NotOnlyInTheRepeatedHour() + { + /* Each row carries the offset in force at its own instant: a summer and a winter instant on the Eastern + clock, and a half-hour zone. The time cell is the plain wall time in all of them. */ + var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); + var summer = new DateTime(2026, 7, 6, 16, 0, 0).ToOADate(); + var winter = new DateTime(2026, 1, 15, 17, 0, 0).ToOADate(); + + Assert.Equal("2026-07-06 12:00:00,CPU %,42.5,-04:00", ViewerServerTab.ChartCsvDataLine(summer, "CPU %", 42.5, ",", eastern)); + Assert.Equal("2026-01-15 12:00:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(winter, "CPU %", 42.5, ",", eastern)); + + var kolkata = ServerClock.FixedOffset(330).AsTimeZone(); + Assert.Equal("2026-07-06 21:30:00,CPU %,42.5,+05:30", ViewerServerTab.ChartCsvDataLine(summer, "CPU %", 42.5, ",", kolkata)); + } + + [Fact] + public void ChartCsvDataLine_InUtc_IsPlusZeroOnEveryRow_EvenForTheInstantsEasternRepeats() + { + /* UTC has no repeated hour: 05:30Z and 06:30Z on the Eastern change day are two different times, and both + rows read +00:00. */ + var atFirst = new DateTime(2026, 11, 1, 5, 30, 0).ToOADate(); + var atSecond = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate(); + + Assert.Equal("2026-11-01 05:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(atFirst, "CPU %", 42.5, ",", TimeZoneInfo.Utc)); + Assert.Equal("2026-11-01 06:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(atSecond, "CPU %", 42.5, ",", TimeZoneInfo.Utc)); + } + + [Theory] + [InlineData(",")] + [InlineData(";")] + [InlineData("\t")] + public void ChartCsvDataLine_JoinsTheOffsetCellWithTheChosenSeparator(string separator) + { + var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); + var x = new DateTime(2026, 11, 1, 5, 30, 0).ToOADate(); + + Assert.Equal( + string.Join(separator, "2026-11-01 01:30:00", "CPU %", "42.5", "-04:00"), + ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, separator, eastern)); } [Fact] diff --git a/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs b/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs new file mode 100644 index 0000000000..b79ee53a09 --- /dev/null +++ b/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs @@ -0,0 +1,178 @@ +/* + * 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.Runtime.CompilerServices; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4766: the FinOps Server Inventory rows (one per server) and Application Connections rows read their times on THEIR +/// server's clock. They went through the active server tab's clock, but Server Inventory lists every server at once and +/// the FinOps tab lists the server it was opened for, so in Server mode a row could read another server's hour. Each row +/// now carries the clock its loader stamped (the server's collected clock, else the viewer machine's offset, the rule every +/// list row uses) and words its text on it, with the UTC offset in the repeated autumn hour. +/// +/// US Eastern and India (+05:30 all year) are the servers, so a clock that differs from this machine's is always +/// among them; the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00) and 06:30Z the second +/// (-05:00) on the Eastern one. +/// +/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection + serializes it with every other class that does. */ +[Collection("viewer-time-statics")] +public sealed class ViewerFinOpsRowClockTests : IDisposable +{ + private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300); + + private static readonly ServerClock India = ServerClock.Resolve("India Standard Time", 330); + + private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode; + private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock; + private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture; + + public ViewerFinOpsRowClockTests() + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + } + + public void Dispose() + { + ViewerTimeHelper.CurrentDisplayMode = _savedMode; + ViewerTimeHelper.ActiveServerClock = _savedClock; + CultureInfo.CurrentCulture = _savedCulture; + } + + private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified); + + private static ServerPropertyRow Inventory(DateTime? asOfUtc, DateTime? collectedUtc, ServerClock? clock) => new() + { + ServerId = 1, + ServerName = "SQL1", + InventoryAsOfUtc = asOfUtc, + LastCollectedUtc = collectedUtc, + Clock = clock, + }; + + private static ApplicationConnectionRow Connections(DateTime firstUtc, DateTime lastUtc, ServerClock? clock) => new() + { + ApplicationName = "app", + FirstSeenUtc = firstUtc, + LastSeenUtc = lastUtc, + Clock = clock, + }; + + [Fact] + public void Inventory_ARowReadsItsOwnServersWallTime_NotTheActiveTabs() + { + ViewerTimeHelper.ActiveServerClock = India; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + /* 14:30Z is 10:30 in US Eastern (the row's server) and 20:00 in India (the active tab's). */ + var row = Inventory(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 14, 30), Eastern); + Assert.Equal("2026-07-01 10:30", row.InventoryAsOfText); + Assert.Equal("2026-07-01 10:30", row.LastCollectedText); + + ViewerTimeHelper.ActiveServerClock = Eastern; + var indian = Inventory(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 14, 30), India); + Assert.Equal("2026-07-01 20:00", indian.InventoryAsOfText); + Assert.Equal("2026-07-01 20:00", indian.LastCollectedText); + } + + [Fact] + public void Inventory_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode() + { + ViewerTimeHelper.ActiveServerClock = India; + var first = Inventory(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 5, 30), Eastern); + var second = Inventory(Utc(2026, 11, 1, 6, 30), Utc(2026, 11, 1, 6, 30), Eastern); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal("2026-11-01 01:30 -04:00", first.InventoryAsOfText); + Assert.Equal("2026-11-01 01:30 -05:00", second.InventoryAsOfText); + Assert.Equal("2026-11-01 01:30 -04:00", first.LastCollectedText); + Assert.Equal("2026-11-01 01:30 -05:00", second.LastCollectedText); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal("2026-11-01 05:30", first.InventoryAsOfText); + Assert.Equal("2026-11-01 06:30", second.LastCollectedText); + } + + [Fact] + public void Inventory_AServerWithNoSnapshotOrCollection_ShowsNothing_AndARowWithoutAClockTakesTheActiveOne() + { + ViewerTimeHelper.ActiveServerClock = Eastern; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + var none = Inventory(null, null, Eastern); + Assert.Equal("", none.InventoryAsOfText); + Assert.Equal("", none.LastCollectedText); + + var unstamped = Inventory(Utc(2026, 11, 1, 6, 30), Utc(2026, 11, 1, 6, 30), clock: null); + Assert.Equal("2026-11-01 01:30 -05:00", unstamped.InventoryAsOfText); + Assert.Equal("2026-11-01 01:30 -05:00", unstamped.LastCollectedText); + } + + [Fact] + public void AppConnections_ARowReadsItsOwnServersWallTime_NotTheActiveTabs() + { + ViewerTimeHelper.ActiveServerClock = India; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + var row = Connections(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 15, 30), Eastern); + Assert.Equal("2026-07-01 10:30", row.FirstSeenText); + Assert.Equal("2026-07-01 11:30", row.LastSeenText); + } + + [Fact] + public void AppConnections_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode() + { + ViewerTimeHelper.ActiveServerClock = India; + var row = Connections(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Eastern); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal("2026-11-01 01:30 -04:00", row.FirstSeenText); + Assert.Equal("2026-11-01 01:30 -05:00", row.LastSeenText); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal("2026-11-01 05:30", row.FirstSeenText); + Assert.Equal("2026-11-01 06:30", row.LastSeenText); + } + + /// + /// Both loaders read the clocks and stamp each row with its server's, and the sort values (the "Local" DateTimes) are + /// converted on that same clock, so a column sorts in the order its text reads. Neither goes through the active + /// server's clock any more (ForDisplay). + /// + [Fact] + public void TheLoaders_StampEachRowsClock_AndConvertTheSortValuesOnIt() + { + var workload = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerDataService.FinOps.Workload.cs", ThisFile())); + var connections = ViewerTypedRangeTests.MemberText(workload, "GetApplicationConnectionsAsync"); + Assert.Contains("GetServerClocksAsync(serverId, cancellationToken)", connections); + Assert.Contains("ClockForServerOrMachine(", connections); + Assert.Contains("Clock = clock", connections); + Assert.Contains("ConvertToDisplay(reader.GetDateTime(18), ViewerTimeHelper.CurrentDisplayMode, clock)", connections); + Assert.Contains("ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock)", connections); + Assert.DoesNotContain("ForDisplay(", connections); + + var inventorySource = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerDataService.FinOps.Inventory.cs", ThisFile())); + var inventory = ViewerTypedRangeTests.MemberText(inventorySource, "GetServerInventoryAsync"); + Assert.Contains("GetServerClocksAsync(null, cancellationToken)", inventory); + Assert.Contains("ClockForServerOrMachine(clocks, serverId, TimeZoneInfo.Local, nowUtc)", inventory); + Assert.Contains("Clock = clock", inventory); + Assert.Contains("ConvertToDisplay(reader.GetDateTime(13), ViewerTimeHelper.CurrentDisplayMode, clock)", inventory); + Assert.Contains("ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock)", inventory); + Assert.DoesNotContain("ForDisplay(", inventory); + } + + private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile; +} diff --git a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs new file mode 100644 index 0000000000..9c51a6306c --- /dev/null +++ b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs @@ -0,0 +1,190 @@ +/* + * 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.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Windows.Data; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4766: a viewer grid time column that shows the UTC offset in the repeated autumn hour must sort by the row's true UTC +/// DateTime, not by its text and not by a DateTime already converted to the display zone. +/// +/// The text sorts wrong east of UTC: "02:30 +01:00" sorts before "02:30 +02:00", so the second pass of the hour +/// comes out before the first. A display-zone DateTime (a ...Local value made by ForDisplay) is no better: +/// the second pass at 01:15 (06:15Z) sorts before the first pass at 01:45 (05:45Z). Only the UTC instant puts both passes +/// in order, so each column carries the row's UTC member in SortMemberPath. The filter button's Tag is a +/// separate contract and is left alone. +/// +/// A row read from a stored server wall clock (job history, default trace, the query and procedure stats creation +/// and execution times) keeps its text sort, and a row with no UTC member of its own (the System Events rows) is not in +/// the table. +/// +/* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */ +[Collection("viewer-time-statics")] +public sealed class ViewerGridTimeColumnSortMemberTests +{ + private const string ViewerFolder = "PerformanceMonitor.Darling.Viewer"; + + /// The row type is carried as its assembly-qualified name, so each case stays a serializable, separately reported row. + private static string Name(Type type) => type.AssemblyQualifiedName!; + + /// (xaml in the viewer project, the DataGrid's x:Name, the bound text property, the row type, the SortMemberPath). + public static TheoryData Columns => new() + { + { "FinOpsTab.xaml", "FinOpsApplicationConnectionsDataGrid", "FirstSeenText", Name(typeof(ApplicationConnectionRow)), "FirstSeenUtc" }, + { "FinOpsTab.xaml", "FinOpsApplicationConnectionsDataGrid", "LastSeenText", Name(typeof(ApplicationConnectionRow)), "LastSeenUtc" }, + { "FinOpsTab.xaml", "FinOpsServerInventoryDataGrid", "InventoryAsOfText", Name(typeof(ServerPropertyRow)), "InventoryAsOfUtc" }, + { "FinOpsTab.xaml", "FinOpsServerInventoryDataGrid", "LastCollectedText", Name(typeof(ServerPropertyRow)), "LastCollectedUtc" }, + { "ViewerServerTab.xaml", "QueryStoreClutterGrid", "OptionsCapturedText", Name(typeof(ViewerDataService.QueryStoreClutterRow)), "OptionsCapturedUtc" }, + { "ViewerServerTab.xaml", "QueryStoreOverheadGrid", "LastObservedText", Name(typeof(ViewerDataService.QueryStoreOverheadWaitRow)), "LastObservedUtc" }, + { "ViewerServerTab.xaml", "QuerySnapshotsGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "CurrentActiveQueriesGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "QueryStoreGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreRow)), "LastExecutionTime" }, + { "ViewerServerTab.xaml", "QueryStoreGrid", "FirstExecutionTimeLocal", Name(typeof(ViewerQueryStoreRow)), "FirstExecutionTime" }, + { "ViewerServerTab.xaml", "QueryStoreRegressionsGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreRegressionRow)), "LastExecutionTime" }, + { "ViewerServerTab.xaml", "PlanCorrectionGrid", "CollectionTimeLocal", Name(typeof(PlanCorrectionRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "PlanCorrectionGrid", "ValidSinceLocal", Name(typeof(PlanCorrectionRow)), "ValidSince" }, + { "ViewerServerTab.xaml", "PlanCorrectionGrid", "LastRefreshLocal", Name(typeof(PlanCorrectionRow)), "LastRefresh" }, + { "ViewerServerTab.xaml", "PlanCorrectionGrid", "ExecuteActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "ExecuteActionInitiatedTime" }, + { "ViewerServerTab.xaml", "PlanCorrectionGrid", "RevertActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "RevertActionInitiatedTime" }, + { "ViewerServerTab.xaml", "BlockedProcessReportGrid", "EventTimeLocal", Name(typeof(ViewerBlockedProcessRow)), "EventTime" }, + { "ViewerServerTab.xaml", "DeadlockGrid", "DeadlockTimeLocal", Name(typeof(DeadlockProcessDetail)), "DeadlockTime" }, + { "ViewerServerTab.xaml", "AutomaticTuningGrid", "CollectionTimeLocal", Name(typeof(AutomaticTuningRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "ServerConfigChangesGrid", "ChangeTimeDisplay", Name(typeof(ServerConfigChangeRow)), "ChangeTime" }, + { "ViewerServerTab.xaml", "DatabaseConfigChangesGrid", "ChangeTimeDisplay", Name(typeof(DatabaseConfigChangeRow)), "ChangeTime" }, + { "ViewerServerTab.xaml", "TraceFlagChangesGrid", "ChangeTimeDisplay", Name(typeof(TraceFlagChangeRow)), "ChangeTime" }, + { "ViewerServerTab.xaml", "CollectionCaveatsGrid", "FirstSeenFormatted", Name(typeof(CollectionCaveatRow)), "FirstSeenUtc" }, + { "ViewerServerTab.xaml", "CollectionCaveatsGrid", "LastSeenFormatted", Name(typeof(CollectionCaveatRow)), "LastSeenUtc" }, + { "ViewerServerTab.xaml", "PgCollectionCaveatsGrid", "FirstSeenFormatted", Name(typeof(CollectionCaveatRow)), "FirstSeenUtc" }, + { "ViewerServerTab.xaml", "PgCollectionCaveatsGrid", "LastSeenFormatted", Name(typeof(CollectionCaveatRow)), "LastSeenUtc" }, + { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastSuccessFormatted", Name(typeof(CollectorHealthRow)), "LastSuccessTime" }, + { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastRunFormatted", Name(typeof(CollectorHealthRow)), "LastRunTime" }, + { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastErrorFormatted", Name(typeof(CollectorHealthRow)), "LastErrorTime" }, + { "ViewerServerTab.xaml", "CollectionLogGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" }, + { "CollectionLogWindow.xaml", "LogDataGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" }, + { "WaitDrillDownWindow.xaml", "ResultsDataGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" }, + { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "FirstExecutionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "FirstExecutionTime" }, + { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "LastExecutionTime" }, + { "ManageServersWindow.xaml", "ServersGrid", "LastCollectedDisplay", Name(typeof(ManagedServerListItem)), "LastCollectedUtc" }, + { "NotificationRoutesWindow.xaml", "RoutesGrid", "ModifiedDisplay", Name(typeof(NotificationRouteRow)), "ModifiedAtUtc" }, + }; + + /// + /// The column bound to the text property carries the row's UTC member in SortMemberPath, and that member is a + /// public or DateTime? on the row type. A wrong name sorts nothing and raises nothing, + /// so the name is checked against the row type the grid is bound to. A name ending in Local or Display + /// would be a display-zone value and is refused. + /// + [Theory] + [MemberData(nameof(Columns))] + public void ATimeColumn_SortsByItsRowsUtcDateTime(string xamlFile, string grid, string textProperty, string rowTypeName, string sortMember) + { + var xaml = ReadRepoFile("Darling", ViewerFolder, xamlFile); + + var column = FindColumn(xaml, grid, textProperty); + var declared = Regex.Match(column, @"\sSortMemberPath=""(?[^""]*)"""); + Assert.True(declared.Success, $"{xamlFile}: the {grid} column bound to {textProperty} has no SortMemberPath, so it sorts by its text."); + Assert.Equal(sortMember, declared.Groups["path"].Value); + + var rowType = Type.GetType(rowTypeName, throwOnError: true)!; + var property = rowType.GetProperty(sortMember, BindingFlags.Public | BindingFlags.Instance); + Assert.True(property != null, $"{rowType.Name} has no public property {sortMember}."); + Assert.True( + (Nullable.GetUnderlyingType(property!.PropertyType) ?? property.PropertyType) == typeof(DateTime), + $"{rowType.Name}.{sortMember} is a {property.PropertyType.Name}, not a DateTime."); + + Assert.False(sortMember.EndsWith("Local", StringComparison.Ordinal) || sortMember.EndsWith("Display", StringComparison.Ordinal), + $"{sortMember} reads as a display-zone member; the column must sort by the UTC one."); + } + + /// + /// The two passes of the repeated hour, sorted the way a header click sorts them (a + /// on the column's SortMemberPath). US Eastern falls back at 2026-11-01 06:00Z, so 05:45Z is 01:45 -04:00 (the + /// first pass) and 06:15Z is 01:15 -05:00 (the second). The UTC member puts the first pass first; the display-zone + /// DateTime and the text both put the second pass first, which is what the columns did before. + /// + [Fact] + public void TheRepeatedHour_SortsFirstPassBeforeSecond_OnTheUtcMember_ButNotOnTheDisplayDateTimeOrTheText() + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + var savedClock = ViewerTimeHelper.ActiveServerClock; + var savedCulture = CultureInfo.CurrentCulture; + try + { + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + ViewerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300); + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + + /* The reader sets the UTC instant and the display-zone DateTime side by side. */ + ApplicationConnectionRow Row(string name, DateTime utc) => new() + { + ApplicationName = name, + FirstSeenUtc = utc, + FirstSeenLocal = ViewerTimeHelper.ForDisplay(utc), + }; + var firstPass = Row("first", Utc(2026, 11, 1, 5, 45)); + var secondPass = Row("second", Utc(2026, 11, 1, 6, 15)); + var rows = new[] { secondPass, firstPass }; + + /* The member the column is declared to sort by, read from the XAML rather than restated here. */ + var declared = Regex.Match( + FindColumn(ReadRepoFile("Darling", ViewerFolder, "FinOpsTab.xaml"), "FinOpsApplicationConnectionsDataGrid", "FirstSeenText"), + @"\sSortMemberPath=""(?[^""]*)""").Groups["path"].Value; + + Assert.Equal(new[] { "first", "second" }, Sorted(rows, declared)); + + /* Why the display-zone value and the text are refused as sort keys. */ + Assert.Equal(new DateTime(2026, 11, 1, 1, 45, 0), firstPass.FirstSeenLocal); + Assert.Equal(new DateTime(2026, 11, 1, 1, 15, 0), secondPass.FirstSeenLocal); + Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenLocal))); + Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenText))); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + ViewerTimeHelper.ActiveServerClock = savedClock; + CultureInfo.CurrentCulture = savedCulture; + } + } + + private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified); + + /// The application names in the order a header click on that sort member ascending gives. + private static string[] Sorted(ApplicationConnectionRow[] rows, string sortMemberPath) + { + var view = new ListCollectionView(rows); + view.SortDescriptions.Add(new SortDescription(sortMemberPath, ListSortDirection.Ascending)); + return view.Cast().Select(r => r.ApplicationName).ToArray(); + } + + /// The start tag of the one DataGridTextColumn inside the named grid whose binding is the text property. + private static string FindColumn(string xaml, string grid, string textProperty) + { + var gridStart = Regex.Match(xaml, @"]*?x:Name=""" + Regex.Escape(grid) + @""""); + Assert.True(gridStart.Success, $"the grid {grid} is not in the file."); + var gridEnd = xaml.IndexOf("", gridStart.Index, StringComparison.Ordinal); + Assert.True(gridEnd > gridStart.Index, $"the end of the grid {grid} was not found."); + + var columns = Regex.Matches(xaml[gridStart.Index..gridEnd], @"""])*>") + .Where(m => Regex.IsMatch(m.Value, @"\sBinding=""\{Binding " + Regex.Escape(textProperty) + @"(?=[,}\s])")) + .ToList(); + Assert.True(columns.Count == 1, $"{grid} has {columns.Count} columns bound to {textProperty}; the pin expects exactly one."); + return columns[0].Value; + } +} diff --git a/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs b/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs new file mode 100644 index 0000000000..f75a39cf35 --- /dev/null +++ b/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs @@ -0,0 +1,183 @@ +/* + * 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.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4766: the viewer grid columns that bound a DateTime with a XAML StringFormat (FinOps First Seen, Last Seen, +/// Inventory As Of and Last Collected; the Query Store clutter grid's Options Captured; the overhead grid's Last Seen). +/// A DateTime already converted to the display clock cannot say which of the two 01:30s of the repeated autumn hour it +/// was, and a XAML format cannot add an offset. Each row now keeps the UTC instant beside the converted DateTime and words +/// a text property from it through ; the column binds the +/// text and sorts by the DateTime, so the order stays chronological. +/// +/// US Eastern falls back at 2026-11-01 06:00Z, so 05:30Z is the first 01:30 (-04:00) and 06:30Z the second (-05:00). +/// +/* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */ +[Collection("viewer-time-statics")] +public sealed class ViewerGridTimeTextZoneTests +{ + private const string ViewerFolder = "PerformanceMonitor.Darling.Viewer"; + + private static DateTime Naive(int year, int month, int day, int hour, int minute = 0) => + new(year, month, day, hour, minute, 0, DateTimeKind.Unspecified); + + private static ServerClock Eastern => ServerClock.Resolve("Eastern Standard Time", -300); + + /* Runs the body in the given display mode and server clock, on the invariant culture; all three are restored. */ + private static void WithDisplay(TimeDisplayMode mode, ServerClock clock, Action body) + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + var savedClock = ViewerTimeHelper.ActiveServerClock; + var savedCulture = CultureInfo.CurrentCulture; + try + { + ViewerTimeHelper.CurrentDisplayMode = mode; + ViewerTimeHelper.ActiveServerClock = clock; + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + + body(); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + ViewerTimeHelper.ActiveServerClock = savedClock; + CultureInfo.CurrentCulture = savedCulture; + } + } + + /// + /// A FinOps Application Connections row: First Seen at 05:30Z and Last Seen at 06:30Z read 01:30 -04:00 and 01:30 -05:00 + /// in Server mode on a US Eastern clock, the two DateTimes the columns sort by are told apart only by the text, and the + /// hour after is the bare wall time. UTC mode is the stored instant. + /// + [Fact] + public void AFinOpsApplicationConnectionRow_InTheRepeatedHour_ReadsItsOffset() + { + var row = new ApplicationConnectionRow + { + FirstSeenUtc = Naive(2026, 11, 1, 5, 30), + LastSeenUtc = Naive(2026, 11, 1, 6, 30), + }; + var after = new ApplicationConnectionRow { FirstSeenUtc = Naive(2026, 11, 1, 7, 30), LastSeenUtc = Naive(2026, 11, 1, 7, 30) }; + + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30 -04:00", row.FirstSeenText); + Assert.Equal("2026-11-01 01:30 -05:00", row.LastSeenText); + Assert.Equal("2026-11-01 02:30", after.FirstSeenText); + }); + + WithDisplay(TimeDisplayMode.UTC, Eastern, () => + { + Assert.Equal("2026-11-01 05:30", row.FirstSeenText); + Assert.Equal("2026-11-01 06:30", row.LastSeenText); + }); + } + + /// + /// A FinOps inventory row: Inventory As Of and Last Collected read the offset in the repeated hour, and a server with no + /// snapshot or no collection shows an empty cell, as the XAML-formatted column did. + /// + [Fact] + public void AFinOpsInventoryRow_InTheRepeatedHour_ReadsItsOffset_AndAMissingTimeIsEmpty() + { + var row = new ServerPropertyRow + { + InventoryAsOfUtc = Naive(2026, 11, 1, 5, 30), + LastCollectedUtc = Naive(2026, 11, 1, 6, 30), + }; + var missing = new ServerPropertyRow(); + + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30 -04:00", row.InventoryAsOfText); + Assert.Equal("2026-11-01 01:30 -05:00", row.LastCollectedText); + Assert.Equal("", missing.InventoryAsOfText); + Assert.Equal("", missing.LastCollectedText); + }); + } + + /// + /// The Query Store clutter grid: a database row's Options Captured and an overhead row's Last Seen read the offset in the + /// repeated hour, and a database with no options row has no text at all, so the binding's TargetNullValue still + /// draws the em-dash. + /// + [Fact] + public void AClutterRow_InTheRepeatedHour_ReadsItsOffset_AndNoOptionsRowHasNoText() + { + var database = new ViewerDataService.QueryStoreClutterRow { OptionsCapturedUtc = Naive(2026, 11, 1, 6, 30) }; + var noOptions = new ViewerDataService.QueryStoreClutterRow(); + var wait = new ViewerDataService.QueryStoreOverheadWaitRow { LastObservedUtc = Naive(2026, 11, 1, 5, 30) }; + + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30 -05:00", database.OptionsCapturedText); + Assert.Null(noOptions.OptionsCapturedText); + Assert.Equal("2026-11-01 01:30 -04:00", wait.LastObservedText); + }); + } + + /// + /// Each column is a WPF column this suite does not instantiate, so the wiring is a source pin: the column binds its text + /// property (no StringFormat, which cannot carry the offset), sorts by the row's UTC DateTime (the display-zone one + /// puts the second pass of the repeated hour before the first, see ), + /// and, where its header carries a filter button, keeps that button's tag on the display-zone DateTime property. + /// + [Theory] + [InlineData("FinOpsTab.xaml", "FirstSeenText", "FirstSeenUtc", "FirstSeenLocal", true)] + [InlineData("FinOpsTab.xaml", "LastSeenText", "LastSeenUtc", "LastSeenLocal", true)] + [InlineData("FinOpsTab.xaml", "InventoryAsOfText", "InventoryAsOfUtc", "InventoryAsOf", true)] + [InlineData("FinOpsTab.xaml", "LastCollectedText", "LastCollectedUtc", "LastCollected", true)] + [InlineData("ViewerServerTab.xaml", "OptionsCapturedText", "OptionsCapturedUtc", "OptionsCaptured", false)] + [InlineData("ViewerServerTab.xaml", "LastObservedText", "LastObservedUtc", "LastObserved", false)] + public void EachColumn_BindsItsText_SortsByItsUtcDateTime_AndFiltersOnTheDateTime( + string file, string textProperty, string sortMember, string dateProperty, bool hasFilterButton) + { + var xaml = ReadRepoFile("Darling", ViewerFolder, file); + + /* The element's start tag, whatever the order of its attributes; the binding may carry only a TargetNullValue. */ + var start = Regex.Match(xaml, + @"]*\sBinding=""\{Binding " + textProperty + @"(?:, TargetNullValue=[^,}]+)?\}"")" + + @"(?=[^>]*\sSortMemberPath=""" + sortMember + @""")[^>]*>"); + Assert.True(start.Success, $"{file}: the column bound to {textProperty} and sorted by {sortMember} is not where this pin looks."); + + Assert.DoesNotContain("StringFormat", start.Value, StringComparison.Ordinal); + + if (hasFilterButton) + { + var end = xaml.IndexOf("", start.Index, StringComparison.Ordinal); + Assert.True(end > start.Index, $"{file}: the end of the {textProperty} column was not found."); + Assert.Contains($"Tag=\"{dateProperty}\"", xaml[start.Index..end], StringComparison.Ordinal); + } + } + + /// + /// The readers set the UTC instant beside each converted DateTime, so a row can never show the default 0001-01-01. + /// + [Theory] + [InlineData("ViewerDataService.FinOps.Workload.cs", "FirstSeenUtc = reader.GetDateTime(18)")] + [InlineData("ViewerDataService.FinOps.Workload.cs", "LastSeenUtc = reader.GetDateTime(19)")] + [InlineData("ViewerDataService.FinOps.Inventory.cs", "InventoryAsOfUtc = reader.IsDBNull(13) ? null : reader.GetDateTime(13)")] + [InlineData("ViewerDataService.FinOps.Inventory.cs", "LastCollectedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)")] + [InlineData("ViewerDataService.QueryStoreClutter.cs", "OptionsCapturedUtc = c?.CapturedAt")] + [InlineData("ViewerDataService.QueryStoreClutter.cs", "LastObservedUtc = w.LastObserved")] + public void EachReader_SetsTheUtcInstant_BesideTheConvertedDateTime(string file, string assignment) + { + Assert.Contains(assignment, ReadRepoFile("Darling", ViewerFolder, file), StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs b/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs new file mode 100644 index 0000000000..377b644d0d --- /dev/null +++ b/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs @@ -0,0 +1,103 @@ +/* + * 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.Runtime.CompilerServices; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4766: the Notification Routes grid's Modified column. modified_at is written as +/// now() AT TIME ZONE 'UTC', so it is one UTC instant (the row reads it as ), and it +/// used to be ToLocalTime().ToString("g"): this machine's clock in every display mode, and bare in the repeated +/// autumn hour. It now goes through like the grids +/// beside it, so it follows the mode and names the UTC offset of each 01:30 on a US Eastern server (autumn change +/// 2026-11-01 at 06:00 UTC). +/// +/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection + serializes it with every other class that does. */ +[Collection("viewer-time-statics")] +public sealed class ViewerNotificationRouteDisplayTests : IDisposable +{ + private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300); + + private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode; + private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock; + private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture; + + public ViewerNotificationRouteDisplayTests() + { + /* "g" runs on the current culture, as every grid's text does; the invariant one fixes its pattern (MM/dd/yyyy HH:mm). */ + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + } + + public void Dispose() + { + ViewerTimeHelper.CurrentDisplayMode = _savedMode; + ViewerTimeHelper.ActiveServerClock = _savedClock; + CultureInfo.CurrentCulture = _savedCulture; + } + + private static NotificationRouteRow Route(int y, int mo, int d, int h, int mi) => new() + { + RouteId = 1, + MetricMatch = "High CPU", + ModifiedAtUtc = DateTime.SpecifyKind(new DateTime(y, mo, d, h, mi, 0), DateTimeKind.Utc), + }; + + [Fact] + public void Modified_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode() + { + ViewerTimeHelper.ActiveServerClock = Eastern; + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal("11/01/2026 01:30 -04:00", Route(2026, 11, 1, 5, 30).ModifiedDisplay); + Assert.Equal("11/01/2026 01:30 -05:00", Route(2026, 11, 1, 6, 30).ModifiedDisplay); + Assert.Equal("11/01/2026 02:30", Route(2026, 11, 1, 7, 30).ModifiedDisplay); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal("11/01/2026 05:30", Route(2026, 11, 1, 5, 30).ModifiedDisplay); + Assert.Equal("11/01/2026 06:30", Route(2026, 11, 1, 6, 30).ModifiedDisplay); + } + + /// The column follows the display mode: a summer instant on the Eastern server reads its wall time, not this machine's. + [Fact] + public void Modified_InServerMode_ReadsTheServersWallTime_WhateverThisMachinesZoneIs() + { + ViewerTimeHelper.ActiveServerClock = Eastern; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + Assert.Equal("07/01/2026 10:30", Route(2026, 7, 1, 14, 30).ModifiedDisplay); + } + + /// A route that has not been stored yet has no time to show. + [Fact] + public void Modified_OfARouteNotStoredYet_IsEmpty() + { + Assert.Equal("", new NotificationRouteRow().ModifiedDisplay); + } + + /// The column is worded by the shared renderer and not by a ToLocalTime() of its own. + [Fact] + public void Modified_IsWordedByTheSharedRenderer() + { + var source = ViewerTypedRangeTests.StripComments( + ViewerTypedRangeTests.MemberText( + ViewerTypedRangeTests.ViewerSource("ViewerDataService.NotificationRoutes.cs", ThisFile()), "ModifiedDisplay")); + + Assert.Contains("ViewerTimeHelper.FormatForDisplay(ModifiedAtUtc, \"g\")", source); + Assert.DoesNotContain("ToLocalTime", source); + } + + private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile; +} diff --git a/Darling/Darling.Tests/ViewerServerClockTests.cs b/Darling/Darling.Tests/ViewerServerClockTests.cs index e958839082..afeb78af8b 100644 --- a/Darling/Darling.Tests/ViewerServerClockTests.cs +++ b/Darling/Darling.Tests/ViewerServerClockTests.cs @@ -139,6 +139,137 @@ public void TheProcessWideClock_DrivesForDisplayAndTheDisplayZone() } } + [Fact] + public void FormatForDisplay_InServerMode_GivesEachInstantOfTheRepeatedHourItsOwnOffset() + { + /* US Eastern fell back at 06:00Z on 2026-11-01: 05:30Z is the FIRST 01:30 (daylight time, -04:00) and 06:30Z the + SECOND (standard time, -05:00). Both used to print as 01:30, two grid rows nobody could tell apart (#4766). */ + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss")); + + /* The whole repeated hour is covered, edge to edge: 05:00Z is the first 01:00 and 06:59Z the last minute of + the second 01:xx. The format string is the site's own, so a time-only column carries the offset too. */ + Assert.Equal("01:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 0), "HH:mm")); + Assert.Equal("01:59 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 59), "HH:mm")); + Assert.Equal("01:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 0), "HH:mm")); + Assert.Equal("01:59 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 59), "HH:mm")); + + /* A value that may be missing is the same text, and nothing at all when it is. */ + Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay((DateTime?)Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("", ViewerTimeHelper.FormatForDisplay((DateTime?)null, "yyyy-MM-dd HH:mm:ss")); + }); + } + + [Fact] + public void FormatForDisplay_OutsideTheRepeatedHour_IsTheForDisplayTextWithNothingAppended() + { + /* The minute before the repeated hour (00:59 daylight time), the first minute after it (02:00 standard time), + and an ordinary summer and winter instant read exactly as ForDisplay(x).ToString(format) did. */ + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 00:59:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 4, 59), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-11-01 02:00:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 7, 0), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-07-06 12:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 7, 6, 16, 0), "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-01-15 12:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 1, 15, 17, 0), "yyyy-MM-dd HH:mm")); + + foreach (var instant in new[] { Naive(2026, 11, 1, 4, 59), Naive(2026, 11, 1, 7, 0), Naive(2026, 7, 6, 16, 0), Naive(2026, 1, 15, 17, 0) }) + { + Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString("yyyy-MM-dd HH:mm:ss"), ViewerTimeHelper.FormatForDisplay(instant, "yyyy-MM-dd HH:mm:ss")); + } + }); + } + + [Fact] + public void FormatForDisplay_InUtcMode_NeverAddsAnOffset() + { + /* UTC has no repeated hour: 05:30Z and 06:30Z are two different times, whatever the server's own zone is. */ + WithDisplay(TimeDisplayMode.UTC, Eastern, () => + { + Assert.Equal("2026-11-01 05:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-11-01 06:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss")); + }); + } + + [Fact] + public void FormatForDisplay_OnAFixedOffsetClock_NeverAddsAnOffset_AtTheInstantsWhereAUsZoneRepeatsAnHour() + { + /* A fixed-offset clock (a server that reported no time zone id) has no repeated hour: at 05:30Z and 06:30Z on the + day a US zone falls back, a -05:00 clock reads 00:30 and 01:30, each once. */ + WithDisplay(TimeDisplayMode.ServerTime, ServerClock.FixedOffset(-300), () => + { + Assert.Equal("2026-11-01 00:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-11-01 01:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("01:30", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "HH:mm")); + }); + + /* The same two instants on a zone clock read 01:30 twice. */ + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss")); + }); + } + + [Fact] + public void FormatForDisplay_OnAZoneEastOfUtc_GivesTheTwoPassesOfTheRepeatedHourTheirPositiveOffsets() + { + /* Berlin falls back at 01:00Z on 2026-10-25 (03:00 CEST to 02:00 CET): 02:30 happens at 00:30Z (+02:00) and + again at 01:30Z (+01:00). */ + WithDisplay(TimeDisplayMode.ServerTime, ServerClock.Resolve("W. Europe Standard Time", 60), () => + { + Assert.Equal("2026-10-25 02:30:00 +02:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 0, 30), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-10-25 02:30:00 +01:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 1, 30), "yyyy-MM-dd HH:mm:ss")); + + /* The hour before and the hour after are bare. */ + Assert.Equal("2026-10-25 01:59:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 24, 23, 59), "yyyy-MM-dd HH:mm:ss")); + Assert.Equal("2026-10-25 03:00:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 2, 0), "yyyy-MM-dd HH:mm:ss")); + }); + } + + [Fact] + public void FormatForDisplay_FormatsOnTheCurrentCulture_AsTheSitesItReplacedDid() + { + /* "g" is the culture's own general date and short time. The renderer keeps the current culture, so the grids + that print it read as they did before; only the offset suffix is new. */ + var german = new System.Globalization.CultureInfo("de-DE"); + + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + var instant = Naive(2026, 11, 1, 6, 30); + + Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString("g") + " -05:00", ViewerTimeHelper.FormatForDisplay(instant, "g")); + Assert.NotEqual( + ViewerTimeHelper.ForDisplay(instant).ToString("g", System.Globalization.CultureInfo.InvariantCulture) + " -05:00", + ViewerTimeHelper.FormatForDisplay(instant, "g")); + }, german); + } + + /// Runs with the process-wide display mode and server clock set, on the invariant + /// culture unless one is given, so a literal expected string does not depend on the machine's separators. All + /// three are restored. + private static void WithDisplay(TimeDisplayMode mode, ServerClock clock, Action body, System.Globalization.CultureInfo? culture = null) + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + var savedClock = ViewerTimeHelper.ActiveServerClock; + var savedCulture = System.Globalization.CultureInfo.CurrentCulture; + try + { + ViewerTimeHelper.CurrentDisplayMode = mode; + ViewerTimeHelper.ActiveServerClock = clock; + System.Globalization.CultureInfo.CurrentCulture = culture ?? System.Globalization.CultureInfo.InvariantCulture; + + body(); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + ViewerTimeHelper.ActiveServerClock = savedClock; + System.Globalization.CultureInfo.CurrentCulture = savedCulture; + } + } + [Fact] public void ServerClockSql_ReadsTheZoneAndTheOffsetFromTheSameNewestRow() { diff --git a/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs b/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs new file mode 100644 index 0000000000..9e9af1dca0 --- /dev/null +++ b/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs @@ -0,0 +1,125 @@ +/* + * 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.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4766: the two time-range slicers word the range they hold the same way. Lite's caption (the start and the end of the +/// selection) goes through , so the two instants of a repeated autumn hour read "01:30 +/// -04:00" and "01:30 -05:00". The viewer's slicer is a copy of Lite's and used to build the caption with +/// ForDisplay(...).ToString(...), which read both as "01:30". The tick labels along the axis stay the plain wall time +/// in both. +/// +/// The caption is built in a private method of a WPF control, so the tests read the two sources: the viewer's call +/// must be Lite's call (same method, same instants, same format, so the same culture), and the text that call gives for +/// the pair is checked on US Eastern (autumn change 2026-11-01 at 06:00 UTC). +/// +public sealed class ViewerSlicerRangeLabelTests +{ + private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300); + + private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified); + + /// The caption's two Format calls, as written, from a slicer's UpdateRangeLabel. + private static string[] FormatCalls(string source) + { + var body = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "UpdateRangeLabel")) + .Replace("UiDisplayZone.", "DisplayZone.", StringComparison.Ordinal); + return Regex.Matches(body, @"DisplayZone\.Format\(UtcAtNorm\(_range(?:Start|End)\),[^;]*;") + .Select(m => Regex.Replace(m.Value, @"\s+", " ")) + .ToArray(); + } + + /// + /// The viewer's range caption is Lite's, call for call: two DisplayZone.Format calls, one per end of the range, + /// in a zone value, with the same "yyyy-MM-dd HH:mm" format. That method words the text on the invariant culture in + /// both products. + /// + [Fact] + public void TheViewersRangeCaption_IsLitesCall_ForCall() + { + var viewer = FormatCalls(ViewerTypedRangeTests.ViewerSource("TimeRangeSlicerControl.xaml.cs", ThisFile())); + var lite = FormatCalls(File.ReadAllText(LitePath("Controls", "TimeRangeSlicerControl.xaml.cs"))); + + Assert.Equal(2, lite.Length); + Assert.Equal(lite, viewer); + Assert.Equal("DisplayZone.Format(UtcAtNorm(_rangeStart), zone, \"yyyy-MM-dd HH:mm\");", viewer[0]); + Assert.Equal("DisplayZone.Format(UtcAtNorm(_rangeEnd), zone, \"yyyy-MM-dd HH:mm\");", viewer[1]); + } + + /// + /// The caption's zone is the display mode's zone on the active server's clock, and the viewer's caption no longer + /// words the range from ForDisplay. The tick labels are the one place the slicer still does, so they stay + /// the plain wall time. + /// + [Fact] + public void TheViewersRangeCaption_ReadsTheCurrentDisplayZone_AndTheTickLabelsStayPlain() + { + var source = ViewerTypedRangeTests.ViewerSource("TimeRangeSlicerControl.xaml.cs", ThisFile()); + var caption = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "UpdateRangeLabel")); + + Assert.Contains("var zone = ViewerTimeHelper.CurrentDisplayZone();", caption); + Assert.DoesNotMatch(@"\bForDisplay\b", caption); + + var code = ViewerTypedRangeTests.StripComments(source); + Assert.Single(Regex.Matches(code, @"ViewerTimeHelper\.ForDisplay\(tickTime\)\.ToString\(""MM/dd HH:mm""\)")); + Assert.Single(Regex.Matches(code, @"\bForDisplay\(")); + } + + /// + /// The text that shared call gives for the pair, on the culture Lite's caption uses (the invariant one, whatever the + /// machine's): 05:30Z is the first 01:30 (-04:00), 06:30Z the second (-05:00), and the hour after is bare. + /// + [Fact] + public void TheRangeCaptionsCall_NamesTheOffsetOfEachOccurrenceOfTheRepeatedHour() + { + var saved = CultureInfo.CurrentCulture; + try + { + /* A culture with its own time separator would change ToString(format) but not the invariant Format call. */ + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI"); + var zone = ViewerTimeHelper.DisplayZoneFor(TimeDisplayMode.ServerTime, Eastern); + + Assert.Equal("2026-11-01 01:30 -04:00", DisplayZone.Format(Utc(2026, 11, 1, 5, 30), zone, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-11-01 01:30 -05:00", DisplayZone.Format(Utc(2026, 11, 1, 6, 30), zone, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-11-01 02:30", DisplayZone.Format(Utc(2026, 11, 1, 7, 30), zone, "yyyy-MM-dd HH:mm")); + Assert.Equal("2026-11-01 05:30", DisplayZone.Format(Utc(2026, 11, 1, 5, 30), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm")); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + /// A Lite source file, found by walking up from this test file to the repo root. + private static string LitePath(string folder, string file, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var relative = Path.Combine("Lite", folder, file); + while (dir is not null && !File.Exists(Path.Combine(dir, relative))) + { + dir = Path.GetDirectoryName(dir); + } + + Assert.NotNull(dir); + return Path.Combine(dir!, relative); + } + + private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile; +} diff --git a/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs b/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs index 141e7e815a..83290771ad 100644 --- a/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs +++ b/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs @@ -452,8 +452,10 @@ public void ManageServers_ReadsTheClocksOncePerLoad_AndStampsEachRow() var display = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "LastCollectedDisplay")); var load = ViewerTypedRangeTests.StripComments(source); - Assert.DoesNotContain("ForDisplay", display); - Assert.Contains("ConvertToDisplay(utc, ViewerTimeHelper.CurrentDisplayMode, Clock", display); + /* The active-clock ForDisplay by its exact name: FormatForDisplay is the renderer that takes the row's zone (#4766). */ + Assert.DoesNotMatch(@"\bForDisplay\b", display); + Assert.Matches( + @"FormatForDisplay\(\s*utc,\s*ViewerTimeHelper\.DisplayZoneFor\(ViewerTimeHelper\.CurrentDisplayMode,\s*Clock", display); Assert.Single(Regex.Matches(load, @"GetServerClocksAsync\(")); Assert.Contains("Clock = ViewerTimeHelper.ClockForServerOrMachine(clocks, row.ServerId, TimeZoneInfo.Local, nowUtc)", load); } @@ -465,8 +467,10 @@ public void AlertHistory_ReadsTheClocksOncePerLoad_AndStampsEachRow() var display = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "TimeLocal")); var load = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "GetAlertHistoryAsync")); - Assert.DoesNotContain("ForDisplay", display); - Assert.Contains("ConvertToDisplay(AlertTime, ViewerTimeHelper.CurrentDisplayMode, Clock", display); + /* The active-clock ForDisplay by its exact name: FormatForDisplay is the renderer that takes the row's zone (#4766). */ + Assert.DoesNotMatch(@"\bForDisplay\b", display); + Assert.Matches( + @"FormatForDisplay\(\s*AlertTime,\s*ViewerTimeHelper\.DisplayZoneFor\(ViewerTimeHelper\.CurrentDisplayMode,\s*Clock", display); /* One ask per load, not per row - and the ask goes to the fleet's clocks held for AlertClockLifetime (#4766), not to the store on every poll (ViewerAlertClockCacheTests pins the cache itself). */ Assert.Single(Regex.Matches(load, @"_alertClocks\.GetAsync\(")); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml index 8a9f0ba24f..8c8641e18c 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml @@ -63,7 +63,7 @@ Margin="10" ContextMenu="{StaticResource DataGridContextMenu}"> - + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml index 68b22c3028..3d03be261d 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -917,8 +917,8 @@ public partial class TimeRangeSlicerControl : UserControl { @@ -373,8 +375,12 @@ private Brush FindBrush(string key, string fallbackHex) private void UpdateRangeLabel() { if (_data.Count == 0) { RangeLabel.Text = ""; return; } - var startDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeStart)).ToString("yyyy-MM-dd HH:mm"); - var endDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeEnd)).ToString("yyyy-MM-dd HH:mm"); + /* #4766: worded as Lite's slicer words it (DisplayZone.Format, invariant culture), so the two instants of the + repeated autumn hour read "01:30 -04:00" and "01:30 -05:00" instead of both "01:30". The tick labels stay + the plain wall time. */ + var zone = ViewerTimeHelper.CurrentDisplayZone(); + var startDisplay = DisplayZone.Format(UtcAtNorm(_rangeStart), zone, "yyyy-MM-dd HH:mm"); + var endDisplay = DisplayZone.Format(UtcAtNorm(_rangeEnd), zone, "yyyy-MM-dd HH:mm"); var spanHours = (UtcAtNorm(_rangeEnd) - UtcAtNorm(_rangeStart)).TotalHours; var spanLabel = spanHours >= 1 ? $"{spanHours:F0}h" : $"{spanHours * 60:F0}m"; RangeLabel.Text = $"{startDisplay} → {endDisplay} ({spanLabel})"; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs index ce3e11ffb5..e1451dbf0f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs @@ -68,10 +68,13 @@ public sealed class ViewerAlertRow public ServerClock? Clock { get; init; } /// Stored naive-UTC; shown in the viewer's time display mode (Server/Local/UTC), Server on this row's - /// own server's clock (). - public string TimeLocal => - ViewerTimeHelper.ConvertToDisplay(AlertTime, ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock) - .ToString("yyyy-MM-dd HH:mm:ss"); + /// own server's clock (). An alert in the repeated hour after a fall-back reads the same wall + /// time in both of its occurrences, so it takes a space and its UTC offset ("2026-11-01 01:30:00 -05:00"), as + /// Lite's alert row and do for the other + /// grids (#4766). + public string TimeLocal => ViewerTimeHelper.FormatForDisplay( + AlertTime, ViewerTimeHelper.DisplayZoneFor(ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock), + "yyyy-MM-dd HH:mm:ss"); public string CurrentValueDisplay => AlertMetricClassifier.FormatHistoryValue(MetricName, CurrentValue); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs index 7ccffd52f4..06c77fd4a9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs @@ -63,7 +63,7 @@ public sealed class ViewerBlockedProcessRow : BlockedProcessAlertRow /// The stored naive-UTC event time in the viewer machine's local time (Lite's grid format). public string EventTimeLocal - => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : ""; + => EventTime is { } eventTime ? ViewerTimeHelper.FormatForDisplay(eventTime, "yyyy-MM-dd HH:mm:ss") : ""; /// Lite's wait-time rendering: sub-second in ms, else one-decimal seconds. public string WaitTimeFormatted => ViewerDataService.FormatWaitTime(WaitTimeMs); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs index 385d8b3196..01b69c639c 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs @@ -867,7 +867,7 @@ public class CollectionLogRow public string Status { get; set; } = ""; public string? ErrorMessage { get; set; } - public string CollectionTimeFormatted => ViewerTimeHelper.ForDisplay(CollectionTime).ToString("g"); + public string CollectionTimeFormatted => ViewerTimeHelper.FormatForDisplay(CollectionTime, "g"); public string DurationFormatted => DurationMs.HasValue ? (DurationMs.Value < 1000 ? $"{DurationMs.Value} ms" : $"{DurationMs.Value / 1000.0:F1} s") @@ -1027,15 +1027,15 @@ the two surfaces that have a rows_stored to count and a field to render it in. * : $"{AvgDurationMs / 1000:F1} s"; public string LastSuccessFormatted => LastSuccessTime.HasValue - ? ViewerTimeHelper.ForDisplay(LastSuccessTime.Value).ToString("g") + ? ViewerTimeHelper.FormatForDisplay(LastSuccessTime.Value, "g") : "Never"; public string LastRunFormatted => LastRunTime.HasValue - ? ViewerTimeHelper.ForDisplay(LastRunTime.Value).ToString("g") + ? ViewerTimeHelper.FormatForDisplay(LastRunTime.Value, "g") : "Never"; public string LastErrorFormatted => LastErrorTime.HasValue - ? ViewerTimeHelper.ForDisplay(LastErrorTime.Value).ToString("g") + ? ViewerTimeHelper.FormatForDisplay(LastErrorTime.Value, "g") : ""; /// @@ -1061,6 +1061,6 @@ public class CollectionCaveatRow public DateTime FirstSeenUtc { get; set; } public DateTime LastSeenUtc { get; set; } - public string FirstSeenFormatted => ViewerTimeHelper.ForDisplay(FirstSeenUtc).ToString("g"); - public string LastSeenFormatted => ViewerTimeHelper.ForDisplay(LastSeenUtc).ToString("g"); + public string FirstSeenFormatted => ViewerTimeHelper.FormatForDisplay(FirstSeenUtc, "g"); + public string LastSeenFormatted => ViewerTimeHelper.FormatForDisplay(LastSeenUtc, "g"); } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs index d8ca456910..80778337e7 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs @@ -262,7 +262,7 @@ public sealed class ServerConfigChangeRow(ConfigChangeDiff.ServerConfigChange ch public bool? IsDynamic => change.IsDynamic; public bool? IsAdvanced => change.IsAdvanced; - public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss"); public string DynamicDisplay => change.DynamicDisplay; public string AdvancedDisplay => change.AdvancedDisplay; public bool RequiresRestart => change.RequiresRestart; @@ -280,7 +280,7 @@ public sealed class DatabaseConfigChangeRow(ConfigChangeDiff.DatabaseConfigChang public string? OldValue => change.OldValue; public string? NewValue => change.NewValue; - public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss"); public string ChangeDescription => change.ChangeDescription; } @@ -298,7 +298,7 @@ public sealed class TraceFlagChangeRow(ConfigChangeDiff.TraceFlagChange change) /// enabled / disabled / modified (the set-diff outcome). public string ChangeType => change.ChangeType; - public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss"); public string PreviousStatusDisplay => change.PreviousStatusDisplay; public string NewStatusDisplay => change.NewStatusDisplay; public string GlobalDisplay => change.GlobalDisplay; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs index eb2c0ca899..548d14a711 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs @@ -62,7 +62,7 @@ public sealed class DeadlockProcessDetail : DeadlockProcessInfo /// deadlocks.deadlock_time is the XE @timestamp, so it is naive UTC and /// converts through . public string DeadlockTimeLocal - => DeadlockTime is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; + => DeadlockTime is { } t ? ViewerTimeHelper.FormatForDisplay(t, "yyyy-MM-dd HH:mm:ss") : ""; public string VictimDisplay => IsVictim ? "Victim" : ""; public string WaitTimeFormatted => WaitTime > 0 ? $"{WaitTime:N0} ms" : ""; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs index 34ff40a6e4..01ef445e37 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs @@ -363,6 +363,12 @@ FROM server_properties public async Task> GetServerInventoryAsync(CancellationToken cancellationToken = default) { + /* #4766: Server Inventory is one row per server, so each row reads its times on ITS server's clock (the + collected one, else the viewer machine's offset, the rule every list row uses) and not on the active server + tab's. The fleet's clocks are read once per load. */ + var clocks = await GetServerClocksAsync(null, cancellationToken); + var nowUtc = DateTime.UtcNow; + await using var command = _dataSource.CreateCommand(ServerInventorySql); command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds; @@ -377,9 +383,13 @@ public async Task> GetServerInventoryAsync(CancellationT ? $"{version} - {updateLevel}" : $"{version} - {level}"; + var serverId = reader.GetInt32(0); + var clock = ViewerTimeHelper.ClockForServerOrMachine(clocks, serverId, TimeZoneInfo.Local, nowUtc); + items.Add(new ServerPropertyRow { - ServerId = reader.GetInt32(0), + ServerId = serverId, + Clock = clock, ServerName = reader.IsDBNull(1) ? "" : reader.GetString(1), Edition = reader.IsDBNull(2) ? "" : reader.GetString(2), ProductVersion = versionDisplay, @@ -393,7 +403,10 @@ public async Task> GetServerInventoryAsync(CancellationT IsClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12), /* #2359: this is the CONFIG SNAPSHOT time, not a freshness heartbeat. Named for what it is so nobody reads a days-old value as a stale metric again. */ - InventoryAsOf = reader.IsDBNull(13) ? null : ViewerTimeHelper.ForDisplay(reader.GetDateTime(13)), + InventoryAsOf = reader.IsDBNull(13) ? null : ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(13), ViewerTimeHelper.CurrentDisplayMode, clock), + /* #4766: the UTC instants too (this one and LastCollected below), so the columns' text can name the + offset in the repeated autumn hour. */ + InventoryAsOfUtc = reader.IsDBNull(13) ? null : reader.GetDateTime(13), /* sqlserver_start_time is the server's LOCAL clock — read verbatim, shown as-is like Lite (UptimeDisplay = Now - start). host OS + AG role are the collected guarded values. */ SqlServerStartTime = reader.IsDBNull(14) ? null : reader.GetDateTime(14), @@ -404,7 +417,8 @@ is so nobody reads a days-old value as a stale metric again. */ as a stale metric rather than as the date monitoring stopped. */ IsEnabled = reader.IsDBNull(17) || reader.GetBoolean(17), MonthlyCost = reader.IsDBNull(18) ? 0m : Convert.ToDecimal(reader.GetValue(18)), - LastCollected = reader.IsDBNull(19) ? null : ViewerTimeHelper.ForDisplay(reader.GetDateTime(19)) + LastCollected = reader.IsDBNull(19) ? null : ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock), + LastCollectedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19) }); } return items; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs index 45c2f2f881..590d7343a3 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs @@ -299,6 +299,12 @@ public async Task> GetApplicationConnectionsAsync { var cutoff = DateTime.UtcNow.AddHours(-24); + /* #4766: the rows read their times on THIS server's clock (its collected one, else the viewer machine's offset, + the rule every list row uses), read once per load, and not on the active server tab's: the FinOps tab lists the + server it was opened for. */ + var clock = ViewerTimeHelper.ClockForServerOrMachine( + await GetServerClocksAsync(serverId, cancellationToken), serverId, TimeZoneInfo.Local, DateTime.UtcNow); + await using var command = _dataSource.CreateCommand(ApplicationConnectionsSql); command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds; command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); @@ -328,8 +334,12 @@ public async Task> GetApplicationConnectionsAsync AvgLogicalReads = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)), MaxLogicalReads = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)), SampleCount = reader.IsDBNull(17) ? 0 : Convert.ToInt64(reader.GetValue(17)), - FirstSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(18)), - LastSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(19)) + FirstSeenLocal = ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(18), ViewerTimeHelper.CurrentDisplayMode, clock), + LastSeenLocal = ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock), + /* #4766: the UTC instants too, so the columns' text can name the offset in the repeated autumn hour. */ + FirstSeenUtc = reader.GetDateTime(18), + LastSeenUtc = reader.GetDateTime(19), + Clock = clock }); } return items; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index a6bd560d5f..4159712f2f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Linq; +using PerformanceMonitor.Analysis.Baselines; namespace PerformanceMonitor.Darling.Viewer; @@ -164,6 +165,34 @@ public sealed class ApplicationConnectionRow public long SampleCount { get; set; } public DateTime FirstSeenLocal { get; set; } public DateTime LastSeenLocal { get; set; } + + /// + /// The UTC instants behind and (#4766). A converted wall + /// clock cannot say which of the two 01:30s of the repeated autumn hour it was, so the column text is worded from these. + /// + public DateTime FirstSeenUtc { get; set; } + public DateTime LastSeenUtc { get; set; } + + /// + /// The clock of the server these rows are for, stamped by the loader (#4766): its collected clock, else the viewer + /// machine's offset (), the rule every list row uses. The + /// FinOps tab loads the server it is opened for, which need not be the server tab that is active, so the times read + /// that server's wall time in Server mode. Null (a row built without one) falls back to the active server's. + /// + public ServerClock? Clock { get; set; } + + /// + /// What the First Seen and Last Seen columns show (#4766): the instant in the display mode on the row's server's clock, + /// with its UTC offset added in the repeated autumn hour + /// (). The columns bind these + /// and sort by and , so the order stays chronological. + /// + public string FirstSeenText => ViewerTimeHelper.FormatForDisplay(FirstSeenUtc, DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm"); + public string LastSeenText => ViewerTimeHelper.FormatForDisplay(LastSeenUtc, DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm"); + + /// The zone the current display mode names for 's server, else the active server's. + internal static TimeZoneInfo DisplayZoneNow(ServerClock? rowClock) => + ViewerTimeHelper.DisplayZoneFor(ViewerTimeHelper.CurrentDisplayMode, rowClock ?? ViewerTimeHelper.ActiveServerClock); } /// Per-file database size + growth config (Database Sizes sub-tab). @@ -242,12 +271,43 @@ public sealed class ServerPropertyRow /// public DateTime? InventoryAsOf { get; set; } + /// + /// The UTC instants behind and (#4766). A converted wall clock + /// cannot say which of the two 01:30s of the repeated autumn hour it was, so the column text is worded from these. + /// + public DateTime? InventoryAsOfUtc { get; set; } + + /// + /// What the Inventory As Of column shows (#4766): the instant in the display mode, with its UTC offset added in the + /// repeated autumn hour, and nothing for a server with no snapshot. The column binds this and sorts by + /// . + /// + public string InventoryAsOfText => InventoryAsOfUtc.HasValue + ? ViewerTimeHelper.FormatForDisplay(InventoryAsOfUtc.Value, ApplicationConnectionRow.DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm") + : ""; + + /// + /// The clock of THIS row's server, stamped by the loader (#4766): its collected clock, else the viewer machine's + /// offset (). Server Inventory is one row per server, so each + /// row's times read its own server's wall time in Server mode and not the active server tab's. Null (a row built + /// without one) falls back to the active server's. + /// + public ServerClock? Clock { get; set; } + /// /// The newest collection of ANY kind for this server — MAX(collection_time) across /// v_collection_log, the same signal list_servers and the Overview cards use (#2359). This is /// the real freshness heartbeat, and it moves every sweep. /// public DateTime? LastCollected { get; set; } + + /// The UTC instant behind ; see . + public DateTime? LastCollectedUtc { get; set; } + + /// What the Last Collected column shows (#4766); see . The column sorts by . + public string LastCollectedText => LastCollectedUtc.HasValue + ? ViewerTimeHelper.FormatForDisplay(LastCollectedUtc.Value, ApplicationConnectionRow.DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm") + : ""; public bool? IsHadrEnabled { get; set; } public bool? IsClustered { get; set; } public string AgReplicaRole { get; set; } = "Standalone"; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs index 08410d7990..ab47e357e4 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs @@ -25,8 +25,9 @@ namespace PerformanceMonitor.Darling.Viewer; /// long-runtime / retry flags for the grid's color-coding. run_datetime is the monitored server's LOCAL /// wall clock, so converts it to naive-UTC in C# with the /// server's before it reaches this row — exactly like the Default Trace reader -/// (#4766) — so renders through the same -/// as every other viewer grid and sorts consistently with them. +/// (#4766) — so sorts consistently with every other viewer grid and renders through +/// , as the plain wall time: a stored server-local time cannot say which pass +/// of the repeated autumn hour it was, so it never takes the offset suffix a real instant does. /// public sealed class ViewerJobHistoryRow { @@ -57,11 +58,19 @@ public sealed class ViewerJobHistoryRow public DateTime? LastSuccessfulRunUtc { get; init; } public bool IsLongRunning { get; init; } - /// Stored naive-UTC; shown in the viewer machine's local time (the viewer convention). + /// + /// in the current display mode, as the plain wall time and never with the repeated-hour + /// UTC offset that adds (#4766). That offset is only + /// true for a real instant, and this one was converted from a STORED server-local time: a wall time that happens + /// twice on the autumn change day reads as its first occurrence (), so a step that + /// ran at 01:30 the second time (-05:00) would print "-04:00", the other pass's offset. The bare text is what + /// the row's own wall clock said. + /// public string RunTimeLocal => RunDateTimeUtc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; + /// as the plain wall time, for the reason gives. public string LastSuccessfulRunLocal => LastSuccessfulRunUtc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : "Never"; @@ -557,6 +566,11 @@ public sealed class ViewerAgentStatusRow public string StatusDisplay => AgentRunning ? "Running" : (AgentStatusDesc ?? "Stopped"); + /// + /// as the plain wall time (#4766). It was converted from the server's stored wall + /// clock, which cannot say which pass of a repeated autumn hour it was, so it never takes the UTC offset that + /// adds for a real instant. + /// public string NextScheduledRunLocal => NextScheduledRunUtc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : "None scheduled"; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs index ee70f788ef..abe47529f0 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs @@ -45,7 +45,7 @@ public sealed class ViewerLongQueryRow public string ObjectName { get; set; } = ""; public string EventTimeLocal - => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : ""; + => EventTime is { } eventTime ? ViewerTimeHelper.FormatForDisplay(eventTime, "yyyy-MM-dd HH:mm:ss") : ""; public string DurationFormatted => FormatMicroseconds(DurationMicroseconds); public string CpuFormatted => FormatMicroseconds(CpuTimeMicroseconds); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs index 224daa122f..62071868c4 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs @@ -238,7 +238,15 @@ public sealed class NotificationRouteRow /* Grid display members. */ public string MatchKindDisplay => Family is null ? "Exact metric" : "Family"; public string ChannelsDisplay => ConfiguredChannels.Count == 0 ? "—" : string.Join(", ", ConfiguredChannels); - public string ModifiedDisplay => ModifiedAtUtc == default ? "" : ModifiedAtUtc.ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture); + + /// + /// When the route was last saved, in the viewer's display mode (#4766). is the store's + /// modified_at, written as now() AT TIME ZONE 'UTC', so it is one UTC instant, and it reads as the + /// other grids' times do: Server, Local or UTC per the mode, with the UTC offset added in the repeated autumn + /// hour (). This used to be the machine's local + /// time in every mode. Empty for a route that has not been stored yet. + /// + public string ModifiedDisplay => ModifiedAtUtc == default ? "" : ViewerTimeHelper.FormatForDisplay(ModifiedAtUtc, "g"); public NotificationRouteRow Clone() => new() { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs index d6cb99d8c0..103cd5bfa0 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs @@ -1026,7 +1026,7 @@ public string ThreadsDisplay /// was collected, and none of it is retained, which is what the row says. /// public string LastCollectionDisplay => LastCollectionTime.HasValue - ? ViewerTimeHelper.ForDisplay(LastCollectionTime.Value).ToString("HH:mm:ss") + ? ViewerTimeHelper.FormatForDisplay(LastCollectionTime.Value, "HH:mm:ss") : IsOnline == false ? "None retained" : "Never"; /* Collection status. The (IsOnline, CollectionStale, AwaitingFirstCollection) triple is resolved by diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs index 21bcb6058f..151676d9a5 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs @@ -226,7 +226,7 @@ against the collector-written UTC collection_time rather than inferred from Plan /// A naive-UTC stamp in the display mode. NOT for a server-clock column — every one of /// those in this row uses instead. internal static string Local(DateTime? naiveUtc) - => naiveUtc is { } utc ? ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm:ss") : ""; + => naiveUtc is { } utc ? ViewerTimeHelper.FormatForDisplay(utc, "yyyy-MM-dd HH:mm:ss") : ""; private static string YesNo(bool? value) => value is bool flag ? (flag ? "Yes" : "No") : ""; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs index 03842a7c2b..eaeac7ebf0 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs @@ -213,7 +213,7 @@ public static List BuildPostgresCollectorHealth( StoreTable = definition.TargetTable, Status = status, LastRun = facts?.LastRunAt is { } at - ? ViewerTimeHelper.ForDisplay(at).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture) + ? ViewerTimeHelper.FormatForDisplay(at, "yyyy-MM-dd HH:mm") : "", Runs = facts?.Runs ?? 0, FailedRuns = facts?.FailedRuns ?? 0, @@ -357,7 +357,7 @@ public async Task> GetPgCpuUtilizationHistoryAsync( .OrderByDescending(s => s.SampleTimeUtc) .Select(s => new PgCpuUtilizationRow { - Time = ViewerTimeHelper.ForDisplay(s.SampleTimeUtc).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture), + Time = ViewerTimeHelper.FormatForDisplay(s.SampleTimeUtc, "yyyy-MM-dd HH:mm"), CpuPercent = s.CpuPercent, }) .ToList(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs index 5b7a332fec..c820bd39a6 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs @@ -81,7 +81,7 @@ set only when WaitDrillDownWindow walks blocking chains — the transitive block public bool HasQueryPlan { get; set; } public bool HasLiveQueryPlan { get; set; } public string CollectionTimeLocal => - CollectionTime == DateTime.MinValue ? "" : ViewerTimeHelper.ForDisplay(CollectionTime).ToString("yyyy-MM-dd HH:mm:ss"); + CollectionTime == DateTime.MinValue ? "" : ViewerTimeHelper.FormatForDisplay(CollectionTime, "yyyy-MM-dd HH:mm:ss"); /// The transaction begin time, empty when the request has no open transaction. /// transaction_begin_time from sys.dm_tran_active_transactions is a SQL-server-local wall-clock time (NOT diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs index cd1abd78f4..dcda1a674e 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs @@ -769,12 +769,13 @@ public static string FormatServerClock(DateTime? serverLocal) /// for instead — query_store_stats' first- and last-execution /// times, which Query Store returns as datetimeoffset and QueryStoreCollector normalises /// through DateTimeOffset.UtcDateTime. Inlining - /// ViewerTimeHelper.ForDisplay(x).ToString(...) would work identically; a named method beside its - /// opposite is what makes the choice reviewable. + /// ViewerTimeHelper.FormatForDisplay(x, ...) would work identically; a named method beside its + /// opposite is what makes the choice reviewable. A time in the repeated autumn hour carries its UTC offset + /// (). /// public static string FormatStoredUtc(DateTime? naiveUtc) => naiveUtc.HasValue - ? ViewerTimeHelper.ForDisplay(naiveUtc.Value).ToString("yyyy-MM-dd HH:mm:ss") + ? ViewerTimeHelper.FormatForDisplay(naiveUtc.Value, "yyyy-MM-dd HH:mm:ss") : ""; /// diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs index cf3a44305b..19961a10a0 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs @@ -94,6 +94,17 @@ public sealed class QueryStoreClutterRow /// column sorts chronologically rather than by the text of a formatted stamp. public DateTime? OptionsCaptured { get; init; } + /// The UTC instant behind (#4766). A converted wall clock cannot say which + /// of the two 01:30s of the repeated autumn hour it was, so the column text is worded from this. + public DateTime? OptionsCapturedUtc { get; init; } + + /// What the Options Captured column shows (#4766): the instant in the display mode, with its UTC offset + /// added in the repeated autumn hour. Null when the verdict read no options row, so the binding's + /// TargetNullValue still draws the em-dash. The column binds this and sorts by . + public string? OptionsCapturedText => OptionsCapturedUtc.HasValue + ? ViewerTimeHelper.FormatForDisplay(OptionsCapturedUtc.Value, "yyyy-MM-dd HH:mm") + : null; + /// The composition's prose for this row's reasons, one sentence per reason — the same /// sentences the MCP payload and the web panel carry. Shown as the row's tooltip and in the /// detail pane under the grid. @@ -132,6 +143,13 @@ public sealed class QueryStoreOverheadWaitRow public long WaitingTasks { get; init; } public long MeasuredSeconds { get; init; } public DateTime LastObserved { get; init; } + + /// The UTC instant behind (#4766); the column text is worded from it. + public DateTime LastObservedUtc { get; init; } + + /// What the Last Seen column shows (#4766): the instant in the display mode, with its UTC offset added in the + /// repeated autumn hour. The column binds this and sorts by . + public string LastObservedText => ViewerTimeHelper.FormatForDisplay(LastObservedUtc, "yyyy-MM-dd HH:mm"); } /// Both halves of one read: the per-database rows and the one per-server overhead block. @@ -206,6 +224,7 @@ private static QueryStoreClutterRow Project(QueryStoreClutter.DatabaseClutter ro StaleQueryThresholdDays = c?.StaleQueryThresholdDays, PctOfCap = c is null ? null : Round(QueryStoreClutter.PctOfCap(c), 1), OptionsCaptured = c is null ? null : ViewerTimeHelper.ForDisplay(c.CapturedAt), + OptionsCapturedUtc = c?.CapturedAt, Recommendation = string.Join(" ", QueryStoreClutter.Recommendations(row)), }; } @@ -225,6 +244,7 @@ private static QueryStoreOverheadSummary ProjectOverhead( WaitingTasks = w.WaitingTasksTotal, MeasuredSeconds = w.MeasuredSeconds, LastObserved = ViewerTimeHelper.ForDisplay(w.LastObserved), + LastObservedUtc = w.LastObserved, }) .ToList(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs index 32adedec92..21bd9695c2 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs @@ -32,15 +32,31 @@ namespace PerformanceMonitor.Darling.Viewer; * The row view-models below are flat projections of the Common records (all shredded columns preserved, * so the grids faithfully match sp_HealthParser's per-category table shape) plus an EventTimeLocal * display string — the machine-local render of the event's naive-UTC XE @timestamp, via the same - * ViewerTimeHelper.ForDisplay the deadlock / blocked-process grids use. Only SevereError adds a - * resolved DatabaseName (see ResolveDatabaseName). + * ViewerTimeHelper.FormatForDisplay the deadlock / blocked-process grids use (a Default Trace row is a + * stored server wall clock instead, so it renders the bare wall time through + * SystemEventRowFormat.StoredWallClock and never takes the repeated-hour offset, #4766). Only SevereError + * adds a resolved DatabaseName (see ResolveDatabaseName). */ /// Shared machine-local render of a naive-UTC event timestamp for the System Events grids. internal static class SystemEventRowFormat { + /// + /// A time converted from a STORED server wall clock (the Default Trace StartTime), as the plain wall time + /// (#4766). maps both passes of a repeated local hour to the first, so the + /// instant cannot say which pass the event was in; appending the offset would print the first pass's for an + /// event that ran in the second. Every other time reads as does. Declared before + /// on purpose: the wrapper census reads each wrapper's body as the text that follows its + /// signature, and a renderer named in the next member would be read as this one's. + /// + public static string StoredWallClock(DateTime? utc) => + utc.HasValue ? ViewerTimeHelper.ForDisplay(utc.Value).ToString("yyyy-MM-dd HH:mm:ss") : ""; + + /// A REAL instant (the system_health XE @timestamp is UTC): the text + /// words, so the two passes of the repeated autumn + /// hour differ by their UTC offsets. public static string Local(DateTime? utc) => - utc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; + utc is { } t ? ViewerTimeHelper.FormatForDisplay(t, "yyyy-MM-dd HH:mm:ss") : ""; } /// One scheduler-monitor utilization sample (Scheduler Issues sub-tab), flagged the way sp_HealthParser flags this section: SQL CPU pinned, other-process CPU high, or memory utilization low. @@ -208,9 +224,10 @@ public sealed class IoIssuesRow(IoIssuesRecord record) /// it to naive-UTC in C# with the server's (its time zone where SQL Server reports /// one, else the collected server_properties.utc_offset_minutes, else UTC) BEFORE it reaches this row, /// so is the event's real UTC time on either side of a daylight-saving change and -/// renders through the same as every -/// other System Events grid and sorts consistently with them (the inverse of the MCP reader's bounds -/// conversion; mirrors CorrelatedTimelineLanesControl's CPU-lane de-skew) (#4766). +/// sorts consistently with every other System Events grid (the inverse of the MCP +/// reader's bounds conversion; mirrors CorrelatedTimelineLanesControl's CPU-lane de-skew) (#4766). It renders through +/// , not : the wall time is +/// stored, so an event in the repeated autumn hour cannot say which pass it was in and never takes an offset. /// public sealed class DefaultTraceEventRow { @@ -231,7 +248,7 @@ public DefaultTraceEventRow( string? textData) { EventTimeUtc = eventTimeUtc; - EventTimeLocal = SystemEventRowFormat.Local(eventTimeUtc); + EventTimeLocal = SystemEventRowFormat.StoredWallClock(eventTimeUtc); Category = category.ToString(); EventName = eventName; DatabaseName = databaseName; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs index 27b70c801b..17dcabb4ec 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs @@ -32,7 +32,7 @@ internal static class HistoryTime { /// Formats a naive-UTC collection timestamp in the current display mode (Server / Local / UTC). public static string CollectionLocal(DateTime collectionTimeUtc) - => ViewerTimeHelper.ForDisplay(collectionTimeUtc).ToString("yyyy-MM-dd HH:mm:ss"); + => ViewerTimeHelper.FormatForDisplay(collectionTimeUtc, "yyyy-MM-dd HH:mm:ss"); } /// One collected query_stats snapshot for a single (database, query_hash) — Lite's diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs index e510d74085..1a29354d43 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs @@ -121,7 +121,7 @@ internal static string Milliseconds(long ms) internal static string Timestamp(DateTime? utc) => utc is { } value - ? ViewerTimeHelper.ForDisplay(value).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture) + ? ViewerTimeHelper.FormatForDisplay(value, "yyyy-MM-dd HH:mm") : string.Empty; /// A percentage of a total, or the dash when the total is zero — which is "nothing happened", diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs index afbff2e0a5..31e1fc8a10 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs @@ -185,7 +185,7 @@ private async void LatestSnapshot_Click(object sender, RoutedEventArgs e) var (batchTime, rows) = await _dataService.GetLatestQuerySnapshotBatchAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter); _querySnapshotsFilterMgr!.UpdateData(rows); LatestSnapshotIndicator.Text = batchTime.HasValue - ? $"Latest snapshot: {ViewerTimeHelper.ForDisplay(batchTime.Value):yyyy-MM-dd HH:mm:ss}" + ? $"Latest snapshot: {ViewerTimeHelper.FormatForDisplay(batchTime.Value, "yyyy-MM-dd HH:mm:ss")}" : "No snapshots stored"; } catch (Exception ex) @@ -318,7 +318,7 @@ internal async Task DeepLinkToActiveQueriesAsync(DateTime fromUtc, DateTime toUt await RefreshServerClockAsync(); ApplyServerClockToHelper(); - var indicator = $"Finding window: {ViewerTimeHelper.ForDisplay(fromUtc):yyyy-MM-dd HH:mm} → {ViewerTimeHelper.ForDisplay(toUtc):HH:mm}"; + var indicator = $"Finding window: {ViewerTimeHelper.FormatForDisplay(fromUtc, "yyyy-MM-dd HH:mm")} → {ViewerTimeHelper.FormatForDisplay(toUtc, "HH:mm")}"; _pendingActiveQueriesWindow = (fromUtc, toUtc, indicator); await NavigateToActiveQueriesForWindowAsync(fromUtc, toUtc, indicator); } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs index b8a3fef132..b9223df4b0 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs @@ -81,20 +81,23 @@ internal static IReadOnlyList ChartMenuHeaderOrder(string? drillDownLabe return items; } - /* The chart-data CSV shape: DateTime (),Series,Value (Lite's ContextMenuHelper CSV export verbatim). + /* The chart-data CSV shape: DateTime (),Series,Value,UTC offset. The first three cells are Lite's + ContextMenuHelper CSV export verbatim; the fourth names each row's instant exactly (#4766). Pure so the tests pin the header + row format without a WpfPlot. */ /// - /// The chart-data CSV header line ("DateTime (<zone Id>){sep}Series{sep}Value", #4766). The time column names - /// , the zone writes each time in ("DateTime (UTC)", - /// "DateTime (Eastern Standard Time)"), so a file opened later still says which clock its times are on. The name is - /// quoted when it holds the separator, as any other cell would be. + /// The chart-data CSV header line ("DateTime (<zone Id>){sep}Series{sep}Value{sep}UTC offset", #4766). The + /// time column names , the zone writes each time in + /// ("DateTime (UTC)", "DateTime (Eastern Standard Time)"), so a file opened later still says which clock its times + /// are on. The name is quoted when it holds the separator, as any other cell would be. The last column holds the + /// UTC offset each row's time was written at. /// internal static string ChartCsvHeaderLine(string separator, TimeZoneInfo zone) => - string.Join(separator, new[] { CsvEscape($"DateTime ({zone.Id})", separator), "Series", "Value" }); + string.Join(separator, new[] { CsvEscape($"DateTime ({zone.Id})", separator), "Series", "Value", "UTC offset" }); - /// One chart-data CSV row: an invariant yyyy-MM-dd HH:mm:ss timestamp, the escaped series - /// name, and the invariant value (Lite's exact row shape). + /// The first three cells of a chart-data CSV row: an invariant yyyy-MM-dd HH:mm:ss timestamp, + /// the escaped series name, and the invariant value (Lite's exact row shape). + /// adds the UTC offset cell. internal static string FormatChartCsvLine(DateTime dateTime, string series, double value, string separator) => string.Join(separator, new[] { @@ -108,11 +111,16 @@ internal static string FormatChartCsvLine(DateTime dateTime, string series, doub /// date, and the file shows it in , the zone the chart's own tick, hover and crosshair /// labels use, so the CSV reads as the chart does. An instant in a repeated hour reads as the same wall time /// each time it comes round (06:30Z on a US Eastern autumn change day is the second 01:30), as the chart's tick - /// labels do; the file has no offset column to tell the two apart. Pure, so a test names the zone instead of - /// reading the display mode. + /// labels do; the fourth cell, , tells the two apart (-04:00 for the first + /// 01:30, -05:00 for the second). It is written on every row, so the time column keeps one plain date-and-time + /// format from the first row to the last. Pure, so a test names the zone instead of reading the display mode. /// - internal static string ChartCsvDataLine(double plottedX, string series, double value, string separator, TimeZoneInfo zone) => - FormatChartCsvLine(DisplayZone.ToDisplay(DateTime.FromOADate(plottedX), zone), series, value, separator); + internal static string ChartCsvDataLine(double plottedX, string series, double value, string separator, TimeZoneInfo zone) + { + var instant = DateTime.FromOADate(plottedX); + return FormatChartCsvLine(DisplayZone.ToDisplay(instant, zone), series, value, separator) + + separator + DisplayZone.UtcOffsetText(instant, zone); + } /// RFC-4180 CSV quoting (Lite's ContextMenuHelper.CsvEscape). internal static string CsvEscape(string value, string separator) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs index f503c5e6eb..83ba14b234 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs @@ -67,7 +67,7 @@ rather than attempting an enqueue that would throw. */ { case ActiveQueriesLiveStatus.Fetched: CurrentActiveQueriesGrid.ItemsSource = result.Rows; - var stamp = ViewerTimeHelper.ForDisplay(DateTime.UtcNow).ToString("yyyy-MM-dd HH:mm:ss"); + var stamp = ViewerTimeHelper.FormatForDisplay(DateTime.UtcNow, "yyyy-MM-dd HH:mm:ss"); CurrentActiveQueriesStatus.Text = result.Rows.Count == 0 ? $"No user requests running as of {stamp}." : $"{result.Rows.Count} running request(s) as of {stamp}."; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs index a0bc9ff1e8..100ecd2841 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs @@ -223,8 +223,8 @@ ordinary read (M1) — null tells the gate this end is open. */ /// sits past 's slack after /// , collapsed otherwise. The chart-title idiom /// () states the same fact on Performance Trends; this is its grid-header - /// form, in 's own yyyy-MM-dd HH:mm, the format every trend - /// chart title already uses for a head timestamp. + /// form, in 's yyyy-MM-dd HH:mm, the format + /// every trend chart title already uses for a head timestamp. /// internal static void UpdateTruncationBanner(TextBlock banner, DateTime? floor, DateTime requestedStartUtc, string? tierSuffix = null, QueryStoreIntervalWide.WideReadPlan? widePlan = null) @@ -241,13 +241,13 @@ named whether or not the grid itself was cut. */ if (wideTruncated || slicerTruncated || !string.IsNullOrEmpty(tierSuffix)) { var slicerSince = slicerTruncated - ? ViewerTimeHelper.ForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc)).ToString("yyyy-MM-dd HH:mm") + ? ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm") : null; var text = wideTruncated - ? $"Showing since {ViewerTimeHelper.ForDisplay(wideStart):yyyy-MM-dd HH:mm}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}" + ? $"Showing since {ViewerTimeHelper.FormatForDisplay(wideStart, "yyyy-MM-dd HH:mm")}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}" + (slicerSince is null ? string.Empty : $" · slicer since {slicerSince}") : slicerSince is null - ? $"Showing {ViewerTimeHelper.ForDisplay(requestedStartUtc):yyyy-MM-dd HH:mm}" + ? $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}" : $"Slicer since {slicerSince} (the grid shows the full window)"; banner.Text = text + tierSuffix; @@ -272,8 +272,8 @@ named whether or not the grid itself was cut. */ } banner.Text = truncated - ? $"Showing since {ViewerTimeHelper.ForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc)):yyyy-MM-dd HH:mm}{tierSuffix}" - : $"Showing {ViewerTimeHelper.ForDisplay(requestedStartUtc):yyyy-MM-dd HH:mm}{tierSuffix}"; + ? $"Showing since {ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm")}{tierSuffix}" + : $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}{tierSuffix}"; banner.Visibility = Visibility.Visible; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs index 3fb41bb91a..ee0e9c68fc 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs @@ -93,8 +93,8 @@ private void SetComparisonMode( if (active && baselineRange.HasValue) { - var from = ViewerTimeHelper.ForDisplay(baselineRange.Value.From).ToString("yyyy-MM-dd HH:mm"); - var to = ViewerTimeHelper.ForDisplay(baselineRange.Value.To).ToString("yyyy-MM-dd HH:mm"); + var from = ViewerTimeHelper.FormatForDisplay(baselineRange.Value.From, "yyyy-MM-dd HH:mm"); + var to = ViewerTimeHelper.FormatForDisplay(baselineRange.Value.To, "yyyy-MM-dd HH:mm"); banner.Text = $"Comparing against baseline: {from} → {to}"; } } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs index 249e8a2e4a..c5d1c573e3 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs @@ -114,17 +114,21 @@ rather than drawing nothing. */ /// /// Draws the window's baseline discontinuities on one trend chart (#3653 A5): a dashed vertical line per - /// marker at its UTC instant, legend-named with the shared sentence. Called after the + /// marker at its UTC instant, legend-named with the shared sentence, which adds the instant's UTC offset when + /// its wall time happens twice in the display zone (the repeated autumn hour, #4766). Called after the /// series is added and before the chart's axis limits and legend are set, as /// asks. /// private static void MarkDiscontinuities(ScottPlot.WPF.WpfPlot chart, IReadOnlyList discontinuities) { + var zone = ViewerTimeHelper.CurrentDisplayZone(); foreach (var discontinuity in discontinuities) { - /* The marker sits at the UTC instant like every other X on the chart; the sentence names it in the display zone. */ + /* The marker sits at the UTC instant like every other X on the chart; the sentence names it in the display zone, + with that instant's UTC offset when the wall time repeats. */ var shown = ViewerTimeHelper.ForDisplay(discontinuity.At); - ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), BaselineDiscontinuities.Sentence(shown, discontinuity.Reason)); + ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), + BaselineDiscontinuities.Sentence(shown, discontinuity.Reason, DisplayZone.AmbiguousOffsetSuffix(discontinuity.At, zone))); } } @@ -177,7 +181,7 @@ private static string DescribeBucketWidth(int minutes) internal static string DescribeQueryStoreTrendCoverage(QueryStoreTrendSeries series) => ComposeTrendCoverage( series.Route.UseRollup - ? $"{series.Source} (one point per hour before {ViewerTimeHelper.ForDisplay(series.Route.RawStartUtc):yyyy-MM-dd HH:mm}, one per Query Store interval from it)" + ? $"{series.Source} (one point per hour before {ViewerTimeHelper.FormatForDisplay(series.Route.RawStartUtc, "yyyy-MM-dd HH:mm")}, one per Query Store interval from it)" : $"{series.Source} (one point per Query Store interval)", series.HeadUnserved ? series.EffectiveStartUtc : null, "the corrected Query Store rollup has not materialized the rest of this window (--backfill-rollups reaches it)"); @@ -196,8 +200,8 @@ private static string ComposeTrendCoverage(string served, DateTime? dataBeginsUt return $"Source: {served}"; } - var from = ViewerTimeHelper.ForDisplay(head); - return $"Source: {served} — data begins {from:yyyy-MM-dd HH:mm}; {whyUnserved}"; + var from = ViewerTimeHelper.FormatForDisplay(head, "yyyy-MM-dd HH:mm"); + return $"Source: {served} — data begins {from}; {whyUnserved}"; } /// Puts on a chart as its title, coloured like its tick diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index 4e944b11b8..a69e7965f3 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -709,7 +709,7 @@ public static DateTime ForDisplay(DateTime naiveUtc) => ConvertToDisplay(naiveUtc, CurrentDisplayMode, _serverClock); + /// + /// as TEXT in the current display mode (#4766): formatted with + /// on the current culture, then a space and the instant's UTC offset when the wall time + /// happens twice in that zone (), so the two rows of a grid that + /// both read 01:30 in the repeated autumn hour differ by "-04:00" and "-05:00". Every other time is the text + /// ForDisplay(x).ToString(format) gave, and UTC never gets a suffix. The twin of Lite's + /// ServerTimeHelper.FormatServerTime: the renderer for text that shows one instant. A label that is parsed + /// back or used as a key or file name stays on , as do axis and tick labels and the + /// time-range slicer's, which want the plain wall time. + /// + public static string FormatForDisplay(DateTime naiveUtc, string format) => + FormatForDisplay(naiveUtc, CurrentDisplayZone(), format); + + /// + /// as TEXT in (#4766): applied to + /// the instant's wall clock there on the current culture, then a space and the instant's UTC offset when that + /// wall time happens twice in (). The one + /// place the viewer's text takes the suffix, and the twin of Lite's ServerTimeHelper.FormatInstant: + /// asks it for the current display zone, and a row that + /// converts on its own server's clock (an alert row, a Manage Servers row) asks it for that zone + /// ( on the row's ), so no list words the rule again. + /// + public static string FormatForDisplay(DateTime naiveUtc, TimeZoneInfo zone, string format) + { + var text = DisplayZone.ToDisplay(naiveUtc, zone).ToString(format); + var suffix = DisplayZone.AmbiguousOffsetSuffix(naiveUtc, zone); + return suffix is null ? text : text + " " + suffix; + } + + /// + /// for a value that may be missing: an empty string for + /// null, so a grid cell with no time shows nothing. + /// + public static string FormatForDisplay(DateTime? naiveUtc, string format) => + naiveUtc.HasValue ? FormatForDisplay(naiveUtc.Value, format) : ""; + /// /// Pure (static-free) naive-UTC → display conversion for an explicit mode + fixed offset: the same as /// the overload with a clock that never changes offset. diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml index c153f22f04..9217f1c4e5 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml @@ -84,7 +84,7 @@ private void MarkDiscontinuities(ScottPlot.WPF.WpfPlot chart, IReadOnlyList discontinuities) { + var zone = GetPickerZone(); foreach (var discontinuity in discontinuities) { - var shown = DisplayZone.ToDisplay(discontinuity.At, GetPickerZone()); - ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), BaselineDiscontinuities.Sentence(shown, discontinuity.Reason)); + var shown = DisplayZone.ToDisplay(discontinuity.At, zone); + ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), + BaselineDiscontinuities.Sentence(shown, discontinuity.Reason, DisplayZone.AmbiguousOffsetSuffix(discontinuity.At, zone))); } } diff --git a/Lite/Controls/ServerTab.Grids.cs b/Lite/Controls/ServerTab.Grids.cs index 4b3f2b1fe9..935ebf2f8f 100644 --- a/Lite/Controls/ServerTab.Grids.cs +++ b/Lite/Controls/ServerTab.Grids.cs @@ -253,7 +253,7 @@ private void CollectionHealthGrid_MouseDoubleClick(object sender, System.Windows { if (CollectionHealthGrid.SelectedItem is not CollectorHealthRow item) return; - var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName); + var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName, _serverClock); window.Owner = Window.GetWindow(this); window.ShowDialog(); } diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index 523c00a6c3..4ac2865046 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -821,6 +821,13 @@ private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hours await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask); + /* #4766: every row reads its time on THIS tab's server clock, not on whichever clock is active when the + grid renders. Both grids sit in this server's own tab, so the two are the same while the tab is showing, + and the stamped one also holds when the display mode flips or another tab is selected first. */ + var tabClock = _serverClock; + foreach (var row in collectionHealthTask.Result) row.Clock = tabClock; + foreach (var row in collectionLogTask.Result) row.Clock = tabClock; + _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate); diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 568adb00f2..abdec9f46f 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -382,7 +382,7 @@