diff --git a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs index 9c51a6306c..425f3bdfa8 100644 --- a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs +++ b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs @@ -7,14 +7,18 @@ */ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Globalization; +using System.IO; using System.Linq; +using System.Net; using System.Reflection; using System.Text.RegularExpressions; using System.Windows.Data; using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Notifications; using PerformanceMonitor.Ui; using Xunit; using static Darling.Tests.RepoFile; @@ -31,9 +35,17 @@ namespace Darling.Tests; /// 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. +/// Every column that shows a time as text sorts by the row's UTC DateTime where it has one. A row with none +/// (the System Events rows, the PostgreSQL panels) gets a get-only DateTime member for the value its text is formatted +/// from. A value that only ever existed as the monitored server's own wall clock (the dm_exec_* stamps of the query and +/// procedure stats, Agent's running-job start, a transaction's begin time, the default trace) sorts by the row's +/// DateTime for that value, and the rows below marked as a stored wall clock are those. Such a value can misorder inside +/// the repeated autumn hour, because the wall time occurs twice and the clock cannot say which pass a stamp belongs to; +/// it still orders by time, where the text ordered by its characters. +/// +/// The scan at the end reads every DataGrid column in the viewer's XAML and fails on a column that shows a time as +/// text with no SortMemberPath, or one that names its own text, so a column added later cannot slip back to sorting by +/// its text. /// /* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */ [Collection("viewer-time-statics")] @@ -83,6 +95,103 @@ public sealed class ViewerGridTimeColumnSortMemberTests { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "LastExecutionTime" }, { "ManageServersWindow.xaml", "ServersGrid", "LastCollectedDisplay", Name(typeof(ManagedServerListItem)), "LastCollectedUtc" }, { "NotificationRoutesWindow.xaml", "RoutesGrid", "ModifiedDisplay", Name(typeof(NotificationRouteRow)), "ModifiedAtUtc" }, + + /* Columns whose row already had a UTC (or display-zone) DateTime and only lacked the SortMemberPath. */ + { "AlertsHistoryTab.xaml", "AlertsDataGrid", "TimeLocal", Name(typeof(ViewerAlertRow)), "AlertTime" }, + { "JobHistoryTab.xaml", "JobHistoryDataGrid", "RunTimeLocal", Name(typeof(ViewerJobHistoryRow)), "RunDateTimeUtc" }, + { "JobHistoryTab.xaml", "JobHistoryDataGrid", "LastSuccessfulRunLocal", Name(typeof(ViewerJobHistoryRow)), "LastSuccessfulRunUtc" }, + { "MuteRulesWindow.xaml", "RulesGrid", "ExpiresDisplay", Name(typeof(MuteRule)), "ExpiresAtUtc" }, + { "ProcedureHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(ViewerProcedureStatsHistoryRow)), "CollectionTime" }, + { "QueryStatsHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(ViewerQueryStatsHistoryRow)), "CollectionTime" }, + { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "LongQueryCompletionsGrid", "EventTimeLocal", Name(typeof(ViewerLongQueryRow)), "EventTime" }, + + /* The System Events rows carry the raw naive-UTC XE @timestamp as EventTime (the same member Lite's rows carry). */ + { "ViewerServerTab.xaml", "SchedulerIssuesGrid", "EventTimeLocal", Name(typeof(SchedulerIssueRow)), "EventTime" }, + { "ViewerServerTab.xaml", "SevereErrorsGrid", "EventTimeLocal", Name(typeof(SevereErrorRow)), "EventTime" }, + { "ViewerServerTab.xaml", "MemoryConditionsGrid", "EventTimeLocal", Name(typeof(MemoryConditionsRow)), "EventTime" }, + { "ViewerServerTab.xaml", "MemoryBrokerGrid", "EventTimeLocal", Name(typeof(MemoryBrokerRow)), "EventTime" }, + { "ViewerServerTab.xaml", "MemoryNodeOomGrid", "EventTimeLocal", Name(typeof(MemoryNodeOomRow)), "EventTime" }, + { "ViewerServerTab.xaml", "SignificantWaitsGrid", "EventTimeLocal", Name(typeof(SignificantWaitRow)), "EventTime" }, + { "ViewerServerTab.xaml", "CpuTasksGrid", "EventTimeLocal", Name(typeof(CpuTasksRow)), "EventTime" }, + { "ViewerServerTab.xaml", "IoIssuesGrid", "EventTimeLocal", Name(typeof(IoIssuesRow)), "EventTime" }, + + /* A stored wall clock: the default trace's StartTime is the server's own clock, converted to an instant with the + server's clock, so the two passes of a repeated local hour share one instant. Sorted by that value. */ + { "ViewerServerTab.xaml", "DefaultTraceGrid", "EventTimeLocal", Name(typeof(DefaultTraceEventRow)), "EventTimeUtc" }, + + /* Stored wall clocks: sys.dm_exec_* stamps, Agent's start_execution_date and a transaction's begin time are the + monitored server's own local clock and have no UTC member. Each sorts by the row's DateTime for that value. */ + { "ViewerServerTab.xaml", "QueryStatsGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStatsRow)), "LastExecutionTime" }, + { "ViewerServerTab.xaml", "QueryStatsGrid", "CreationTimeLocal", Name(typeof(ViewerQueryStatsRow)), "CreationTime" }, + { "ViewerServerTab.xaml", "ProcedureStatsGrid", "LastExecutionTimeLocal", Name(typeof(ViewerProcedureStatsRow)), "LastExecutionTime" }, + { "ViewerServerTab.xaml", "ProcedureStatsGrid", "CachedTimeFormatted", Name(typeof(ViewerProcedureStatsRow)), "CachedTime" }, + { "ViewerServerTab.xaml", "RunningJobsGrid", "StartTimeLocal", Name(typeof(RunningJobRow)), "StartTime" }, + { "ProcedureHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerProcedureStatsHistoryRow)), "LastExecutionTime" }, + { "ProcedureHistoryWindow.xaml", "HistoryDataGrid", "CachedTimeLocal", Name(typeof(ViewerProcedureStatsHistoryRow)), "CachedTime" }, + { "QueryStatsHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStatsHistoryRow)), "LastExecutionTime" }, + { "QueryStatsHistoryWindow.xaml", "HistoryDataGrid", "CreationTimeLocal", Name(typeof(ViewerQueryStatsHistoryRow)), "CreationTime" }, + { "WaitDrillDownWindow.xaml", "ResultsDataGrid", "TranStartTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "TranStartTime" }, + + /* A calendar day the row keeps as a DateTime; the text is "ddd MM/dd", which sorts by the weekday's name. */ + { "FinOpsTab.xaml", "FinOpsProvisioningTrendGrid", "DayDisplay", Name(typeof(ProvisioningTrendRow)), "Day" }, + { "FinOpsTab.xaml", "FinOpsMemoryGrantEfficiencyDataGrid", "DayDisplay", Name(typeof(MemoryGrantEfficiencyRow)), "Day" }, + + /* The PostgreSQL panels: every timestamp is naive UTC in the store, and each display row now carries the UTC + instant its text is formatted from. */ + { "ViewerServerTab.xaml", "PgCollectorHealthGrid", "LastRun", Name(typeof(ViewerDataService.PostgresCollectorHealthRow)), "LastRunUtc" }, + { "ViewerServerTab.xaml", "PgCpuGrid", "Time", Name(typeof(ViewerDataService.PgCpuUtilizationRow)), "SampleTimeUtc" }, + { "ViewerServerTab.xaml", "PgBlockingChainsGrid", "CapturedAt", Name(typeof(PgDisplay.ChainRow)), "CapturedAtUtc" }, + { "ViewerServerTab.xaml", "PgBlockingCyclesGrid", "CapturedAt", Name(typeof(PgDisplay.CycleRow)), "CapturedAtUtc" }, + { "ViewerServerTab.xaml", "PgSessionStatesGrid", "FirstSeenAt", Name(typeof(PgDisplay.SessionStateRow)), "FirstSeenAtUtc" }, + { "ViewerServerTab.xaml", "PgSessionStatesGrid", "LastSeenAt", Name(typeof(PgDisplay.SessionStateRow)), "LastSeenAtUtc" }, + { "ViewerServerTab.xaml", "PgAutovacuumGrid", "LastAutovacuum", Name(typeof(PgDisplay.AutovacuumRow)), "LastAutovacuumUtc" }, + { "ViewerServerTab.xaml", "PgAutovacuumGrid", "LastVacuum", Name(typeof(PgDisplay.AutovacuumRow)), "LastVacuumUtc" }, + { "ViewerServerTab.xaml", "PgAutovacuumGrid", "LastAutoanalyze", Name(typeof(PgDisplay.AutovacuumRow)), "LastAutoanalyzeUtc" }, + { "ViewerServerTab.xaml", "PgAutovacuumGrid", "LastAnalyze", Name(typeof(PgDisplay.AutovacuumRow)), "LastAnalyzeUtc" }, + { "ViewerServerTab.xaml", "PgAutovacuumGrid", "MeasuredAt", Name(typeof(PgDisplay.AutovacuumRow)), "MeasuredAtUtc" }, + { "ViewerServerTab.xaml", "PgXminHorizonGrid", "MeasuredAt", Name(typeof(PgDisplay.XminRow)), "MeasuredAtUtc" }, + { "ViewerServerTab.xaml", "PgWraparoundGrid", "MeasuredAt", Name(typeof(PgDisplay.WraparoundRow)), "MeasuredAtUtc" }, + { "ViewerServerTab.xaml", "PgReplicationSlotsGrid", "InactiveSince", Name(typeof(PgDisplay.SlotRow)), "InactiveSinceUtc" }, + { "ViewerServerTab.xaml", "PgReplicationSlotsGrid", "MeasuredAt", Name(typeof(PgDisplay.SlotRow)), "MeasuredAtUtc" }, + { "ViewerServerTab.xaml", "PgIndexUsageGrid", "LastScan", Name(typeof(PgDisplay.IndexUsageRow)), "LastScanUtc" }, + { "ViewerServerTab.xaml", "PgDatabaseStatsGrid", "StatsReset", Name(typeof(PgDisplay.DatabaseRow)), "StatsResetUtc" }, + { "ViewerServerTab.xaml", "PgIoStatsGrid", "StatsReset", Name(typeof(PgDisplay.IoRow)), "StatsResetUtc" }, + + /* The five grids that used to bind the shared reader's row, and so showed the raw UTC DateTime in every display mode + (#4766): each now binds a display row that words the time from the mode and keeps the instant in its Utc member. */ + { "ViewerServerTab.xaml", "PgLockStatsGrid", "LastSeen", Name(typeof(PgDisplay.LockStatRow)), "LastSeenUtc" }, + { "ViewerServerTab.xaml", "PgWaitSamplingGrid", "CaptureTime", Name(typeof(PgDisplay.WaitSamplingRow)), "CaptureTimeUtc" }, + { "ViewerServerTab.xaml", "PgColumnStatsGrid", "CaptureTime", Name(typeof(PgDisplay.ColumnStatRow)), "CaptureTimeUtc" }, + { "ViewerServerTab.xaml", "PgReplicationStatsGrid", "BackendStart", Name(typeof(PgDisplay.ReplicationStatRow)), "BackendStartUtc" }, + { "ViewerServerTab.xaml", "PgIndexBloatGrid", "MeasuredAt", Name(typeof(PgDisplay.IndexBloatRow)), "MeasuredAtUtc" }, + { "ViewerServerTab.xaml", "PgIndexBloatGrid", "EstimatedAt", Name(typeof(PgDisplay.IndexBloatRow)), "EstimatedAtUtc" }, + }; + + /// + /// Columns whose header or bound property reads as a time to the scan but are not one, each with the reason. + /// A key that matches no column in the XAML fails the scan, so an entry cannot outlive its column. + /// + private static readonly Dictionary<(string File, string Grid, string Property), string> NotTimes = new() + { + [("CollectionLogWindow.xaml", "LogDataGrid", "RowsCollected")] = "a row count", + [("ViewerServerTab.xaml", "QuerySnapshotsGrid", "ElapsedTimeFormatted")] = "a duration, sorted by its millisecond count", + [("ViewerServerTab.xaml", "CurrentActiveQueriesGrid", "ElapsedTimeFormatted")] = "a duration, sorted by its millisecond count", + [("WaitDrillDownWindow.xaml", "ResultsDataGrid", "ElapsedTimeFormatted")] = "a duration, sorted by its millisecond count", + [("ViewerServerTab.xaml", "BlockedProcessReportGrid", "WaitTimeFormatted")] = "a wait duration, sorted by its millisecond count", + [("ViewerServerTab.xaml", "PgCollectorHealthGrid", "RowsCollected")] = "a row count", + [("ViewerServerTab.xaml", "PgBlockingChainsGrid", "SamplesAsRoot")] = "a sample count", + [("ViewerServerTab.xaml", "PgDeadlocksGrid", "TimesSeen")] = "a count of sightings", + [("ViewerServerTab.xaml", "PgLogEventsGrid", "TimesSeen")] = "a count of sightings", + [("ViewerServerTab.xaml", "PgLockStatsGrid", "Captures")] = "a count of captures", + [("ViewerServerTab.xaml", "PgReplicationStatsGrid", "Samples")] = "a count of samples", + [("ViewerServerTab.xaml", "PgKernelStatsGrid", "CounterReset")] = "a yes/no flag that the counters were reset, not when", + [("ViewerServerTab.xaml", "PgWaitSamplingGrid", "CounterReset")] = "a yes/no flag that the counters were reset, not when", + [("ViewerServerTab.xaml", "PgAutovacuumGrid", "InsertsSinceVacuum")] = "a row count", + [("ViewerServerTab.xaml", "PgAutovacuumGrid", "ModsSinceAnalyze")] = "a row count", + [("ViewerServerTab.xaml", "PgPlanCaptureGrid", "Observed")] = "the setting value the readiness check observed, not a time", + [("ViewerServerTab.xaml", "PgIndexBloatGrid", "EstimatedReclaimableBytes")] = "a byte count", + [("ViewerServerTab.xaml", "PgIndexBloatGrid", "SkippedReason")] = "the reason a measurement was skipped, text", }; /// @@ -173,6 +282,144 @@ private static string[] Sorted(ApplicationConnectionRow[] rows, string sortMembe return view.Cast().Select(r => r.ApplicationName).ToArray(); } + /// + /// Every DataGrid column in the viewer's XAML that shows a time as text sorts by a DateTime, not by that text + /// (#4766). A column counts as a time when its header or its bound property reads as one (), + /// unless the header carries a unit (a duration or a size) or it binds a DateTime through a StringFormat, which + /// sorts as the value it formats. What is left must either sit in with a SortMemberPath that + /// the reflection check above resolves to a DateTime of its row, or be named in with the reason + /// it is not a time. A column that binds a DateTime with no text in between is not let through: it prints the raw UTC + /// value in every display mode, which is the defect the display rows exist to remove, so it fails here too. + /// A column added later that does none of these fails here with its file, its header and the fix. + /// + [Fact] + public void EveryTimeColumnInTheViewer_SortsByADateTime_NotByItsText() + { + var pinned = Columns + .Select(row => ((Xunit.ITheoryDataRow)row).GetData()) + .Select(d => (File: (string)d![0]!, Grid: (string)d[1]!, Property: (string)d[2]!)) + .ToHashSet(); + + var failures = new List(); + var seen = new HashSet<(string File, string Grid, string Property)>(); + var viewerRoot = PathTo("Darling", ViewerFolder); + foreach (var path in Directory.EnumerateFiles(viewerRoot, "*.xaml", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal)) + { + if (path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + continue; + } + + var file = Path.GetRelativePath(viewerRoot, path).Replace('\\', '/'); + foreach (var column in ReadColumns(file)) + { + var key = (column.File, column.Grid, column.BoundProperty); + seen.Add(key); + if (!ReadsAsATime(column) || column.BoundPropertyHasStringFormat || NotTimes.ContainsKey(key)) + { + continue; + } + + var label = $"{column.File}: the {column.Grid} column '{column.Header}' (bound to {column.BoundProperty})"; + var declared = column.SortMemberPath; + if (declared.Length == 0) + { + failures.Add($"{label} shows a time as text and has no SortMemberPath, so it sorts by that text. " + + "Fix: set SortMemberPath to the row's UTC DateTime member (else the row's own DateTime for that value, " + + "else add a get-only DateTime property the text is formatted from) and add the column to Columns."); + } + else if (declared == column.BoundProperty || Regex.IsMatch(declared, "(?:Local|Display|Text|Formatted)$")) + { + failures.Add($"{label} sorts by {declared}, which is text. Fix: sort by the row's DateTime member instead."); + } + else if (!pinned.Contains(key)) + { + failures.Add($"{label} sorts by {declared}, which nothing checks. Fix: add the column to Columns so the row type resolves {declared} to a DateTime."); + } + } + } + + foreach (var entry in NotTimes.Keys.Where(k => !seen.Contains(k))) + { + failures.Add($"{entry.File}: the {entry.Grid} column bound to {entry.Property} is listed as a non-time but is not in the XAML. Fix: remove the entry."); + } + + Assert.True(failures.Count == 0, Environment.NewLine + string.Join(Environment.NewLine, failures)); + } + + /// The words that make a header or a bound property name read as a time. Tuned against every column in the viewer. + private static readonly Regex TimeWords = new( + @"\b(?:time|date|when|seen|expires?|expiry|created|creation|modified|collected|captured|observed|started|start|ended|since|measured|estimated|reset|day|cached" + + @"|as of|(?:last|first) (?:run|success|scan|vacuum|autovacuum|analyze|autoanalyze|execution|refresh|access|cleanup end)|(?:executed|reverted|updated|last error) at)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// A header that carries a unit is a measure (a duration, a size, a rate), not a time of day. + private static readonly Regex UnitInHeader = new(@"\((?:ms|s|sec|secs|us|min|mins|kb|mb|gb|kb/s|%)\)|\bms\b|%", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static bool ReadsAsATime(XamlColumn column) + { + if (UnitInHeader.IsMatch(column.Header)) + { + return false; + } + + var propertyWords = Regex.Replace(column.BoundProperty, "(?<=[a-z0-9])(?=[A-Z])", " "); + return TimeWords.IsMatch(column.Header) || TimeWords.IsMatch(propertyWords); + } + + /// One DataGrid column of the viewer's XAML, as the scan sees it. + private sealed record XamlColumn(string File, string Grid, string Header, string BoundProperty, bool BoundPropertyHasStringFormat, string SortMemberPath); + + private static readonly Regex GridStart = new(@"""])*>", RegexOptions.CultureInvariant); + + private static readonly Regex ColumnStart = new(@"<(?DataGrid\w*Column)(?![\w.])(?:""[^""]*""|[^>""])*>", RegexOptions.CultureInvariant); + + private static string Attribute(string startTag, string name) => + Regex.Match(startTag, @"\s" + name + @"=""(?[^""]*)""").Groups["v"].Value; + + /// Every DataGrid column in the file: its grid, header text, bound property and SortMemberPath. + private static List ReadColumns(string xamlFile) + { + var xaml = ReadRepoFile("Darling", ViewerFolder, xamlFile); + var grids = GridStart.Matches(xaml) + .Select(m => (m.Index, Name: Attribute(m.Value, "x:Name") is { Length: > 0 } name ? name : "(unnamed)")) + .ToList(); + + var columns = new List(); + foreach (Match start in ColumnStart.Matches(xaml)) + { + var body = ""; + if (!start.Value.EndsWith("/>", StringComparison.Ordinal)) + { + var end = xaml.IndexOf("", start.Index, StringComparison.Ordinal); + body = end < 0 ? "" : xaml[(start.Index + start.Length)..end]; + } + + var header = Attribute(start.Value, "Header"); + if (header.Length == 0) + { + header = Regex.Match(body, @"]*?\sText=""(?[^""]*)""").Groups["t"].Value; + } + + var binding = Attribute(start.Value, "Binding"); + if (binding.Length == 0) + { + binding = Regex.Match(body, @"\{Binding\s+[A-Za-z_][^}]*\}").Value; + } + + columns.Add(new XamlColumn( + xamlFile, + grids.LastOrDefault(g => g.Index < start.Index).Name ?? "(none)", + WebUtility.HtmlDecode(header), + Regex.Match(binding, @"\{Binding\s+(?:Path=)?(?

[A-Za-z_][\w.]*)").Groups["p"].Value, + binding.Contains("StringFormat", StringComparison.Ordinal), + Attribute(start.Value, "SortMemberPath"))); + } + + return columns; + } + ///

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) { diff --git a/Darling/Darling.Tests/ViewerPostgresGridTimeTextTests.cs b/Darling/Darling.Tests/ViewerPostgresGridTimeTextTests.cs new file mode 100644 index 0000000000..117bb39cb8 --- /dev/null +++ b/Darling/Darling.Tests/ViewerPostgresGridTimeTextTests.cs @@ -0,0 +1,438 @@ +/* + * 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.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4766: the PostgreSQL Locks, Wait Sampling, Replication, Column Statistics and Index Bloat grids bound the shared +/// reader's row as it came, so each of their time columns showed the raw UTC DateTime in every display mode, while every +/// other time on the tab follows the mode and words the offset of the repeated autumn hour. Each grid now binds a +/// row that words the time from the mode and keeps the instant beside it in a ...Utc member, +/// which the column sorts by ( pins the XAML side). +/// +/// 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). +/// The rest of each row is pinned to be the reader row's, unchanged: the same name and type for every column the grid +/// binds, so the grid's other bindings, its sort keys and its filters did not have to move. +/// +/* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */ +[Collection("viewer-time-statics")] +public sealed class ViewerPostgresGridTimeTextTests +{ + 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); + + private static readonly DateTime FirstPass = Naive(2026, 11, 1, 5, 30); + + private static readonly DateTime SecondPass = Naive(2026, 11, 1, 6, 30); + + /* 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; + } + } + + /// + /// The text and the UTC member a display row gives for an instant, in each mode: Server mode on a US Eastern clock reads + /// the two 01:30s of the repeated hour apart by their offset, UTC mode reads the stored instant, and the UTC member + /// holds the instant itself in both. + /// + private static void AssertFollowsTheDisplayMode(Func project) + { + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + Assert.Equal("2026-11-01 01:30 -04:00", project(FirstPass).Text); + Assert.Equal("2026-11-01 01:30 -05:00", project(SecondPass).Text); + Assert.Equal(FirstPass, project(FirstPass).Utc); + Assert.Equal(SecondPass, project(SecondPass).Utc); + }); + + WithDisplay(TimeDisplayMode.UTC, Eastern, () => + { + Assert.Equal("2026-11-01 05:30", project(FirstPass).Text); + Assert.Equal("2026-11-01 06:30", project(SecondPass).Text); + Assert.Equal(FirstPass, project(FirstPass).Utc); + Assert.Equal(SecondPass, project(SecondPass).Utc); + }); + } + + private static DarlingPgLockStatsReader.PgLockStatRow LockReader(DateTime lastSeen) => new( + DatabaseName: "orders", + LockType: "relation", + Mode: "AccessExclusiveLock", + Granted: false, + RelationOid: 16_384, + RelationName: "orders", + Captures: 7, + TotalCaptures: 60, + MaxBackends: 3, + MaxWaitMs: 1250.5, + LastSeen: lastSeen); + + private static DarlingPgWaitSamplingReader.PgWaitSamplingRow WaitReader(DateTime captureTime) => new( + EventType: "Lock", + Event: "relation", + QueryId: 4_611_686_018_427_387_904, + SampleCount: 42, + EstimatedWaitMs: 4_200, + BackendCount: 5, + CounterReset: true, + CaptureTime: captureTime); + + private static DarlingPgReplicationStatsReader.PgReplicationStatRow ReplicationReader(DateTime? backendStart) => new( + ApplicationName: "walreceiver", + ClientAddr: "10.0.0.7", + State: "streaming", + SyncState: "async", + SentBytesBehind: 1_024, + ReplayBytesBehind: 8_192, + WorstReplayBytesBehind: 65_536, + ReplayLagMs: 12.5, + WorstReplayLagMs: 480.25, + Samples: 58, + TotalSamples: 60, + BackendStart: backendStart, + LastSeen: Naive(2026, 11, 1, 8)); + + private static DarlingPgColumnStatsReader.PgColumnStatRow ColumnReader(DateTime captureTime) => new( + DatabaseName: "orders", + SchemaName: "public", + TableName: "line_items", + ColumnName: "status", + NDistinct: -0.5, + NullFrac: 0.125, + AvgWidth: 12, + Correlation: 0.75, + TopValueFrequency: 0.5, + CommonValueCount: 9, + CaptureTime: captureTime); + + /// An index bloat row of the given kind (measured or estimated), or a skipped one when it is given a reason. + private static DarlingPgIndexBloatReader.PgIndexBloatRow BloatReader(DateTime captureTime, string? kind, string? skippedReason = null) => new( + DatabaseName: "orders", + SchemaName: "public", + TableName: "line_items", + IndexName: "ix_line_items_status", + IndexBytes: 1_048_576, + TreeLevel: 2, + EmptyPages: 4, + DeletedPages: 1, + AvgLeafDensity: 71.5, + LeafFragmentation: 12.25, + EstimatedReclaimableBytes: 262_144, + SkippedReason: skippedReason, + MeasurementKind: kind, + EstBloatPct: 18.5, + IndexPages: 128, + TableRows: 90_000, + Fillfactor: 90, + EstTupleBytes: 24, + EstLeafPages: 100, + PgstattupleAvailable: true, + CaptureTime: captureTime); + + // ── The time, in the display mode ──────────────────────────────────────────────────────────── + + [Fact] + public void ALockRow_ReadsItsLastSeenInTheDisplayMode_AndKeepsTheInstant() + { + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.LockStat(LockReader(at)); + return (row.LastSeen, row.LastSeenUtc); + }); + + var shown = PgDisplay.LockStat(LockReader(FirstPass)); + Assert.Equal("AccessExclusiveLock", shown.Mode); + Assert.False(shown.Granted); + } + + [Fact] + public void AWaitSamplingRow_ReadsItsLastSeenInTheDisplayMode_AndKeepsTheInstant() + { + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.WaitSampling(WaitReader(at)); + return (row.CaptureTime, row.CaptureTimeUtc); + }); + + var shown = PgDisplay.WaitSampling(WaitReader(FirstPass)); + Assert.Equal(4_611_686_018_427_387_904, shown.QueryId); + Assert.True(shown.CounterReset); + } + + [Fact] + public void AReplicationRow_ReadsItsConnectedSinceInTheDisplayMode_AndKeepsTheInstant() + { + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.ReplicationStat(ReplicationReader(at)); + return (row.BackendStart, row.BackendStartUtc); + }); + + var shown = PgDisplay.ReplicationStat(ReplicationReader(FirstPass)); + Assert.Equal("walreceiver", shown.ApplicationName); + Assert.Equal(65_536L, shown.WorstReplayBytesBehind); + } + + [Fact] + public void AColumnStatRow_ReadsItsCapturedInTheDisplayMode_AndKeepsTheInstant() + { + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.ColumnStat(ColumnReader(at)); + return (row.CaptureTime, row.CaptureTimeUtc); + }); + + var shown = PgDisplay.ColumnStat(ColumnReader(FirstPass)); + Assert.Equal("status", shown.ColumnName); + Assert.Equal(0.5, shown.TopValueFrequency); + } + + [Fact] + public void AnIndexBloatRow_ReadsItsMeasuredAndEstimatedInTheDisplayMode_AndKeepsTheInstants() + { + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.IndexBloat(BloatReader(at, "measured")); + return (row.MeasuredAt, row.MeasuredAtUtc); + }); + + AssertFollowsTheDisplayMode(at => + { + var row = PgDisplay.IndexBloat(BloatReader(at, "estimated")); + return (row.EstimatedAt, row.EstimatedAtUtc); + }); + + var shown = PgDisplay.IndexBloat(BloatReader(FirstPass, "estimated")); + Assert.Equal("ix_line_items_status", shown.IndexName); + Assert.Equal(262_144L, shown.EstimatedReclaimableBytes); + } + + /// + /// A time the reader row does not have shows nothing, as the DateTime column did: a standby with no recorded start, and + /// an index bloat row that is not a measurement (or not an estimate) or that carries a reason instead of an answer. Which + /// of the two an index bloat row carries is the reader row's own rule, and the display row takes it from there. + /// + [Fact] + public void ATimeTheReaderRowDoesNotHave_ShowsNothing_AndHasNoInstant() + { + WithDisplay(TimeDisplayMode.ServerTime, Eastern, () => + { + var noStart = PgDisplay.ReplicationStat(ReplicationReader(null)); + Assert.Equal("", noStart.BackendStart); + Assert.Null(noStart.BackendStartUtc); + + var measured = PgDisplay.IndexBloat(BloatReader(FirstPass, "measured")); + Assert.Equal("2026-11-01 01:30 -04:00", measured.MeasuredAt); + Assert.Equal(FirstPass, measured.MeasuredAtUtc); + Assert.Equal("", measured.EstimatedAt); + Assert.Null(measured.EstimatedAtUtc); + + var estimated = PgDisplay.IndexBloat(BloatReader(SecondPass, "estimated")); + Assert.Equal("", estimated.MeasuredAt); + Assert.Null(estimated.MeasuredAtUtc); + Assert.Equal("2026-11-01 01:30 -05:00", estimated.EstimatedAt); + Assert.Equal(SecondPass, estimated.EstimatedAtUtc); + + var skipped = PgDisplay.IndexBloat(BloatReader(FirstPass, "estimated", skippedReason: "no pg_stats grant")); + Assert.Equal("", skipped.MeasuredAt); + Assert.Null(skipped.MeasuredAtUtc); + Assert.Equal("", skipped.EstimatedAt); + Assert.Null(skipped.EstimatedAtUtc); + Assert.Equal("no pg_stats grant", skipped.SkippedReason); + }); + } + + // ── The rest of each row is the reader row's ───────────────────────────────────────────────── + + /// The five grids, by x:Name; the tables below are keyed by it. + public static TheoryData Grids => new() + { + "PgLockStatsGrid", + "PgWaitSamplingGrid", + "PgReplicationStatsGrid", + "PgColumnStatsGrid", + "PgIndexBloatGrid", + }; + + /// + /// A reader row, the display row the grid is given for it, and the columns that became text. The reader rows are the + /// same as above, at an instant that is not the repeated hour, so no test here depends on the clock. + /// + private static (object Reader, object Display, Type DisplayType, string[] TimeColumns) Pair(string grid) + { + var at = Naive(2026, 11, 1, 8, 15); + switch (grid) + { + case "PgLockStatsGrid": + var lockReader = LockReader(at); + return (lockReader, PgDisplay.LockStat(lockReader), typeof(PgDisplay.LockStatRow), new[] { "LastSeen" }); + case "PgWaitSamplingGrid": + var waitReader = WaitReader(at); + return (waitReader, PgDisplay.WaitSampling(waitReader), typeof(PgDisplay.WaitSamplingRow), new[] { "CaptureTime" }); + case "PgReplicationStatsGrid": + var replicationReader = ReplicationReader(at); + return (replicationReader, PgDisplay.ReplicationStat(replicationReader), typeof(PgDisplay.ReplicationStatRow), new[] { "BackendStart" }); + case "PgColumnStatsGrid": + var columnReader = ColumnReader(at); + return (columnReader, PgDisplay.ColumnStat(columnReader), typeof(PgDisplay.ColumnStatRow), new[] { "CaptureTime" }); + case "PgIndexBloatGrid": + var bloatReader = BloatReader(at, "measured"); + return (bloatReader, PgDisplay.IndexBloat(bloatReader), typeof(PgDisplay.IndexBloatRow), new[] { "MeasuredAt", "EstimatedAt" }); + default: + throw new ArgumentOutOfRangeException(nameof(grid), grid, "not one of the five grids"); + } + } + + /// + /// Every property a display row has, other than a time it words as text and that time's UTC member, is the reader row's + /// property of the same name and type, carrying the same value. A column left off the display row, renamed on it or given + /// another type would move a binding, a sort key or a filter of the grid without anything failing to build. + /// + [Theory] + [MemberData(nameof(Grids))] + public void ADisplayRow_CarriesEveryOtherColumn_UnderTheReaderRowsNameAndType(string grid) + { + var (reader, display, displayType, timeColumns) = Pair(grid); + var readerType = reader.GetType(); + + var carried = 0; + foreach (var property in displayType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (timeColumns.Contains(property.Name, StringComparer.Ordinal)) + { + Assert.Equal(typeof(string), property.PropertyType); + continue; + } + + if (property.Name.EndsWith("Utc", StringComparison.Ordinal) && timeColumns.Contains(property.Name[..^3], StringComparer.Ordinal)) + { + /* The instant the text is formatted from: the reader's own value, with its type (DateTime stays DateTime, + DateTime? stays DateTime?), so nothing about the time is invented on the way. */ + var source = readerType.GetProperty(property.Name[..^3], BindingFlags.Public | BindingFlags.Instance); + Assert.True(source != null, $"{readerType.Name} has no {property.Name[..^3]} for {displayType.Name}.{property.Name} to hold."); + Assert.Equal(source!.PropertyType, property.PropertyType); + Assert.Equal(source.GetValue(reader), property.GetValue(display)); + continue; + } + + var counterpart = readerType.GetProperty(property.Name, BindingFlags.Public | BindingFlags.Instance); + Assert.True(counterpart != null, $"{displayType.Name}.{property.Name} is not a property of {readerType.Name}."); + Assert.Equal(counterpart!.PropertyType, property.PropertyType); + Assert.Equal(counterpart.GetValue(reader), property.GetValue(display)); + carried++; + } + + Assert.True(carried >= 7, $"{displayType.Name} carries only {carried} of the reader row's columns; the grid binds more than that."); + } + + /// + /// Every property the grid's XAML binds, in a column or a cell template, is a public property of the row the grid is + /// given. The grid never sees a missing property: WPF logs the binding error and shows an empty cell. + /// + [Theory] + [MemberData(nameof(Grids))] + public void EveryBindingOfTheGrid_ResolvesOnItsDisplayRow(string grid) + { + var (_, _, displayType, _) = Pair(grid); + var xaml = ReadRepoFile("Darling", ViewerFolder, "ViewerServerTab.xaml"); + + var start = Regex.Match(xaml, @"]*?x:Name=""" + Regex.Escape(grid) + @""""); + Assert.True(start.Success, $"the grid {grid} is not in ViewerServerTab.xaml."); + var end = xaml.IndexOf("", start.Index, StringComparison.Ordinal); + Assert.True(end > start.Index, $"the end of the grid {grid} was not found."); + + var bound = Regex.Matches(xaml[start.Index..end], @"\{Binding\s+(?:Path=)?(?

[A-Za-z_][\w.]*)") + .Select(m => m.Groups["p"].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + Assert.True(bound.Count >= 8, $"{grid} binds only {bound.Count} properties; the scan is not reading the grid."); + + var missing = bound + .Where(name => displayType.GetProperty(name, BindingFlags.Public | BindingFlags.Instance) == null) + .ToList(); + Assert.True(missing.Count == 0, $"{grid} binds {string.Join(", ", missing)}, which {displayType.Name} does not have."); + } + + // ── The grid is given the display rows ─────────────────────────────────────────────────────── + + ///

+ /// The grid is handed the display rows and not the reader's. The viewer is WPF and cannot be run here, so this is a + /// source pin on the one line that sets each grid's items: a grid put back on the reader's rows would print the raw UTC + /// DateTime again, and every check above would still pass. + /// + [Theory] + [InlineData("PgLockStatsGrid", "LockStatRows")] + [InlineData("PgWaitSamplingGrid", "WaitSamplingRows")] + [InlineData("PgReplicationStatsGrid", "ReplicationStatRows")] + [InlineData("PgColumnStatsGrid", "ColumnStatRows")] + [InlineData("PgIndexBloatGrid", "IndexBloatRows")] + public void TheGrid_IsGivenTheDisplayRows_NotTheReadersRows(string grid, string projection) + { + var source = ReadRepoFile("Darling", ViewerFolder, "ViewerServerTab.Postgres.cs"); + + Assert.Contains($"{grid}.ItemsSource = PgDisplay.{projection}(rows);", source, StringComparison.Ordinal); + Assert.DoesNotContain($"{grid}.ItemsSource = rows;", source, StringComparison.Ordinal); + } + + /// The projection of a list keeps every row, in the reader's order. + [Fact] + public void TheListProjections_KeepEveryRow_InTheReadersOrder() + { + var locks = new[] { LockReader(SecondPass), LockReader(FirstPass) }; + Assert.Equal(new[] { SecondPass, FirstPass }, PgDisplay.LockStatRows(locks).Select(r => r.LastSeenUtc)); + + var waits = new[] { WaitReader(SecondPass), WaitReader(FirstPass) }; + Assert.Equal(new[] { SecondPass, FirstPass }, PgDisplay.WaitSamplingRows(waits).Select(r => r.CaptureTimeUtc)); + + var standbys = new[] { ReplicationReader(SecondPass), ReplicationReader(null), ReplicationReader(FirstPass) }; + Assert.Equal(new DateTime?[] { SecondPass, null, FirstPass }, PgDisplay.ReplicationStatRows(standbys).Select(r => r.BackendStartUtc)); + + var columns = new[] { ColumnReader(SecondPass), ColumnReader(FirstPass) }; + Assert.Equal(new[] { SecondPass, FirstPass }, PgDisplay.ColumnStatRows(columns).Select(r => r.CaptureTimeUtc)); + + var indexes = new[] { BloatReader(SecondPass, "measured"), BloatReader(FirstPass, "estimated") }; + Assert.Equal(new DateTime?[] { SecondPass, null }, PgDisplay.IndexBloatRows(indexes).Select(r => r.MeasuredAtUtc)); + Assert.Equal(new DateTime?[] { null, FirstPass }, PgDisplay.IndexBloatRows(indexes).Select(r => r.EstimatedAtUtc)); + + Assert.Empty(PgDisplay.LockStatRows(new List())); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml index 3d03be261d..f355360416 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -407,7 +407,7 @@ - + - +