diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
index 1da0e3426..0fe3207f0 100644
--- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
+++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
@@ -2248,11 +2248,14 @@ public void TheDailySummary_PublishesOneBandSpelling_OnBothSkus()
///
/// Every literal LIMIT n (n > 1) that ENDS a reader statement on either SKU — the shape
- /// #3541 A3 and #3659 found behind tools that advertised limit. Seven remain, all in Lite's
- /// service layer, none behind a tool that takes a limit: six are viewer-only reads and one
+ /// #3541 A3 and #3659 found behind tools that advertised limit. Six remain, all in Lite's
+ /// service layer, none behind a tool that takes a limit: five are viewer-only reads and one
/// (GetPlanCacheSnapshotAsync, behind get_plan_cache_bloat, which takes no cap) is a
/// ceiling of 30 over a population of a dozen cache types. LIMIT 1 is the latest-row idiom and is
- /// not a page. Pinned so a new terminal literal has to say what it is, and so the Lite twin of
+ /// not a page. The Long Queries grid's read (GetRecentLongQueryCompletionsAsync) left the roster in
+ /// #4989: its cap is the named constant LongQueryGridCap, written into the statement as
+ /// LIMIT {LongQueryGridCap}, so no literal ends it, and the grid's "Showing since" notice reads the same
+ /// constant. Pinned so a new terminal literal has to say what it is, and so the Lite twin of
/// has a
/// population to shrink.
///
@@ -2260,7 +2263,6 @@ public static readonly (string File, string Member, int Limit)[] StatementTermin
[
("LocalDataService.Blocking.cs", "GetBlockingPairRowsAsync", 5000),
("LocalDataService.FinOps.Recommendations.cs", "GetRecommendationsAsync", 10),
- ("LocalDataService.LongQueries.cs", "GetRecentLongQueryCompletionsAsync", 200),
("LocalDataService.PlanCache.cs", "GetPlanCacheSnapshotAsync", 30),
("LocalDataService.RunningJobs.cs", "GetAnomalousJobsAsync", 5),
("LocalDataService.WaitStats.cs", "GetAllQuerySnapshotsInRangeAsync", 2000),
diff --git a/Lite.Tests/CollectorDurationHoverTests.cs b/Lite.Tests/CollectorDurationHoverTests.cs
new file mode 100644
index 000000000..d95c873e7
--- /dev/null
+++ b/Lite.Tests/CollectorDurationHoverTests.cs
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Darling.Tests;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4989: what the Duration Trends hover says over a line with a collection gap in it, and what the chart does with its
+/// hover and its Y axis label when it is redrawn. The line breaks at every gap by inserting a point of its own (a NaN
+/// between the two buckets it separates), so a hover line counted by the point's position in the scatter falls one bucket
+/// behind after each break: it said the NEXT bucket's runs for every bucket after the first break and nothing for the
+/// last. The hover lines are keyed by the X each bucket is drawn at, so a bucket finds its own and the inserted point finds
+/// none. Each test draws the series on a real chart, renders it so the chart knows its pixels, and asks the hover what it
+/// says with the mouse over a bucket's point.
+///
+public class CollectorDurationHoverTests
+{
+ private static readonly DateTime Start = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// One collector's twenty buckets, ten minutes apart, with a seventy-minute hole after the fifth: the line breaks there
+ /// once. Bucket k holds k + 1 runs that averaged 50 + k ms, and its slowest took 100 + 3k ms.
+ ///
+ private static List TwentyBucketsWithOneBreak() =>
+ Enumerable.Range(0, 20).Select(k => new CollectorDurationBucket
+ {
+ CollectorName = "wait_stats",
+ BucketStart = Start.AddMinutes(10 * k + (k >= 5 ? 60 : 0)),
+ MaxDurationMs = 100 + 3 * k,
+ AverageDurationMs = 50 + k,
+ RunCount = k + 1,
+ }).ToList();
+
+ /// The hover text over each of the line's twenty points in turn (the mouse exactly on the point), in bucket order.
+ private static List HoverOverEachBucket(List buckets) =>
+ OnStaThread(() =>
+ {
+ var chart = new ScottPlot.WPF.WpfPlot();
+ var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc);
+ var line = Assert.Single(ServerTab.BuildCollectorDurationSeries(buckets));
+ ServerTab.PlotCollectorDurationSeries(chart, hover, new[] { line });
+ chart.Plot.Axes.AutoScale();
+ using var render = chart.Plot.GetImage(1000, 400);
+
+ return line.Xs
+ .Select((x, i) => hover.HoverTextAt(chart.Plot.GetPixel(new ScottPlot.Coordinates(x, line.MaxMs[i]))))
+ .ToList();
+ });
+
+ /// The popup's fourth line, or null when it is the three lines it is without one.
+ private static string? DetailOf(string? hover)
+ {
+ Assert.NotNull(hover);
+ var lines = hover!.Split('\n');
+ return lines.Length == 4 ? lines[3] : null;
+ }
+
+ ///
+ /// The observed case: twenty buckets and one break. The hover on bucket 10 said "Slowest of 12 runs; average 61 ms" (bucket
+ /// 11's), because the break pushed it one point along the scatter, and the last bucket said nothing, because its place
+ /// ran off the end of the lines. Each bucket says its own.
+ ///
+ [Fact]
+ public void ABucketAfterABreak_ShowsItsOwnRunsAndAverage_NotTheNextBucketsAndTheLastOneShowsItsOwn()
+ {
+ var text = HoverOverEachBucket(TwentyBucketsWithOneBreak());
+
+ Assert.Equal(20, text.Count);
+ Assert.Equal("Slowest of 11 runs; average 60 ms", DetailOf(text[10]));
+ Assert.Equal("Slowest of 20 runs; average 69 ms", DetailOf(text[19]));
+ for (var k = 0; k < text.Count; k++)
+ {
+ Assert.Equal($"Slowest of {k + 1} {(k == 0 ? "run" : "runs")}; average {50 + k} ms", DetailOf(text[k]));
+ }
+ }
+
+ ///
+ /// The hover lines are keyed by the X each bucket is drawn at, and the point the line inserts at the break is none of
+ /// them: every real point of the drawn scatter finds its own bucket's line, and the NaN point between the two buckets the
+ /// break separates finds none.
+ ///
+ [Fact]
+ public void TheLinesAreKeyedByX_SoEveryDrawnBucketFindsItsOwn_AndThePointTheBreakInsertsFindsNone()
+ {
+ var (line, drawn) = OnStaThread(() =>
+ {
+ var chart = new ScottPlot.WPF.WpfPlot();
+ var series = Assert.Single(ServerTab.BuildCollectorDurationSeries(TwentyBucketsWithOneBreak()));
+ ServerTab.PlotCollectorDurationSeries(chart, null, new[] { series });
+ var scatter = Assert.Single(chart.Plot.PlottableList.OfType());
+
+ return (series, scatter.Data.GetScatterPoints().ToList());
+ });
+
+ var byX = line.DetailsByX();
+
+ Assert.Equal(20, byX.Count);
+ Assert.Equal(21, drawn.Count);
+ var inserted = Assert.Single(drawn, p => double.IsNaN(p.Y));
+ Assert.False(byX.ContainsKey(inserted.X), "the point the break inserted has a hover line");
+ var real = drawn.Where(p => !double.IsNaN(p.Y)).ToList();
+ Assert.Equal(20, real.Count);
+ for (var k = 0; k < real.Count; k++)
+ {
+ Assert.Equal(line.Details[k], byX[real[k].X]);
+ }
+ }
+
+ ///
+ /// A line with no gap, and a series registered without hover lines, keep what they always said: the label, the value and
+ /// the time, three lines, and the buckets before the first break are unchanged either way.
+ ///
+ [Fact]
+ public void ASeriesRegisteredWithoutDetails_KeepsTheThreeLineText()
+ {
+ var text = OnStaThread(() =>
+ {
+ var chart = new ScottPlot.WPF.WpfPlot();
+ var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc);
+ var xs = new[] { Start.ToOADate(), Start.AddMinutes(10).ToOADate(), Start.AddMinutes(20).ToOADate() };
+ var scatter = chart.Plot.Add.Scatter(xs, new double[] { 5, 9, 7 });
+ hover.Add(scatter, "wait_stats");
+ chart.Plot.Axes.AutoScale();
+ using var render = chart.Plot.GetImage(1000, 400);
+
+ return hover.HoverTextAt(chart.Plot.GetPixel(new ScottPlot.Coordinates(xs[1], 9)));
+ });
+
+ Assert.NotNull(text);
+ var lines = text!.Split('\n');
+ Assert.Equal(3, lines.Length);
+ Assert.Equal("wait_stats", lines[0]);
+ Assert.Equal("9 ms", lines[1]);
+ }
+
+ ///
+ /// Clearing the hover is what an empty range does to what the last range registered (the render clears it before it
+ /// draws): with the mouse over a point that had a line, the hover said it, and after the clear it says nothing.
+ ///
+ [Fact]
+ public void ClearingTheHover_LeavesItHoldingNothing()
+ {
+ var (before, after) = OnStaThread(() =>
+ {
+ var chart = new ScottPlot.WPF.WpfPlot();
+ var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc);
+ var line = Assert.Single(ServerTab.BuildCollectorDurationSeries(TwentyBucketsWithOneBreak()));
+ ServerTab.PlotCollectorDurationSeries(chart, hover, new[] { line });
+ chart.Plot.Axes.AutoScale();
+ using var render = chart.Plot.GetImage(1000, 400);
+ var pixel = chart.Plot.GetPixel(new ScottPlot.Coordinates(line.Xs[10], line.MaxMs[10]));
+
+ var held = hover.HoverTextAt(pixel);
+ hover.Clear();
+ return (held, hover.HoverTextAt(pixel));
+ });
+
+ Assert.NotNull(before);
+ Assert.Null(after);
+ }
+
+ ///
+ /// The source of UpdateCollectorDurationChart, comments blanked where the pins below look for code and kept where
+ /// they look for the label's text: the method's braces, found in the code-only text, cut the same span out of the file.
+ ///
+ private static (string Source, string Code) UpdateCollectorDurationChartBody()
+ {
+ var file = Lite.Tests.ParitySource.ReadFile("Lite/Controls/ServerTab.Charts.cs");
+ var fileCode = CSharpSourceWalker.StripCommentsAndStrings(file);
+ var start = fileCode.IndexOf("private void UpdateCollectorDurationChart(", StringComparison.Ordinal);
+ Assert.True(start >= 0, "ServerTab.Charts.cs has no UpdateCollectorDurationChart");
+ var open = fileCode.IndexOf('{', start);
+ var length = CSharpSourceWalker.BraceBalanced(fileCode, open).Length;
+
+ return (file.Substring(open, length), fileCode.Substring(open, length));
+ }
+
+ ///
+ /// The chart draws each bucket's slowest run, so its Y axis says that, in the words the Darling viewer's chart uses.
+ /// It used to say "Duration (ms)", which reads as the average a run takes.
+ ///
+ [Fact]
+ public void TheYAxisLabel_NamesTheSlowestRunPerBucket()
+ {
+ var (source, code) = UpdateCollectorDurationChartBody();
+ var literals = CSharpSourceWalker.StringLiteralBodies(source).Select(l => l.Text).ToList();
+
+ Assert.Contains("Plot.YLabel(", code, StringComparison.Ordinal);
+ Assert.Contains("Slowest run per bucket (ms)", literals);
+ Assert.DoesNotContain("Duration (ms)", literals);
+ }
+
+ ///
+ /// A range with no points returns early, so the hover must be cleared before that return: it was cleared only on the way
+ /// to drawing, and a tooltip registered by the range before was left on an empty chart.
+ ///
+ [Fact]
+ public void AnEmptyRange_ClearsTheHover_BeforeItReturns()
+ {
+ var (_, code) = UpdateCollectorDurationChartBody();
+
+ var clear = code.IndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal);
+ var emptyReturn = code.IndexOf("if (data.Count == 0)", StringComparison.Ordinal);
+
+ Assert.True(clear >= 0, "UpdateCollectorDurationChart no longer clears the hover");
+ Assert.True(emptyReturn >= 0, "UpdateCollectorDurationChart no longer has its empty-range path");
+ Assert.True(clear < emptyReturn, "UpdateCollectorDurationChart clears the hover only after the empty range has returned");
+ Assert.Equal(code.IndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal), code.LastIndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal));
+ }
+
+ private static T OnStaThread(Func body)
+ {
+ T result = default!;
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { result = body(); }
+ catch (Exception ex) { error = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (error is not null)
+ {
+ throw error;
+ }
+
+ return result;
+ }
+}
diff --git a/Lite.Tests/DataStartBannerProbeFailureTests.cs b/Lite.Tests/DataStartBannerProbeFailureTests.cs
index 0d44da3cb..a8fffe84f 100644
--- a/Lite.Tests/DataStartBannerProbeFailureTests.cs
+++ b/Lite.Tests/DataStartBannerProbeFailureTests.cs
@@ -162,7 +162,7 @@ public void TheSharedBannerRefresh_GoesThroughTheGuardedProbe()
var source = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
Assert.Single(Regex.Matches(source,
- @"private async System\.Threading\.Tasks\.Task RefreshWindowTruncatedBannerAsync\([^)]*\)\s*\{\s*var floor = await ProbeWindowFloorOrNullAsync\(\s*\(\) => Task\.Run\(\(\) => _dataService\.GetQueryWindowFloorAsync\(relation, _serverId, startUtc, endUtc\)\),\s*\$""[^""]*"",\s*startUtc,\s*endUtc\);\s*ApplyWindowFloorToBanner\(banner, floor, startUtc, GetPickerZone\(\)\);"));
+ @"private async System\.Threading\.Tasks\.Task RefreshWindowTruncatedBannerAsync\([^)]*\)\s*\{\s*var floor = await ProbeWindowFloorOrNullAsync\(\s*\(\) => Task\.Run\(\(\) => _dataService\.GetQueryWindowFloorAsync\(relation, _serverId, startUtc, endUtc\)\),\s*\$""[^""]*"",\s*startUtc,\s*endUtc\);\s*ApplyWindowFloorToBanner\(banner, EarlierOfFloorAndRowShown\(floor, earliestRowShownUtc\), startUtc, GetPickerZone\(\)\);"));
}
/// WPF objects require STA, and a probe that has already completed keeps the continuation on this thread.
diff --git a/Lite.Tests/DataStartBannerQueriesTabTests.cs b/Lite.Tests/DataStartBannerQueriesTabTests.cs
index 1c4dbceae..fda4f4c9c 100644
--- a/Lite.Tests/DataStartBannerQueriesTabTests.cs
+++ b/Lite.Tests/DataStartBannerQueriesTabTests.cs
@@ -316,7 +316,7 @@ public void CappedGridBannerStep_AReadUnderItsCap_FallsThroughToTheProbingStep()
public void RefreshCappedGridBannerAsync_HandsTheDecisionThePickerZone_AndTheSharedProbingStep()
{
Assert.Matches(
- @"private System\.Threading\.Tasks\.Task RefreshCappedGridBannerAsync\(\s*QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc,\s*IReadOnlyCollection rows, int rowCap, Func rowTimeUtc\) =>\s*CappedGridBannerAsync\(rows, rowCap, rowTimeUtc,\s*oldestRowShown => ApplyCappedWindowFloorToBanner\(banner, oldestRowShown, startUtc, GetPickerZone\(\)\),\s*\(\) => RefreshWindowTruncatedBannerAsync\(relation, banner, startUtc, endUtc\)\);",
+ @"private System\.Threading\.Tasks\.Task RefreshCappedGridBannerAsync\(\s*QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc,\s*IReadOnlyCollection rows, int rowCap, Func rowTimeUtc\) =>\s*CappedGridBannerAsync\(rows, rowCap, rowTimeUtc,\s*oldestRowShown => ApplyCappedWindowFloorToBanner\(banner, oldestRowShown, startUtc, GetPickerZone\(\)\),\s*\(\) => RefreshWindowTruncatedBannerAsync\(relation, banner, startUtc, endUtc, EarliestRowShown\(rows, rowTimeUtc\)\)\);",
CodeOf("ServerTab.Refresh.cs"));
}
diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs
new file mode 100644
index 000000000..e65de1931
--- /dev/null
+++ b/Lite.Tests/DataStartBannerSurfaceTests.cs
@@ -0,0 +1,1203 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4966: the "Showing since" notice on the Collection Log, the nine System Events grids that read stored events
+/// (Scheduler Issues, Severe Errors, Memory Conditions, Memory Broker, Memory Node OOM, Significant Waits, CPU Tasks,
+/// I/O Issues and Default Trace), the three Config Changes grids and Long Queries. Each one reads its rows over the
+/// toolbar's window, so a window that starts before the store's coverage drew a shorter range with no notice. The
+/// notice comes from the ONE shared probe () and the ONE banner
+/// step () the Queries tab, Active Queries and Current Waits use, and
+/// each surface has the two tests the issue asks for: rows that start inside the range read the notice back, and a
+/// quiet start (the collector covered the whole range, the first row comes late) shows none.
+///
+/// The surfaces with no notice are pinned too ():
+/// the Health Summary is a fixed seven-day aggregate that does not read the toolbar's range, the Corruption Events and
+/// Contention Events charts pin their time axis to the asked range, so an empty span is already drawn as one, and the
+/// Duration Trends chart reads its own full-range buckets, with its axis pinned the same way.
+///
+[Collection("server-time-helper")]
+public sealed class DataStartBannerSurfaceTests : IDisposable
+{
+ private const int ServerId = 4343;
+ private const string ServerName = "SurfaceBannerServer";
+ private readonly string _tempDir;
+ private readonly DuckDbInitializer _duckDb;
+ private long _nextId = 1;
+
+ public DataStartBannerSurfaceTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "SurfaceBanner_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_tempDir);
+ _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb"));
+ }
+
+ public void Dispose()
+ {
+ try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+
+ /// One relation per surface that gets a notice. The eight system_health grids share a relation, as they
+ /// share a collector and a table; the wiring test below pins each grid's own banner and call.
+ public static TheoryData NoticeRelations => new()
+ {
+ QueryWindowRelation.CollectionLog,
+ QueryWindowRelation.SystemHealthEvents,
+ QueryWindowRelation.DefaultTraceEvents,
+ QueryWindowRelation.ServerConfig,
+ QueryWindowRelation.DatabaseConfig,
+ QueryWindowRelation.TraceFlags,
+ QueryWindowRelation.LongQueryCompletions
+ };
+
+ /// The two relations whose grids filter on event_time, the event's own time, and not on
+ /// collection_time, the time a run stored it (#4989): the System Events grids that read system_health
+ /// events, and the Default Trace grid.
+ public static TheoryData EventRelations => new()
+ {
+ QueryWindowRelation.SystemHealthEvents,
+ QueryWindowRelation.DefaultTraceEvents
+ };
+
+ private static DateTime Naive(DateTime instant) => DateTime.SpecifyKind(instant, DateTimeKind.Unspecified);
+
+ private static string Literal(DateTime instant) =>
+ "TIMESTAMP '" + Naive(instant).ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture) + "'";
+
+ ///
+ /// One row in the relation's table with its time column at . Every other column the table
+ /// requires (NOT NULL without a default) gets a placeholder of its type, found from the table itself, so the test
+ /// does not repeat any collector's column list. The two event tables carry two clocks (#4989): event_time, the
+ /// event's own time and nullable, and collection_time, the time a run stored the row. A row there sets BOTH,
+ /// whatever column the probe measures, so a test of one clock cannot lean on the other: is the
+ /// event's time, and ( when not given) the time the run stamped,
+ /// which is later for the history a server's first run stores. Any other relation has the one time column.
+ ///
+ private async Task SeedRowAsync(QueryWindowRelation relation, DateTime at, DateTime? collectedAt = null)
+ {
+ var table = LocalDataService.QueryWindowRelationView(relation)[2..];
+ var times = relation is QueryWindowRelation.SystemHealthEvents or QueryWindowRelation.DefaultTraceEvents
+ ? new Dictionary { ["event_time"] = at, ["collection_time"] = collectedAt ?? at }
+ : new Dictionary { [LocalDataService.QueryWindowRelationTimeColumn(relation)] = at };
+
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+
+ var names = new List();
+ var values = new List();
+ using (var info = connection.CreateCommand())
+ {
+ info.CommandText = $"SELECT name, type, \"notnull\", pk, dflt_value IS NOT NULL FROM pragma_table_info('{table}') ORDER BY cid";
+ using var reader = await info.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var name = reader.GetString(0);
+ var type = reader.GetString(1);
+ var pk = reader.GetBoolean(3);
+ if (!times.ContainsKey(name) && (!(reader.GetBoolean(2) || pk) || reader.GetBoolean(4)))
+ {
+ continue;
+ }
+
+ names.Add(name);
+ values.Add(PlaceholderFor(name, type, pk, times, at));
+ }
+ }
+
+ using var insert = connection.CreateCommand();
+ insert.CommandText = $"INSERT INTO {table} ({string.Join(", ", names)}) VALUES ({string.Join(", ", values)})";
+ await insert.ExecuteNonQueryAsync();
+ }
+
+ private string PlaceholderFor(string name, string type, bool pk, Dictionary times, DateTime at)
+ {
+ if (times.TryGetValue(name, out var time)) return Literal(time);
+ if (name == "server_id") return ServerId.ToString();
+ if (name == "server_name") return $"'{ServerName}'";
+ if (pk) return (_nextId++).ToString();
+
+ var upper = type.ToUpperInvariant();
+ if (upper.StartsWith("VARCHAR", StringComparison.Ordinal)) return "'x'";
+ if (upper.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return Literal(at);
+ if (upper == "BOOLEAN") return "false";
+ if (upper.Contains("INT", StringComparison.Ordinal) || upper.StartsWith("DECIMAL", StringComparison.Ordinal)
+ || upper is "DOUBLE" or "FLOAT" or "REAL")
+ {
+ return $"CAST(1 AS {type})";
+ }
+
+ return $"CAST('x' AS {type})";
+ }
+
+ ///
+ /// The collector's runs in collection_log, every minutes from
+ /// to , whether or not they stored a row.
+ ///
+ private async Task SeedLogRunsAsync(string collector, DateTime firstUtc, DateTime lastUtc, int everyMinutes)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
+SELECT {_nextId} + row_number() OVER (), {ServerId}, '{ServerName}', '{collector}', g.t, 12, 'SUCCESS', 0
+FROM generate_series({Literal(firstUtc)}, {Literal(lastUtc)}, INTERVAL {everyMinutes} MINUTE) AS g(t)";
+ _nextId += await cmd.ExecuteNonQueryAsync() + 1;
+ }
+
+ ///
+ /// A long-query completion every seconds from to
+ /// (the event's own time, event_time), each stored by a run
+ /// seconds later (collection_time). The two clocks differ on purpose: the
+ /// grid windows on the run's time and orders and caps on the event's, so a notice worded from the wrong one is
+ /// told apart from the right one.
+ ///
+ private async Task SeedLongQueriesEverySecondsAsync(DateTime firstEventUtc, DateTime lastEventUtc, int everySeconds)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+INSERT INTO long_query_completions (long_query_completion_id, collection_time, server_id, server_name, event_time, event_type, database_name, statement_text, duration_microseconds)
+SELECT {_nextId} + row_number() OVER (), g.t + INTERVAL {CollectedAfterSeconds} SECOND, {ServerId}, '{ServerName}', g.t, 'rpc_completed', 'Db',
+ 'SELECT ' || CAST(row_number() OVER () AS VARCHAR), 5000000
+FROM generate_series({Literal(firstEventUtc)}, {Literal(lastEventUtc)}, INTERVAL {everySeconds} SECOND) AS g(t)";
+ _nextId += await cmd.ExecuteNonQueryAsync() + 1;
+ }
+
+ /// How long after a completion the run that stored it stamps its collection_time.
+ private const int CollectedAfterSeconds = 90;
+
+ private static string Since(DateTime instant) =>
+ "Showing since " + PerformanceMonitor.Ui.DisplayZone.Format(Naive(instant), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm:ss");
+
+ ///
+ /// A capped grid's read at its cap, then the capped-grid banner step itself (,
+ /// the decision inside the tab's RefreshCappedGridBannerAsync): (visible, text, oldest row the grid returned,
+ /// rows it returned, whether the step handed the banner to the probing step). The probing step stands in for the shared
+ /// RefreshWindowTruncatedBannerAsync: it words the banner from the real probe's answer, which is awaited BEFORE the
+ /// STA block, because a continuation after an await runs on another thread and a WPF banner can only be written by the
+ /// thread that made it.
+ ///
+ private async Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedBannerOverTheRealReadAsync(
+ Func>> read, QueryWindowRelation relation, int rowCap, Func rowTimeUtc,
+ DateTime startUtc, DateTime endUtc)
+ {
+ var service = new LocalDataService(_duckDb);
+ var rows = await read(service);
+ var probedFloor = await service.GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc, ServerClock.Utc);
+ var (visible, text, probed) = OnStaThread(() =>
+ {
+ var banner = new System.Windows.Controls.TextBlock();
+ var probeStepRan = false;
+ ServerTab.CappedGridBannerAsync(rows, rowCap, rowTimeUtc,
+ oldestRowShown => ServerTab.ApplyCappedWindowFloorToBanner(banner, oldestRowShown, startUtc, TimeZoneInfo.Utc),
+ () =>
+ {
+ probeStepRan = true;
+ ServerTab.ApplyWindowFloorToBanner(banner, probedFloor, startUtc, TimeZoneInfo.Utc);
+ return Task.CompletedTask;
+ }).GetAwaiter().GetResult();
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text, probeStepRan);
+ });
+ return (visible, text, rows.Count == 0 ? default : rows.Min(rowTimeUtc), rows.Count, probed);
+ }
+
+ private Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedLongQueriesBannerAsync(DateTime startUtc, DateTime endUtc) =>
+ CappedBannerOverTheRealReadAsync(
+ service => service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: startUtc, toDate: endUtc),
+ QueryWindowRelation.LongQueryCompletions, LocalDataService.LongQueryGridCap, ServerTab.LongQueryRowTimeUtc, startUtc, endUtc);
+
+ private Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedCollectionLogBannerAsync(DateTime startUtc, DateTime endUtc) =>
+ CappedBannerOverTheRealReadAsync(
+ service => service.GetRecentCollectionLogAsync(ServerId, fromDate: startUtc, toDate: endUtc),
+ QueryWindowRelation.CollectionLog, LocalDataService.CollectionLogGridCap, row => row.CollectionTime, startUtc, endUtc);
+
+ /// The probe, then the banner step the surface runs on the result: (visible, text). The probe reads the Default
+ /// Trace through , the monitored server's clock (#4989). A test that names none is a UTC server's,
+ /// whatever clock the process holds as the active one.
+ private async Task<(bool Visible, string Text)> BannerForAsync(QueryWindowRelation relation, DateTime startUtc, DateTime endUtc, ServerClock? clock = null)
+ {
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc, clock ?? ServerClock.Utc);
+ return OnStaThread(() =>
+ {
+ var banner = new System.Windows.Controls.TextBlock();
+ ServerTab.ApplyWindowFloorToBanner(banner, floor, startUtc, TimeZoneInfo.Utc);
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text);
+ });
+ }
+
+ ///
+ /// A range that starts before the first stored row, and before the collector's first run, gets the notice, worded
+ /// at that first row or run. Covers the config snapshots too, whose time column is capture_time.
+ ///
+ [Theory]
+ [MemberData(nameof(NoticeRelations))]
+ public async Task RowsStartInsideTheRange_ShowTheNotice(QueryWindowRelation relation)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var first = end.AddDays(-2);
+ await SeedRowAsync(relation, first);
+ await SeedRowAsync(relation, end.AddHours(-1));
+ if (LocalDataService.QueryWindowRelationCollector(relation) is { } collector)
+ {
+ await SeedLogRunsAsync(collector, first, end, 60);
+ }
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.StartsWith("Showing since ", text, StringComparison.Ordinal);
+ Assert.Contains(Naive(first).ToString("yyyy-MM-dd HH:"), text, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A quiet start is no reason for a notice. The store covered the whole range, but the first row inside it comes
+ /// three days in: for an event or snapshot table the collector ran for a month and stored nothing near the start,
+ /// and for the run log itself the oldest row is a month old.
+ ///
+ [Theory]
+ [MemberData(nameof(NoticeRelations))]
+ public async Task QuietStart_TheStoreCoveredTheRange_ShowsNoNotice(QueryWindowRelation relation)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ if (LocalDataService.QueryWindowRelationCollector(relation) is { } collector)
+ {
+ await SeedLogRunsAsync(collector, end.AddDays(-30), end, 180);
+ }
+ else
+ {
+ await SeedRowAsync(relation, end.AddDays(-30));
+ }
+
+ await SeedRowAsync(relation, end.AddDays(-3));
+ await SeedRowAsync(relation, end.AddHours(-1));
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// A server's first run (T0, ): it stores the history the server already holds, each row
+ /// stamped with the run's own collection_time while its event_time goes back to the event's real time
+ /// (). The collector's runs are then logged hourly to , and
+ /// the newest one stores an event as it happens, so its two times agree.
+ ///
+ private async Task SeedFirstRunAsync(QueryWindowRelation relation, DateTime firstRun, DateTime end, params DateTime[] historyEventTimes)
+ {
+ foreach (var eventTime in historyEventTimes)
+ {
+ await SeedRowAsync(relation, eventTime, collectedAt: firstRun);
+ }
+
+ await SeedRowAsync(relation, end.AddHours(-1));
+ await SeedLogRunsAsync(LocalDataService.QueryWindowRelationCollector(relation)!, firstRun, end, 60);
+ }
+
+ ///
+ /// #4989: a server's first run stores the server's event history, every row stamped with that run's
+ /// collection_time (T0) while its event_time is older: days for the Default Trace, whose first run stores the
+ /// history its trace files hold, and about 10 minutes for system_health, whose first run reads back
+ /// CollectorContext.EventFallbackWindow. The tests seed the history at whatever depth they test. The grids filter on event_time, so a
+ /// range that starts before the oldest event (S < H < T0) shows rows from H, and the banner names H, never T0: a
+ /// banner never names a time later than the earliest row its grid shows. RED before the fix: the probe measured
+ /// collection_time, found only T0, and said "Showing since T0" above rows from before T0.
+ ///
+ [Theory]
+ [MemberData(nameof(EventRelations))]
+ public async Task EventRelations_HistoryStoredAtTheFirstRun_NamesTheOldestEvent_NotTheRun(QueryWindowRelation relation)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var firstRun = end.AddDays(-2);
+ var oldestEvent = end.AddDays(-4);
+ await SeedFirstRunAsync(relation, firstRun, end, oldestEvent, end.AddDays(-3));
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.Contains(Naive(oldestEvent).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ Assert.DoesNotContain(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The same first-run history, with the range starting after its oldest event (H <= S): the grid's rows run back to
+ /// the start of the range, so the window was served whole and there is no banner, though the run that stored the
+ /// history (T0) is later than the start.
+ ///
+ [Theory]
+ [MemberData(nameof(EventRelations))]
+ public async Task EventRelations_HistoryReachesBackToTheRangeStart_ShowsNoNotice(QueryWindowRelation relation)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedFirstRunAsync(relation, end.AddDays(-2), end, end.AddDays(-10), end.AddDays(-5));
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// A first run that found no history stores nothing older than itself: the store starts at that run (T0), and the
+ /// banner names it.
+ ///
+ [Theory]
+ [MemberData(nameof(EventRelations))]
+ public async Task EventRelations_NoStoredHistory_NamesTheFirstRun(QueryWindowRelation relation)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var firstRun = end.AddDays(-2);
+ await SeedFirstRunAsync(relation, firstRun, end);
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.Contains(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ private static DateTime NowUtcSeconds()
+ {
+ var now = DateTime.UtcNow;
+ return new DateTime(now.Ticks - now.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Unspecified);
+ }
+
+ ///
+ /// One Default Trace row as the collector stores it: event_time is the monitored SERVER's wall clock
+ /// () and collection_time the UTC time of the run that stored it (#4989). The event
+ /// is a 'Server Memory Change', one the grid's significance gate keeps, so a test can hold the probe's answer against the
+ /// rows the grid shows.
+ ///
+ private async Task SeedDefaultTraceRowAsync(DateTime serverLocal, DateTime collectedAtUtc)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+INSERT INTO default_trace_events
+ (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name,
+ database_name, duration_us, integer_data, severity, error_number, text_data)
+VALUES ({_nextId++}, {Literal(collectedAtUtc)}, {ServerId}, '{ServerName}', {Literal(serverLocal)}, 'Server Memory Change',
+ NULL, NULL, NULL, NULL, NULL, 'x')";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// A server's first run (T0, ) on a server whose clock is : it
+ /// stores the history the server already holds, each row stamped with the run's UTC collection_time and an
+ /// event_time that is the server's WALL CLOCK at the event (, run through the
+ /// clock). The collector's runs are then logged hourly to , and the newest run stores an event as
+ /// it happens.
+ ///
+ private async Task SeedDefaultTraceFirstRunAsync(ServerClock clock, DateTime firstRunUtc, DateTime end, params DateTime[] historyEventsUtc)
+ {
+ foreach (var eventUtc in historyEventsUtc)
+ {
+ await SeedDefaultTraceRowAsync(clock.ToServerLocal(eventUtc), firstRunUtc);
+ }
+
+ await SeedDefaultTraceRowAsync(clock.ToServerLocal(end.AddHours(-1)), end.AddHours(-1));
+ await SeedLogRunsAsync(LocalDataService.QueryWindowRelationCollector(QueryWindowRelation.DefaultTraceEvents)!, firstRunUtc, end, 60);
+ }
+
+ ///
+ /// #4989: the Default Trace's event_time is the server's wall clock, so the probe converts it through the server's
+ /// clock as the grid does. On a server 5 hours behind UTC the first run (T0) stores history whose oldest event (H) is
+ /// inside the range (S < H < T0), and the banner names H in UTC: not H less 5 hours, the stored wall clock read as
+ /// if it were UTC, and not T0. RED before the fix: the probe compared the wall clock with the UTC window as it was and
+ /// worded the banner at H shifted by 5 hours.
+ ///
+ [Fact]
+ public async Task DefaultTrace_ServerBehindUtc_HistoryBeforeTheFirstRun_NamesTheOldestEventInUtc()
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.FixedOffset(-300);
+ var end = DateTime.UtcNow;
+ var firstRun = end.AddDays(-2);
+ var oldest = end.AddDays(-4);
+ await SeedDefaultTraceFirstRunAsync(clock, firstRun, end, oldest, end.AddDays(-3));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, end.AddDays(-7), end, clock);
+
+ Assert.True(visible);
+ Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ Assert.DoesNotContain(Naive(oldest.AddHours(-5)).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ Assert.DoesNotContain(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The oldest event (H) is before the range's start (S) once both are in UTC, so the grid's range holds the history from
+ /// S on and there is no banner. On a server 5 hours AHEAD of UTC its stored wall clock (H plus 5 hours) is after S, so a
+ /// probe that read the wall clock as UTC found no older row and said the data starts late. (A server behind UTC cannot
+ /// show this: its wall clock reads earlier than the instant, never later.) H sits half an hour before S, inside the
+ /// pre-filter's margin the probe converts row by row, and 72 hours before it, below the margin, where one query answers.
+ ///
+ [Theory]
+ [InlineData(0.5)]
+ [InlineData(72.0)]
+ public async Task DefaultTrace_ServerAheadOfUtc_OldestEventBeforeTheStartInUtc_ShowsNoNotice(double hoursBeforeStart)
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.FixedOffset(300);
+ var end = DateTime.UtcNow;
+ var start = end.AddDays(-7);
+ await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, start.AddHours(-hoursBeforeStart), end.AddDays(-3));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, start, end, clock);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// The other direction on a server 5 hours behind UTC: the oldest event (H) is 3 hours after S in UTC, so the data does
+ /// start late and the banner names H, though its stored wall clock reads as 2 hours BEFORE S. A probe that read the wall
+ /// clock as UTC took that for an older row, and hid the banner.
+ ///
+ [Fact]
+ public async Task DefaultTrace_ServerBehindUtc_OldestEventJustAfterTheStartInUtc_ShowsTheNotice()
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.FixedOffset(-300);
+ var end = DateTime.UtcNow;
+ var start = end.AddDays(-7);
+ var oldest = start.AddHours(3);
+ await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, oldest);
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, start, end, clock);
+
+ Assert.True(visible);
+ Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The collector's runs in v_collection_log are UTC and take no part in the clock: on a server 5 hours behind UTC
+ /// whose first run found no history, the floor is that run as logged, not the run shifted by the clock.
+ ///
+ [Fact]
+ public async Task DefaultTrace_ServerBehindUtc_NoStoredHistory_NamesTheFirstRunInUtc()
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.FixedOffset(-300);
+ var end = DateTime.UtcNow;
+ var firstRun = end.AddDays(-2);
+ await SeedDefaultTraceFirstRunAsync(clock, firstRun, end);
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, end.AddDays(-7), end, clock);
+
+ Assert.True(visible);
+ Assert.Contains(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A probe that names no clock reads the one the grid falls back to, ,
+ /// the Default Trace grid's own default (it passes none), so the banner and the rows it sits over agree.
+ ///
+ [Fact]
+ public async Task DefaultTrace_WithoutAClock_UsesTheActiveServerClockLikeTheGrid()
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.FixedOffset(-300);
+ var end = NowUtcSeconds();
+ var start = end.AddDays(-7);
+ var oldest = end.AddDays(-4);
+ await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, oldest);
+
+ var previous = ServerTimeHelper.ActiveServerClock;
+ try
+ {
+ ServerTimeHelper.ActiveServerClock = clock;
+ var service = new LocalDataService(_duckDb);
+ var floor = await service.GetQueryWindowFloorAsync(QueryWindowRelation.DefaultTraceEvents, ServerId, start, end);
+ var gridRows = await service.GetDefaultTraceEventsAsync(ServerId, fromDate: start, toDate: end);
+
+ Assert.Equal(oldest, floor);
+ Assert.Equal(floor, gridRows.Min(r => r.EventTimeUtc));
+ }
+ finally
+ {
+ ServerTimeHelper.ActiveServerClock = previous;
+ }
+ }
+
+ ///
+ /// Across a daylight-saving change the probe takes each row's offset from the row's OWN date, as the grid does (#4766),
+ /// and the floor is exactly the oldest row the grid shows. US Eastern, 2026: the spring-forward is 8 March (02:00 EST to
+ /// 03:00 EDT, 07:00 UTC) and the fall-back is 1 November (02:00 EDT to 01:00 EST, 06:00 UTC). The range straddles the
+ /// change, and the oldest row is on each side of it in turn, so a probe that applied one offset to the whole range,
+ /// the one at its start or the one at its end, is an hour wrong on one of the four.
+ ///
+ [Theory]
+ [InlineData("2026-03-07 00:00:00", "2026-03-10 00:00:00", "2026-03-08 01:30:00", "2026-03-08 06:30:00")]
+ [InlineData("2026-03-07 00:00:00", "2026-03-10 00:00:00", "2026-03-08 03:30:00", "2026-03-08 07:30:00")]
+ [InlineData("2026-10-30 00:00:00", "2026-11-03 00:00:00", "2026-11-01 00:30:00", "2026-11-01 04:30:00")]
+ [InlineData("2026-10-30 00:00:00", "2026-11-03 00:00:00", "2026-11-01 02:30:00", "2026-11-01 07:30:00")]
+ public async Task DefaultTrace_RangeAcrossADaylightSavingChange_UsesTheOffsetOfTheRowsOwnDate(
+ string startUtc, string endUtc, string oldestServerLocal, string expectedFloorUtc)
+ {
+ await _duckDb.InitializeAsync();
+ var clock = ServerClock.Resolve("Eastern Standard Time", -300);
+ var start = DateTime.Parse(startUtc, CultureInfo.InvariantCulture);
+ var end = DateTime.Parse(endUtc, CultureInfo.InvariantCulture);
+ var oldest = DateTime.Parse(oldestServerLocal, CultureInfo.InvariantCulture);
+ await SeedDefaultTraceRowAsync(oldest, end.AddHours(-1));
+ await SeedDefaultTraceRowAsync(oldest.AddHours(30), end.AddHours(-1));
+
+ var service = new LocalDataService(_duckDb);
+ var floor = await service.GetQueryWindowFloorAsync(QueryWindowRelation.DefaultTraceEvents, ServerId, start, end, clock);
+ var gridRows = await service.GetDefaultTraceEventsAsync(ServerId, fromDate: start, toDate: end, serverClock: clock);
+
+ Assert.Equal(DateTime.Parse(expectedFloorUtc, CultureInfo.InvariantCulture), floor);
+ Assert.Equal(floor, gridRows.Min(r => r.EventTimeUtc));
+ }
+
+ ///
+ /// The system_health event_time is the XE @timestamp, UTC, so a server clock never shifts it: the same
+ /// history as the Default Trace case, on a server 5 hours behind UTC, names the oldest event as stored.
+ ///
+ [Fact]
+ public async Task SystemHealth_UtcEventTime_IsNotShiftedByTheServerClock()
+ {
+ await _duckDb.InitializeAsync();
+ var relation = QueryWindowRelation.SystemHealthEvents;
+ var end = DateTime.UtcNow;
+ var oldest = end.AddDays(-4);
+ await SeedFirstRunAsync(relation, end.AddDays(-2), end, oldest, end.AddDays(-3));
+
+ var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end, ServerClock.FixedOffset(-300));
+
+ Assert.True(visible);
+ Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal);
+ }
+
+ /// Only the Default Trace holds the server's wall clock (#4989); every other relation's time column is UTC.
+ [Fact]
+ public void OnlyTheDefaultTrace_HoldsServerLocalTime()
+ {
+ foreach (var relation in Enum.GetValues())
+ {
+ Assert.Equal(relation == QueryWindowRelation.DefaultTraceEvents, LocalDataService.QueryWindowRelationTimeIsServerLocal(relation));
+ }
+ }
+
+ ///
+ /// Each relation reads the view its grid reads, by the column that grid filters on (#4989: event_time for the
+ /// two event relations, collection_time or capture_time for the rest), and takes its coverage from
+ /// the collector whose runs the store logs under that name. The Collection Log is the run log, so it keeps the
+ /// row-only probe.
+ ///
+ [Theory]
+ [InlineData(QueryWindowRelation.CollectionLog, "v_collection_log", null, "collection_time")]
+ [InlineData(QueryWindowRelation.SystemHealthEvents, "v_system_health_events", "system_health_events", "event_time")]
+ [InlineData(QueryWindowRelation.DefaultTraceEvents, "v_default_trace_events", "default_trace_events", "event_time")]
+ [InlineData(QueryWindowRelation.ServerConfig, "v_server_config", "server_config", "capture_time")]
+ [InlineData(QueryWindowRelation.DatabaseConfig, "v_database_config", "database_config", "capture_time")]
+ [InlineData(QueryWindowRelation.TraceFlags, "v_trace_flags", "trace_flags", "capture_time")]
+ [InlineData(QueryWindowRelation.LongQueryCompletions, "v_long_query_completions", "long_query_completions", "collection_time")]
+ public void Relations_NameTheViewCollectorAndTimeColumnTheirGridReads(
+ QueryWindowRelation relation, string view, string? collector, string timeColumn)
+ {
+ Assert.Equal(view, LocalDataService.QueryWindowRelationView(relation));
+ Assert.Equal(collector, LocalDataService.QueryWindowRelationCollector(relation));
+ Assert.Equal(timeColumn, LocalDataService.QueryWindowRelationTimeColumn(relation));
+ }
+
+ ///
+ /// The wiring: each surface has its banner TextBlock, and the one method that reads it refreshes the banner after
+ /// the rows are bound, through the shared helper and over the UTC window the read took. Every read path of these
+ /// surfaces goes through that method: the sub-tab switch and the toolbar's range change reach the System Events,
+ /// Config Changes and Long Queries loaders through RefreshVisibleTabAsync, and a loader's own Refresh button
+ /// calls the same refresh. Comments are stripped first, so a sentence that names the call cannot satisfy the pin. The
+ /// two grids that read a capped page, the Collection Log and Long Queries, are pinned by
+ /// instead (#4989).
+ ///
+ [Theory]
+ [InlineData("private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SchedulerIssuesWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadSevereErrorsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SevereErrorsWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryConditionsWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryBrokerWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryNodeOomWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SignificantWaitsWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadCpuTasksAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "CpuTasksWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadIoIssuesAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "IoIssuesWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(", "ServerTab.SystemEvents.cs", "DefaultTraceEvents", "DefaultTraceWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "ServerConfig", "ServerConfigChangesWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "DatabaseConfig", "DatabaseConfigChangesWindowTruncatedBanner")]
+ [InlineData("private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(", "ServerTab.ConfigChanges.cs", "TraceFlags", "TraceFlagChangesWindowTruncatedBanner")]
+ public void EverySurface_HasABanner_RefreshedAfterTheRowsAreBound(string signature, string file, string relation, string banner)
+ {
+ var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml"));
+ Assert.Contains($"x:Name=\"{banner}\"", xaml, StringComparison.Ordinal);
+
+ var code = StripComments(File.ReadAllText(ControlsFile(file)).Replace("\r\n", "\n"));
+ var start = code.IndexOf(signature, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"{signature} is not in {file}");
+ var next = Regex.Match(code[(start + signature.Length)..], @"\n (private|internal|public) ");
+ var body = next.Success ? code.Substring(start, signature.Length + next.Index) : code[start..];
+
+ var call = $"RefreshStoredWindowBannerAsync(QueryWindowRelation.{relation}, {banner}, hoursBack, fromDate, toDate)";
+ Assert.Contains(call, body, StringComparison.Ordinal);
+ var bind = body.IndexOf("UpdateData(", StringComparison.Ordinal);
+ Assert.True(bind >= 0 && bind < body.IndexOf(call, StringComparison.Ordinal),
+ $"{banner} must be refreshed after the grid's rows are bound");
+ }
+
+ ///
+ /// The one helper every group surface calls: it takes the window the grids' reads take
+ /// (, the same GetTimeRange call) and hands it to the
+ /// shared banner step, which hides the banner when the probe throws instead of unwinding the refresh.
+ ///
+ [Fact]
+ public void TheBannerHelper_ProbesTheWindowTheGridsReadThroughTheSharedStep()
+ {
+ var code = StripComments(File.ReadAllText(ControlsFile("ServerTab.SystemEvents.cs")).Replace("\r\n", "\n"));
+ var start = code.IndexOf("RefreshStoredWindowBannerAsync(QueryWindowRelation relation,", StringComparison.Ordinal);
+ Assert.True(start >= 0);
+ var body = code[start..Math.Min(code.Length, start + 600)];
+ Assert.Contains("LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate)", body, StringComparison.Ordinal);
+ Assert.Contains("RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc)", body, StringComparison.Ordinal);
+ }
+
+ ///
+ /// #4989: the two grids that read a capped page, the Collection Log (the newest
+ /// runs) and Long Queries (the newest
+ /// completions), refresh their notice through the cap-aware step
+ /// (RefreshCappedGridBannerAsync), not through the probe-only one: after the rows are bound, over the UTC window
+ /// the read took, handing the step the rows the grid shows, the constant that is the read's cap and the time each
+ /// row is capped on. Comments are stripped first. A grid that goes back to the probe-only step would again say
+ /// nothing about the rows its cap dropped, on a range the store covers.
+ ///
+ [Theory]
+ [InlineData("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", "ServerTab.Refresh.cs", "CollectionLog", "CollectionLogWindowTruncatedBanner",
+ "collectionLogTask.Result, LocalDataService.CollectionLogGridCap, row => row.CollectionTime")]
+ [InlineData("private async Task RefreshLongQueriesAsync(", "ServerTab.LongQueries.cs", "LongQueryCompletions", "LongQueriesWindowTruncatedBanner",
+ "task.Result, LocalDataService.LongQueryGridCap, LongQueryRowTimeUtc")]
+ public void CappedSurfaces_RefreshTheirBanner_ThroughTheCapAwareStep_AfterTheRowsAreBound(
+ string signature, string file, string relation, string banner, string rowsCapAndTime)
+ {
+ var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml"));
+ Assert.Contains($"x:Name=\"{banner}\"", xaml, StringComparison.Ordinal);
+
+ var code = StripComments(File.ReadAllText(ControlsFile(file)).Replace("\r\n", "\n"));
+ var start = code.IndexOf(signature, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"{signature} is not in {file}");
+ var next = Regex.Match(code[(start + signature.Length)..], @"\n (private|internal|public) ");
+ var body = next.Success ? code.Substring(start, signature.Length + next.Index) : code[start..];
+
+ var window = "var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);";
+ var call = $"RefreshCappedGridBannerAsync(QueryWindowRelation.{relation}, {banner}, windowStart, windowEnd, {rowsCapAndTime})";
+ Assert.Contains(window, body, StringComparison.Ordinal);
+ Assert.Contains(call, body, StringComparison.Ordinal);
+ Assert.DoesNotContain("RefreshStoredWindowBannerAsync(", body, StringComparison.Ordinal);
+ var bind = body.IndexOf("UpdateData(", StringComparison.Ordinal);
+ Assert.True(bind >= 0 && bind < body.IndexOf(window, StringComparison.Ordinal) && body.IndexOf(window, StringComparison.Ordinal) < body.IndexOf(call, StringComparison.Ordinal),
+ $"{banner} must be refreshed after the grid's rows are bound, over the read's window");
+ }
+
+ ///
+ /// The read's LIMIT and the notice's cap are ONE value. The Long Queries read is written with
+ /// and not with a literal, so a read seeded past the cap comes back with
+ /// exactly that many rows, and the tab hands the same constant to the cap-aware step
+ /// (). The Collection Log's
+ /// default maxRows is the same way. The MCP read that ranks by
+ /// duration, GetSlowestLongQueryCompletionsAsync, caps nothing by time and keeps its own limit.
+ ///
+ [Fact]
+ public async Task TheGridReads_LimitIsTheSameConstantTheBannerCaps()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-LocalDataService.LongQueryGridCap - 40), end.AddMinutes(-2), 60);
+ await SeedLogRunsAsync("wait_stats", end.AddMinutes(-LocalDataService.CollectionLogGridCap - 40), end, 1);
+
+ var service = new LocalDataService(_duckDb);
+ Assert.Equal(LocalDataService.LongQueryGridCap, (await service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: end.AddDays(-1), toDate: end)).Count);
+ Assert.Equal(LocalDataService.CollectionLogGridCap, (await service.GetRecentCollectionLogAsync(ServerId, fromDate: end.AddDays(-1), toDate: end)).Count);
+
+ var longQueries = File.ReadAllText(RepoFile("Lite", "Services", "LocalDataService.LongQueries.cs")).Replace("\r\n", "\n");
+ var grid = longQueries[longQueries.IndexOf("GetRecentLongQueryCompletionsAsync(int serverId", StringComparison.Ordinal)..longQueries.IndexOf("GetSlowestLongQueryCompletionsAsync(int serverId", StringComparison.Ordinal)];
+ Assert.Contains("LIMIT {LongQueryGridCap}", grid, StringComparison.Ordinal);
+ Assert.DoesNotMatch(@"LIMIT \d", grid);
+ }
+
+ ///
+ /// A Long Queries read that fills its cap names its OLDEST returned completion, worded at its own time
+ /// (event_time, 90 seconds before the run that stored it stamped the row), even over a range the store covers:
+ /// the collector's runs reach 30 days back, so the probe alone shows nothing for a 7-day range, but the grid shows only
+ /// the newest 200 of the 3 days of completions it holds, the oldest about 16.6 hours back. The probing step is not asked.
+ ///
+ [Fact]
+ public async Task LongQueries_AReadThatFillsItsCap_NamesItsOldestRow_AlsoOverARangeTheStoreCovers()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("long_query_completions", end.AddDays(-30), end, 360);
+ await SeedLongQueriesEverySecondsAsync(end.AddDays(-3), end.AddMinutes(-2), 5 * 60);
+
+ Assert.False((await BannerForAsync(QueryWindowRelation.LongQueryCompletions, end.AddDays(-7), end)).Visible);
+
+ var (visible, text, oldest, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.Equal(LocalDataService.LongQueryGridCap, rows);
+ Assert.InRange((end - oldest).TotalHours, 16, 17);
+ Assert.True(visible);
+ Assert.Equal(Since(oldest), text);
+ Assert.False(probed);
+ }
+
+ ///
+ /// The notice names the completion's own time, not the time its run stored it: the oldest row's
+ /// collection_time is 90 seconds later, and a notice worded from it would name a time after the oldest row shown.
+ ///
+ [Fact]
+ public async Task LongQueries_TheCappedNotice_IsWordedAtTheEventTime_NotTheTimeTheRunStoredIt()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLongQueriesEverySecondsAsync(end.AddHours(-30), end.AddMinutes(-2), 5 * 60);
+
+ var rows = await new LocalDataService(_duckDb).GetRecentLongQueryCompletionsAsync(ServerId, fromDate: end.AddDays(-7), toDate: end);
+ var (visible, text, oldest, _, _) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.Equal(Since(oldest), text);
+ Assert.Equal(oldest, rows.Min(row => row.EventTime));
+ Assert.NotEqual(Since(rows.Min(row => row.CollectionTime)), text);
+ }
+
+ ///
+ /// The row's time is its event time, and a completion with no event time falls back to the time its run was collected.
+ ///
+ [Fact]
+ public void LongQueryRowTimeUtc_IsTheEventTime_AndFallsBackToTheCollectionTime()
+ {
+ var collected = new DateTime(2026, 6, 3, 10, 2, 0);
+ var eventTime = new DateTime(2026, 6, 3, 10, 0, 30);
+
+ Assert.Equal(eventTime, ServerTab.LongQueryRowTimeUtc(new LongQueryCompletionRow { CollectionTime = collected, EventTime = eventTime }));
+ Assert.Equal(collected, ServerTab.LongQueryRowTimeUtc(new LongQueryCompletionRow { CollectionTime = collected, EventTime = null }));
+ }
+
+ ///
+ /// A read under its cap holds everything the store has in the range, so the coverage notice stands as before: a server
+ /// whose trace was added 48 hours ago says it shows since then, and the probing step is the one that words it.
+ ///
+ [Fact]
+ public async Task LongQueries_AReadUnderItsCap_KeepsTheCoverageNotice()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var added = end.AddHours(-48);
+ await SeedLogRunsAsync("long_query_completions", added, end, 5);
+ await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-2 - 5 * 149), end.AddMinutes(-2), 5 * 60);
+
+ var (visible, text, _, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.Equal(150, rows);
+ Assert.True(probed);
+ Assert.True(visible);
+ Assert.Equal(Since(added), text);
+ }
+
+ /// A range the store covers, read under the cap, shows no notice: the probing step answers the range's start.
+ [Fact]
+ public async Task LongQueries_ACoveredRangeUnderTheCap_ShowsNoNotice()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("long_query_completions", end.AddDays(-30), end, 360);
+ await SeedLongQueriesEverySecondsAsync(end.AddHours(-5), end.AddHours(-4), 20 * 60);
+
+ var (visible, text, _, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.InRange(rows, 1, LocalDataService.LongQueryGridCap - 1);
+ Assert.True(probed);
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// A capped Long Queries read on a range of ONE HOUR whose oldest completion is later than the start shows its notice, at
+ /// that completion: a completion every 10 seconds fills the 200-row cap in about 33 minutes, so the grid starts about 25
+ /// minutes after the range does. The capped verdict gets no slack (the 90-minute slack is the coverage probe's, and
+ /// the probe is not asked).
+ ///
+ [Fact]
+ public async Task LongQueries_AOneHourRange_WhoseGridHitsItsCap_ShowsTheNoticeAtItsOldestRow_WithNoSlack()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-40), end.AddMinutes(-2), 10);
+
+ /* What the probe-only step said of this read: nothing, the first row is inside the slack. */
+ Assert.False((await BannerForAsync(QueryWindowRelation.LongQueryCompletions, end.AddHours(-1), end)).Visible);
+
+ var (visible, text, oldest, rows, probed) = await CappedLongQueriesBannerAsync(end.AddHours(-1), end);
+
+ Assert.Equal(LocalDataService.LongQueryGridCap, rows);
+ Assert.InRange((end - oldest).TotalMinutes, 34, 36);
+ Assert.True(visible);
+ Assert.Equal(Since(oldest), text);
+ Assert.False(probed);
+ }
+
+ ///
+ /// A range of 90 minutes or less makes no probe call for Long Queries, which gets its coverage notice from the shared
+ /// probing step (): such a window can never get one. A range past the
+ /// slack asks the probe once.
+ ///
+ [Theory]
+ [InlineData(60, 0)]
+ [InlineData(90, 0)]
+ [InlineData(91, 1)]
+ [InlineData(1440, 1)]
+ public async Task LongQueries_ARangeNoLongerThanTheSlack_MakesNoProbeCall(int rangeMinutes, int expectedProbeCalls)
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("long_query_completions", end.AddDays(-2), end, 5);
+ var service = new LocalDataService(_duckDb);
+ var start = end.AddMinutes(-rangeMinutes);
+ var probeCalls = 0;
+
+ var floor = await ServerTab.ProbeWindowFloorOrNullAsync(
+ () =>
+ {
+ probeCalls++;
+ return service.GetQueryWindowFloorAsync(QueryWindowRelation.LongQueryCompletions, ServerId, start, end, ServerClock.Utc);
+ },
+ "Long Queries", start, end);
+
+ Assert.Equal(expectedProbeCalls, probeCalls);
+ Assert.Equal(expectedProbeCalls == 0, floor is null);
+ }
+
+ ///
+ /// The Collection Log read fills its cap on a dense log (a run every minute for 12 hours is 720 rows, the cap 500), so
+ /// the notice names the oldest run the grid returned, over a range the store covers (an older run sits 20 days back,
+ /// so the probe alone shows nothing), and the probing step is not asked.
+ ///
+ [Fact]
+ public async Task CollectionLog_AReadThatFillsItsCap_NamesItsOldestRow_AlsoOverARangeTheStoreCovers()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("wait_stats", end.AddDays(-20), end.AddDays(-20), 1);
+ await SeedLogRunsAsync("wait_stats", end.AddHours(-12), end, 1);
+
+ Assert.False((await BannerForAsync(QueryWindowRelation.CollectionLog, end.AddDays(-7), end)).Visible);
+
+ var (visible, text, oldest, rows, probed) = await CappedCollectionLogBannerAsync(end.AddDays(-7), end);
+
+ Assert.Equal(LocalDataService.CollectionLogGridCap, rows);
+ Assert.InRange((end - oldest).TotalMinutes, 498, 501);
+ Assert.True(visible);
+ Assert.Equal(Since(oldest), text);
+ Assert.False(probed);
+ }
+
+ /// A Collection Log read under its cap keeps the coverage notice, worded at the first run in the range.
+ [Fact]
+ public async Task CollectionLog_AReadUnderItsCap_KeepsTheCoverageNotice()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var added = end.AddHours(-3);
+ await SeedLogRunsAsync("wait_stats", added, end, 5);
+
+ var (visible, text, _, rows, probed) = await CappedCollectionLogBannerAsync(end.AddDays(-7), end);
+
+ Assert.InRange(rows, 1, LocalDataService.CollectionLogGridCap - 1);
+ Assert.True(probed);
+ Assert.True(visible);
+ Assert.Equal(Since(added), text);
+ }
+
+ ///
+ /// The other twelve banners (the eight system_health grids, Default Trace and the three Config Changes grids) read
+ /// their whole window: none of their reads carries a row cap, so the probe alone words their notice. A LIMIT
+ /// added to one of these reads would need the cap-aware step the Collection Log and Long Queries use, and fails here
+ /// until it has it.
+ ///
+ [Theory]
+ [InlineData("LocalDataService.SystemEvents.cs")]
+ [InlineData("LocalDataService.ConfigChanges.cs")]
+ public void TheUncappedSurfaces_ReadNoRowCap(string file)
+ {
+ var code = StripComments(File.ReadAllText(RepoFile("Lite", "Services", file)).Replace("\r\n", "\n"));
+
+ Assert.DoesNotMatch(@"\bLIMIT\b", code);
+ Assert.DoesNotMatch(@"\bTOP\s*\(?\s*\d", code);
+ }
+
+ ///
+ /// The surfaces that need no notice, and the shape that makes it true. The Health Summary reads a fixed seven
+ /// days per collector, with no range parameter, so a picked range never starts before its data. The Corruption Events
+ /// and Contention Events charts set their X axis to the asked range, so a span with no data is drawn as the empty
+ /// span it is. The Duration Trends chart needs none for its own reason (#4989): it reads its own full-range buckets
+ /// (, not the Collection Log grid's capped page) with its
+ /// X axis pinned to the asked range, so what it draws is the range's data and the empty span is the span with none; the
+ /// DurationTrends_* tests below pin that. If one of the others starts reading the toolbar's range as a grid, or
+ /// stops pinning its axis, it needs a notice and this test says so.
+ ///
+ [Fact]
+ public void SurfacesWithoutANotice_KeepTheShapeThatNeedsNone()
+ {
+ var health = File.ReadAllText(RepoFile("Lite", "Services", "LocalDataService.CollectionHealth.cs"));
+ Assert.Contains("public async Task> GetCollectionHealthAsync(int serverId)", health, StringComparison.Ordinal);
+ Assert.Contains("GetCollectionHealthAsync(_serverId)", File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")), StringComparison.Ordinal);
+
+ /* The Duration Trends chart is no longer pinned here: it reads its own buckets over the whole range, and the tests
+ below (DurationTrends_*) say what it draws. */
+
+ var systemCharts = File.ReadAllText(ControlsFile("ServerTab.SystemHealthCharts.cs"));
+ Assert.Contains("ChartPalette.CyclingColor(3), xMin, xMax)", systemCharts, StringComparison.Ordinal);
+ Assert.Contains("RenderSickSpinlocksChart(SickSpinlocksChart, _sickSpinlocksHover, data, xMin, xMax)", systemCharts, StringComparison.Ordinal);
+ Assert.Contains("RenderCpuComparisonChart(CpuComparisonChart, _cpuComparisonHover, data, xMin, xMax)", systemCharts, StringComparison.Ordinal);
+ Assert.Equal(3, Regex.Matches(File.ReadAllText(RepoFile("PerformanceMonitor.Ui", "SystemHealthChartRenderer.cs")),
+ Regex.Escape("chart.Plot.Axes.SetLimitsX(xMin, xMax)")).Count);
+ }
+
+ /// What a chart draws, as (first X, last X) of each plotted line: the series drawn on a real chart.
+ private static List<(double First, double Last)> DrawnSpans(IReadOnlyList series) =>
+ OnStaThread(() =>
+ {
+ var chart = new ScottPlot.WPF.WpfPlot();
+ ServerTab.PlotCollectorDurationSeries(chart, null, series);
+ return chart.Plot.PlottableList.OfType()
+ .Select(scatter => scatter.Data.GetScatterPoints().Select(p => p.X).ToList())
+ .Select(xs => (xs.Min(), xs.Max()))
+ .ToList();
+ });
+
+ ///
+ /// #4989: a 24-hour range holding more runs than the Collection Log grid's page (the newest 500) draws points across
+ /// the WHOLE range. The chart used to be handed that page, so at three runs a minute it drew the newest 2 hours 47
+ /// minutes of a day (and at twenty a minute, 25 minutes) and left the rest of its pinned axis empty though the store
+ /// held those runs. The first assertions say the page is the sliver it is; the rest, that each collector's line starts
+ /// at the range's start and ends at its end.
+ ///
+ [Fact]
+ public async Task DurationTrends_ADayHoldingMoreRunsThanTheGridsPage_DrawsPointsAcrossTheWholeRange()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var start = end.AddHours(-24);
+ foreach (var collector in new[] { "wait_stats", "cpu_utilization_stats", "memory_stats" })
+ {
+ await SeedLogRunsAsync(collector, start, end, 1);
+ }
+
+ var service = new LocalDataService(_duckDb);
+ var page = await service.GetRecentCollectionLogAsync(ServerId, fromDate: start, toDate: end);
+ Assert.Equal(LocalDataService.CollectionLogGridCap, page.Count);
+ Assert.True(page.Max(r => r.CollectionTime) - page.Min(r => r.CollectionTime) < TimeSpan.FromHours(4),
+ "the grid's page is the newest runs only");
+
+ var spans = DrawnSpans(ServerTab.BuildCollectorDurationSeries(await service.GetCollectorDurationTrendAsync(ServerId, fromDate: start, toDate: end)));
+
+ Assert.Equal(3, spans.Count);
+ foreach (var (first, last) in spans)
+ {
+ Assert.True(first <= start.AddMinutes(2).ToOADate(), $"a line starts {DateTime.FromOADate(first):O}, after the range's start {start:O}");
+ Assert.True(last >= end.AddMinutes(-2).ToOADate(), $"a line ends {DateTime.FromOADate(last):O}, before the range's end {end:O}");
+ }
+ }
+
+ ///
+ /// #4989: the chart draws each bucket's slowest run, so one slow run still shows as it did when every run was a point,
+ /// and the hover says what stands behind the point: how many runs it is the slowest of and their average. A 7-day range
+ /// of one run a minute is bucketed (far fewer points than runs, and every run counted once), and the one 9-second run
+ /// in it is the height of its bucket's point.
+ ///
+ [Fact]
+ public async Task DurationTrends_ASevenDayRange_DrawsEachBucketsSlowestRun_AndNamesTheAverageAndCountInTheHover()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var start = end.AddDays(-7);
+ await SeedLogRunsAsync("wait_stats", start, end, 1);
+ using (var connection = _duckDb.CreateConnection())
+ {
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+UPDATE collection_log SET duration_ms = 9000
+WHERE collector_name = 'wait_stats' AND collection_time = (SELECT MIN(collection_time) FROM collection_log WHERE collection_time >= {Literal(start.AddDays(3))})";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ var buckets = await new LocalDataService(_duckDb).GetCollectorDurationTrendAsync(ServerId, fromDate: start, toDate: end);
+
+ Assert.True(buckets.Count < 2000, $"{buckets.Count} points for 10,081 runs: the read buckets");
+ Assert.Equal(10081, buckets.Sum(b => b.RunCount));
+ Assert.True(buckets.All(b => b.BucketStart >= start.AddTicks(-10)), "no bucket starts before the range");
+ var slow = Assert.Single(buckets, b => b.MaxDurationMs == 9000);
+ Assert.True(slow.RunCount >= 2);
+ Assert.Equal((9000 + 12.0 * (slow.RunCount - 1)) / slow.RunCount, slow.AverageDurationMs, 6);
+
+ var series = Assert.Single(ServerTab.BuildCollectorDurationSeries(buckets));
+ var at = Array.IndexOf(series.MaxMs, 9000d);
+ Assert.True(at >= 0, "the slow run is a point's height");
+ var culture = System.Globalization.CultureInfo.CurrentCulture;
+ var average = slow.AverageDurationMs == Math.Floor(slow.AverageDurationMs) ? slow.AverageDurationMs.ToString("N0", culture) : slow.AverageDurationMs.ToString("N1", culture);
+ Assert.Equal($"Slowest of {slow.RunCount.ToString("N0", culture)} runs; average {average} ms", series.Details[at]);
+
+ Assert.Equal("wait_stats\n9,000 ms\n10:00:00\nSlowest of 10 runs; average 910.8 ms",
+ PerformanceMonitor.Ui.ChartHoverHelper.HoverText("wait_stats", "9,000", "ms", "10:00:00", "Slowest of 10 runs; average 910.8 ms"));
+ Assert.Equal("wait_stats\n9,000 ms\n10:00:00", PerformanceMonitor.Ui.ChartHoverHelper.HoverText("wait_stats", "9,000", "ms", "10:00:00"));
+ }
+
+ ///
+ /// #4989: the Refresh hands the chart its own read (GetCollectorDurationTrendAsync), started beside the grid's
+ /// read and awaited with it, and never the grid's page.
+ ///
+ [Fact]
+ public void DurationTrends_TheChartIsFedItsOwnRead_StartedBesideTheGridsRead()
+ {
+ var code = StripComments(File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")).Replace("\r\n", "\n"));
+ var start = code.IndexOf("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", StringComparison.Ordinal);
+ Assert.True(start >= 0);
+ var body = code[start..code.IndexOf("private static async Task> SafeQueryAsync", start, StringComparison.Ordinal)];
+
+ Assert.Contains("_dataService.GetCollectorDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)", body, StringComparison.Ordinal);
+ Assert.Contains("Task.WhenAll(collectionHealthTask, collectionLogTask, collectorDurationTask)", body, StringComparison.Ordinal);
+ Assert.Contains("UpdateCollectorDurationChart(collectorDurationTask.Result, hoursBack, fromDate, toDate)", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("UpdateCollectorDurationChart(collectionLogTask", body, StringComparison.Ordinal);
+ }
+
+ private async Task SeedLongQueryAsync(DateTime eventUtc, DateTime collectedUtc)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+INSERT INTO long_query_completions (long_query_completion_id, collection_time, server_id, server_name, event_time, event_type, database_name, statement_text, duration_microseconds)
+VALUES ({_nextId++}, {Literal(collectedUtc)}, {ServerId}, '{ServerName}', {Literal(eventUtc)}, 'rpc_completed', 'Db', 'SELECT 1', 5000000)";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ /// The Long Queries notice for a read UNDER its cap: the real read, the real probe, then the tab's own rule for
+ /// the floor ( over , the pair
+ /// RefreshCappedGridBannerAsync hands the shared step) and its banner step: (visible, text, rows shown).
+ private async Task<(bool Visible, string Text, int Rows)> UnderCapLongQueriesBannerAsync(DateTime startUtc, DateTime endUtc)
+ {
+ var service = new LocalDataService(_duckDb);
+ var rows = await service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: startUtc, toDate: endUtc);
+ var probed = await service.GetQueryWindowFloorAsync(QueryWindowRelation.LongQueryCompletions, ServerId, startUtc, endUtc, ServerClock.Utc);
+ var floor = ServerTab.EarlierOfFloorAndRowShown(probed, ServerTab.EarliestRowShown(rows, ServerTab.LongQueryRowTimeUtc));
+ var (visible, text) = OnStaThread(() =>
+ {
+ var banner = new System.Windows.Controls.TextBlock();
+ ServerTab.ApplyWindowFloorToBanner(banner, floor, startUtc, TimeZoneInfo.Utc);
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text);
+ });
+ return (visible, text, rows.Count);
+ }
+
+ ///
+ /// #4989: a server's first run (T0, 48 hours ago) stores a completion from 8 minutes before itself (a first run reads
+ /// back ), and the grid shows it at its
+ /// event time. Under the cap, on a 7-day range, the notice names that completion's time, T0 less 8 minutes, and not the
+ /// run's T0.
+ ///
+ [Fact]
+ public async Task LongQueries_UnderTheCap_NameTheOldestCompletionShown_WhenItPredatesTheFirstRun()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var firstRun = new DateTime(end.AddHours(-48).Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
+ await SeedLogRunsAsync("long_query_completions", firstRun, end, 60);
+ await SeedLongQueryAsync(firstRun.AddMinutes(-8), firstRun);
+ await SeedLongQueryAsync(firstRun.AddHours(2), firstRun.AddHours(2).AddSeconds(CollectedAfterSeconds));
+
+ var (visible, text, rows) = await UnderCapLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.InRange(rows, 1, LocalDataService.LongQueryGridCap - 1);
+ Assert.True(visible);
+ Assert.Equal(Since(firstRun.AddMinutes(-8)), text);
+ }
+
+ ///
+ /// #4989: with no completion older than the first run, the notice still names the run, as before.
+ ///
+ [Fact]
+ public async Task LongQueries_UnderTheCap_StillNameTheFirstRun_WhenNoCompletionShownIsOlderThanIt()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var firstRun = new DateTime(end.AddHours(-48).Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
+ await SeedLogRunsAsync("long_query_completions", firstRun, end, 60);
+ await SeedLongQueryAsync(firstRun.AddMinutes(30), firstRun.AddMinutes(30).AddSeconds(CollectedAfterSeconds));
+
+ var (visible, text, _) = await UnderCapLongQueriesBannerAsync(end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.Equal(Since(firstRun), text);
+ }
+
+ private static string StripComments(string lfSource) =>
+ Regex.Replace(Regex.Replace(lfSource, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline), @"//[^\n]*", string.Empty);
+
+ /// WPF objects require STA; same shape as DataStartBannerTests.
+ private static T OnStaThread(Func body)
+ {
+ T result = default!;
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { result = body(); }
+ catch (Exception ex) { error = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (error is not null)
+ {
+ throw error;
+ }
+
+ return result;
+ }
+
+ private static string ControlsFile(string name) => RepoFile("Lite", "Controls", name);
+
+ private static string RepoFile(string folder, string subFolderOrFile, string? file = null, [CallerFilePath] string thisFile = "") =>
+ Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", folder, subFolderOrFile, file ?? string.Empty));
+}
diff --git a/Lite.Tests/DataStartBannerTests.cs b/Lite.Tests/DataStartBannerTests.cs
index 2aa11bf4b..19e7be980 100644
--- a/Lite.Tests/DataStartBannerTests.cs
+++ b/Lite.Tests/DataStartBannerTests.cs
@@ -125,10 +125,14 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll
///
/// The relation list is a closed enum, so no view name ever comes from the caller. Each member must name a real
- /// archive view (v_ plus a table in the archivable set, archived by collection_time), with a
- /// server_id column and the column the probe windows on (QueryWindowRelationTimeColumn:
- /// collection_time, or sample_time for Memory Pressure Events, whose chart filters on it), which is
- /// what the probe's SQL assumes.
+ /// archive view (v_ plus a table in the archivable set) whose time column is the one
+ /// names for it (collection_time; capture_time
+ /// on the three config snapshots; event_time on the two event tables, #4966, #4989; sample_time for Memory
+ /// Pressure Events, whose chart filters on it), with a server_id column, which is what the probe's SQL assumes.
+ /// The archive purges each table by that same column except for the two event tables and Memory Pressure Events: their
+ /// grids and chart filter on the event's own time (event_time, sample_time), which is the column the probe
+ /// measures, while the archive still purges them by collection_time, the time a run stored the row. The purge
+ /// column is pinned for those three as collection_time, and the view must carry both.
///
[Fact]
public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndItsProbeTimeColumn()
@@ -138,18 +142,22 @@ public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndItsProbeTim
{
var view = LocalDataService.QueryWindowRelationView(relation);
var timeColumn = LocalDataService.QueryWindowRelationTimeColumn(relation);
- Assert.Contains(timeColumn, new[] { "collection_time", "sample_time" });
+ Assert.Contains(timeColumn, new[] { "collection_time", "capture_time", "event_time", "sample_time" });
Assert.StartsWith("v_", view, StringComparison.Ordinal);
var table = view[2..];
+ var purgeColumn = relation is QueryWindowRelation.SystemHealthEvents or QueryWindowRelation.DefaultTraceEvents
+ or QueryWindowRelation.MemoryPressureEvents
+ ? "collection_time"
+ : timeColumn;
Assert.Contains(table, DuckDbInitializer.ArchivableTables);
- Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == "collection_time");
+ Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == purgeColumn);
using var connection = _duckDb.CreateConnection();
await connection.OpenAsync();
using var readLock = _duckDb.AcquireReadLock();
using var cmd = connection.CreateCommand();
- cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', '{timeColumn}')";
- Assert.Equal(2L, Convert.ToInt64(await cmd.ExecuteScalarAsync()));
+ cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', '{timeColumn}', '{purgeColumn}')";
+ Assert.Equal(purgeColumn == timeColumn ? 2L : 3L, Convert.ToInt64(await cmd.ExecuteScalarAsync()));
}
}
diff --git a/Lite.Tests/DisplayZoneTextFrameTests.cs b/Lite.Tests/DisplayZoneTextFrameTests.cs
index 67f2876de..3051feec9 100644
--- a/Lite.Tests/DisplayZoneTextFrameTests.cs
+++ b/Lite.Tests/DisplayZoneTextFrameTests.cs
@@ -225,7 +225,9 @@ public void TheRefreshHeaderAndTheTruncationBanner_UseTheTabsOwnClockAndZone()
Assert.Contains(
"ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode, _serverClock, refreshedUtc)", refresh, StringComparison.Ordinal);
Assert.Contains("DisplayZone.ToDisplay(refreshedUtc, GetPickerZone())", refresh, StringComparison.Ordinal);
- Assert.Contains("ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone());", refresh, StringComparison.Ordinal);
+ /* #4989: the shared step words the earlier of the probe's floor and the oldest row a capped grid shows; the zone
+ it is worded in is still the tab's own picker zone. */
+ Assert.Contains("ApplyWindowFloorToBanner(banner, EarlierOfFloorAndRowShown(floor, earliestRowShownUtc), startUtc, GetPickerZone());", refresh, StringComparison.Ordinal);
Assert.Contains(
"internal static bool ApplyWindowFloorToBanner(TextBlock banner, DateTime? floor, DateTime startUtc, TimeZoneInfo zone)",
refresh, StringComparison.Ordinal);
diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs
index d821827ea..dd53fb92d 100644
--- a/Lite.Tests/QueryWindowTruncationTests.cs
+++ b/Lite.Tests/QueryWindowTruncationTests.cs
@@ -716,7 +716,9 @@ each take their banner window from GetQueriesTabWindowUtc too (see DataStartBann
$"expected all 10 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " +
$"(windowStart/windowEnd) (found {refreshBannerCallsOnHelperOutput}) -- a server-local cStart must " +
"not feed the banner (#4279/#4284).");
- Assert.Equal(10, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count);
+ /* 10 -> 11: the Collection Log's cap-aware notice (RefreshCappedGridBannerAsync, #4989) takes its window from
+ GetQueriesTabWindowUtc too; it is not one of the 10 RefreshWindowTruncatedBannerAsync calls counted above. */
+ Assert.Equal(11, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count);
}
private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name);
diff --git a/Lite.Tests/StoredEventCopiesSweepTests.cs b/Lite.Tests/StoredEventCopiesSweepTests.cs
index 34961f691..3f17c3ea8 100644
--- a/Lite.Tests/StoredEventCopiesSweepTests.cs
+++ b/Lite.Tests/StoredEventCopiesSweepTests.cs
@@ -49,6 +49,13 @@ counts and buckets count COUNT(DISTINCT StoredEventCopies.DeadlockIdentityTuple)
/* The two last-capture reads, MAX(collection_time) with no time window: a batch that stored only copies of
events already held still read the session, so its collection_time is a true capture. */
[("LocalDataService.SystemEvents.cs", "v_system_health_events")] = 2,
+
+ /* The window-floor probe (#4966): its QueryWindowRelationView map names the views, and the probe asks only for the
+ earliest time inside a window and whether any row sits before its start (LIMIT 1). A copy of an event is stored
+ later than its first copy and keeps the first copy's event_time, so it cannot move the earliest time or add an
+ older row. The probe returns no rows, so there is nothing to hide. */
+ [("LocalDataService.QueryWindowFloor.cs", "v_system_health_events")] = 1,
+ [("LocalDataService.QueryWindowFloor.cs", "v_long_query_completions")] = 1,
};
[Fact]
diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs
index dd7d5788b..5383f8309 100644
--- a/Lite/Controls/ServerTab.Charts.cs
+++ b/Lite/Controls/ServerTab.Charts.cs
@@ -1574,9 +1574,95 @@ is this tab. */
/* ========== Collection Health ========== */
- private void UpdateCollectorDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate)
+ ///
+ /// One collector's line on the Duration Trends chart (#4989): the X of each point (a UTC instant as an OADate, the
+ /// bucket's start), the Y it draws (the bucket's slowest run, in ms) and the hover line that says what stands behind
+ /// the point (), all in the same order.
+ ///
+ internal sealed record CollectorDurationSeries(string Collector, double[] Xs, double[] MaxMs, string[] Details)
+ {
+ ///
+ /// The hover lines keyed by the X each point is drawn at: the very the scatter is built from, so the
+ /// hover's lookup by the nearest point's X is exact. Keyed by X and not by position because the line breaks at every
+ /// collection gap by inserting a point of its own, which shifts every position after the first break; that point
+ /// has no key and so no line.
+ ///
+ internal IReadOnlyDictionary DetailsByX()
+ {
+ var byX = new Dictionary(Xs.Length);
+ for (var i = 0; i < Xs.Length && i < Details.Length; i++)
+ {
+ byX[Xs[i]] = Details[i];
+ }
+
+ return byX;
+ }
+ }
+
+ ///
+ /// The hover line of one Duration Trends point (#4989): the point draws its bucket's slowest run, so the line says how
+ /// many runs it is the slowest of and what the average run took.
+ ///
+ internal static string CollectorDurationDetail(CollectorDurationBucket bucket)
+ {
+ static string Ms(double value) => value == Math.Floor(value)
+ ? value.ToString("N0", System.Globalization.CultureInfo.CurrentCulture)
+ : value.ToString("N1", System.Globalization.CultureInfo.CurrentCulture);
+
+ return $"Slowest of {bucket.RunCount.ToString("N0", System.Globalization.CultureInfo.CurrentCulture)} {(bucket.RunCount == 1 ? "run" : "runs")}; average {Ms(bucket.AverageDurationMs)} ms";
+ }
+
+ ///
+ /// What the Duration Trends chart draws from its own read (,
+ /// #4989): one series per collector, in collector order, each point a bucket at its start and the bucket's slowest
+ /// run as its height. A collector with fewer than two buckets draws no line, as before. internal static so the
+ /// tests see exactly what is drawn without building the UserControl.
+ ///
+ internal static List BuildCollectorDurationSeries(IEnumerable buckets) =>
+ buckets
+ .GroupBy(b => b.CollectorName)
+ .OrderBy(g => g.Key)
+ .Select(g => g.OrderBy(b => b.BucketStart).ToList())
+ .Where(points => points.Count >= 2)
+ .Select(points => new CollectorDurationSeries(
+ points[0].CollectorName,
+ points.Select(b => b.BucketStart.ToOADate()).ToArray(),
+ points.Select(b => b.MaxDurationMs).ToArray(),
+ points.Select(CollectorDurationDetail).ToArray()))
+ .ToList();
+
+ ///
+ /// Draws the series on (the lines only: the axes, the legend and the refresh stay with the
+ /// caller) and registers each with when there is one, with its per-point detail lines.
+ /// internal static so the tests drive it on a real chart.
+ ///
+ internal static void PlotCollectorDurationSeries(ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, IReadOnlyList series)
+ {
+ int colorIdx = 0;
+ foreach (var line in series)
+ {
+ var scatter = chart.Plot.Add.TimeSeries(line.Xs, line.MaxMs);
+ scatter.LegendText = line.Collector;
+ scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 0;
+ hover?.Add(scatter, line.Collector, line.DetailsByX());
+ colorIdx++;
+ }
+ }
+
+ ///
+ /// The Duration Trends chart (#4989): each collector's slowest run per time bucket over the WHOLE asked range, read
+ /// beside the Collection Log grid (), not the grid's page of
+ /// the newest runs it used to be handed. The X axis is pinned to the
+ /// asked range, so a range the store does not cover from its start still draws the empty span it is.
+ ///
+ private void UpdateCollectorDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate)
{
ClearChart(CollectorDurationChart);
+ /* Every render starts the hover empty, the empty range's early return below included: it holds the last render's
+ series, and a tooltip over a chart with nothing on it would still name them. */
+ _collectorDurationHover?.Clear();
ApplyTheme(CollectorDurationChart);
/* Pin the X axis to the settable window (the same idiom as the CPU / tempdb-size charts) rather than
@@ -1595,35 +1681,14 @@ which reads as symmetric dead space. This is the one chart the window-pin campai
return;
}
- /* Group by collector, plot each as a separate series */
- var groups = data
- .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS")
- .GroupBy(d => d.CollectorName)
- .OrderBy(g => g.Key)
- .ToList();
-
- _collectorDurationHover?.Clear();
- int colorIdx = 0;
- foreach (var group in groups)
- {
- var points = group.OrderBy(d => d.CollectionTime).ToList();
- if (points.Count < 2) continue;
-
- var times = points.Select(d => d.CollectionTime.ToOADate()).ToArray();
- var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray();
-
- var scatter = CollectorDurationChart.Plot.Add.TimeSeries(times, durations);
- scatter.LegendText = group.Key;
- scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]);
- scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
- _collectorDurationHover?.Add(scatter, group.Key);
- colorIdx++;
- }
+ /* One series per collector, each point a bucket's slowest successful run (the read keeps only the runs the chart
+ has always drawn: SUCCESS with a duration). */
+ PlotCollectorDurationSeries(CollectorDurationChart, _collectorDurationHover, BuildCollectorDurationSeries(data));
CollectorDurationChart.Plot.Axes.DateTimeTicksBottomUtc(GetPickerZone);
ReapplyAxisColors(CollectorDurationChart);
- CollectorDurationChart.Plot.YLabel("Duration (ms)");
+ /* Each point is a bucket's slowest run, and the label says so (the Darling viewer's chart uses the same words). */
+ CollectorDurationChart.Plot.YLabel("Slowest run per bucket (ms)");
CollectorDurationChart.Plot.Axes.AutoScaleY();
CollectorDurationChart.Plot.Axes.SetLimitsX(xMin, xMax);
ShowChartLegend(CollectorDurationChart);
diff --git a/Lite/Controls/ServerTab.ConfigChanges.cs b/Lite/Controls/ServerTab.ConfigChanges.cs
index 883b5fe31..2c647c000 100644
--- a/Lite/Controls/ServerTab.ConfigChanges.cs
+++ b/Lite/Controls/ServerTab.ConfigChanges.cs
@@ -67,6 +67,7 @@ private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(int hours
_serverConfigChangesFilterMgr!.UpdateData(data);
ShowEngineGap(ServerConfigChangesNoDataMessage, "server_config", data.Count, keepsOwnEmptyText: true);
ServerConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.ServerConfig, ServerConfigChangesWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -75,6 +76,7 @@ private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(int hou
_databaseConfigChangesFilterMgr!.UpdateData(data);
DatabaseConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
DatabaseConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.DatabaseConfig, DatabaseConfigChangesWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -83,6 +85,7 @@ private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(int hoursBac
_traceFlagChangesFilterMgr!.UpdateData(data);
ShowEngineGap(TraceFlagChangesNoDataMessage, "trace_flags", data.Count, keepsOwnEmptyText: true);
TraceFlagChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.TraceFlags, TraceFlagChangesWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
/// The per-sub-tab Refresh button reloads the active Configuration Changes sub-tab over the
diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs
index c5bb8a1ed..3b6b039e9 100644
--- a/Lite/Controls/ServerTab.LongQueries.cs
+++ b/Lite/Controls/ServerTab.LongQueries.cs
@@ -28,12 +28,30 @@ internal static string LongQueriesDisabledText(bool isAzureSqlDatabase) =>
+ "the 'long_query_completions' Enabled box. "
+ LongQueryCompletionsCollector.SessionScopeSentence(isAzureSqlDatabase);
+ ///
+ /// The instant the Long Queries grid's "Showing since" notice compares (#4989): the completion's own time,
+ /// event_time, the XE @timestamp, which is UTC (the grid shows it through the server's clock, and the
+ /// read orders and caps on it), and not the time a run stored the row. A completion without an event time falls back to
+ /// the time its run was collected, which is UTC too. The Default Trace is the one grid whose time is the server's wall
+ /// clock (); this one needs no conversion.
+ ///
+ internal static DateTime LongQueryRowTimeUtc(LongQueryCompletionRow row) => row.EventTime ?? row.CollectionTime;
+
///
/// Tab 20 — Long Queries (#1496): completed long-running queries (rpc/batch over the duration
/// threshold) plus attentions (cancels/timeouts) from the opt-in XE session. Because the collector
/// is default OFF, an explicit empty-state banner is shown while it is disabled, pointing the
/// operator at the exact switch (Settings → Collector Schedules → Edit → the Enabled box). The
/// enabled state is read live each refresh so toggling it updates the banner without reopening.
+ ///
+ /// The grid reads only the newest completions, so its "Showing
+ /// since" notice goes through the cap-aware step (#4989): a read that hit the cap is worded from the oldest completion
+ /// it returned, even where the store covers the range, and with no slack (the notice shows whenever that completion is
+ /// later than the range's start, on a range of an hour too). A read under the cap keeps the coverage notice, unless a
+ /// completion it shows has an earlier event time: a server's first run stores completions up to
+ /// older than itself, and the grid shows each at its event time
+ /// (), so the notice then names the oldest completion shown
+ /// (), the same rule the Darling viewer's event grids follow.
///
private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
{
@@ -51,6 +69,8 @@ private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, Da
/* View-only DESCENDING-by-duration sort — never flips the reader's chronological ORDER BY. */
SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending);
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshCappedGridBannerAsync(QueryWindowRelation.LongQueryCompletions, LongQueriesWindowTruncatedBanner, windowStart, windowEnd, task.Result, LocalDataService.LongQueryGridCap, LongQueryRowTimeUtc);
}
catch (Exception ex)
{
diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs
index bee95c268..5dffe842d 100644
--- a/Lite/Controls/ServerTab.Refresh.cs
+++ b/Lite/Controls/ServerTab.Refresh.cs
@@ -501,7 +501,10 @@ await System.Threading.Tasks.Task.WhenAll(
/// matching MCP tool uses, so the grid and the tool never disagree about whether a window was cut short.
/// Every surface that carries the banner reaches it here: the three Queries grids, Active Queries, Current Waits,
/// the Query Heatmap, Memory Pressure Events and Plan Corrections (the last one through
- /// , when its read is under its cap). Called from the sub-tab switch
+ /// , when its read is under its cap), and, over the toolbar's window, the
+ /// System Events, Default Trace and Config Changes grids (through RefreshStoredWindowBannerAsync) and the two
+ /// grids that read a capped page, the Collection Log and Long Queries (through the cap-aware step, like Plan
+ /// Corrections). Called from the sub-tab switch
/// and full-refresh paths below, from the slicer handlers in ServerTab.Slicers.cs, from the Active Queries
/// drill-downs in ServerTab.DrillDown.cs and from the helpers in ServerTab.QueriesDataStart.cs — a slicer drag
/// re-reads the same grid over a narrower window, which
@@ -519,15 +522,37 @@ await System.Threading.Tasks.Task.WhenAll(
/// #4966: a window no longer than the 90-minute slack () can
/// never get a coverage note, so the probe is not called for it: the banner is hidden and its text cleared, as for
/// any window the probe finds nothing to report on.
+ ///
+ /// #4989: a capped grid under its cap hands , the oldest of its rows by the
+ /// time each is shown on. The notice then names the earlier of the probe's floor and that row
+ /// (): the Long Queries grid shows each completion at its event time, which on a
+ /// server's first run can be up to before the run that stored it,
+ /// and the probe measures the run's time. Every other caller leaves it out and gets the probe's floor as before.
///
- private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc)
+ private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc, DateTime? earliestRowShownUtc = null)
{
var floor = await ProbeWindowFloorOrNullAsync(
() => Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)),
$"[{_server.DisplayName}] {relation}", startUtc, endUtc);
- ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone());
+ ApplyWindowFloorToBanner(banner, EarlierOfFloorAndRowShown(floor, earliestRowShownUtc), startUtc, GetPickerZone());
}
+ ///
+ /// #4989: the floor a capped grid's notice is worded from when its read stayed UNDER its cap: the probe's floor
+ /// (), or the oldest row the grid shows () when that is
+ /// earlier, so a notice never names a time later than a row on the screen. The same rule as the Darling viewer's event
+ /// grids (ViewerEventDataStart.Of). A null floor stays null: the probe found nothing to report on, its failure
+ /// hides the banner, and a window no longer than the slack never gets one, and a row shown does not change any of those.
+ /// For a grid whose rows are shown on the probe's own column (the Collection Log and Plan Corrections) the probe's floor is
+ /// never later than its oldest row, so this returns the floor unchanged.
+ ///
+ internal static DateTime? EarlierOfFloorAndRowShown(DateTime? probedFloor, DateTime? earliestRowShownUtc) =>
+ probedFloor is DateTime floor && earliestRowShownUtc is DateTime shown && shown < floor ? shown : probedFloor;
+
+ /// The oldest of a capped grid's by , or null when it shows none.
+ internal static DateTime? EarliestRowShown(IReadOnlyCollection rows, Func rowTimeUtc) =>
+ rows.Count == 0 ? null : rows.Min(rowTimeUtc);
+
///
/// The answer of a window-floor probe, or null when the probe throws, or when the window is no longer than the
/// slack (): such a window can never get a coverage note, so the
@@ -568,8 +593,8 @@ private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(Quer
/// than the window's start), while a probed floor keeps 's 90-minute slack,
/// which absorbs a first collection that lands a little after the window starts. The ONE
/// decision for every capped grid: calls it, and the grids that read
- /// the newest N rows (Plan Corrections now; Long Queries, Blocked Process Reports and Deadlocks as they adopt it)
- /// pass their rows, their cap and the column their rows are windowed on.
+ /// the newest N rows (Plan Corrections, the Collection Log and Long Queries now; Blocked Process Reports and
+ /// Deadlocks as they adopt it) pass their rows, their cap and the time their rows are shown and capped on.
///
internal static DateTime? CapAwareWindowFloor(DateTime? probedFloor, IReadOnlyCollection rows, int rowCap, Func rowTimeUtc) =>
rowCap > 0 && rows.Count >= rowCap ? rows.Min(rowTimeUtc) : probedFloor;
@@ -580,7 +605,8 @@ private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(Quer
/// no probe, and no slack: its reach is what the grid shows, whatever the store holds, and the banner shows whenever
/// that oldest row is later than the window's start (), even on a range
/// of an hour. Below its cap it is the shared step,
- /// , with its probe, as for every other grid. The decision itself is
+ /// , with its probe, as for every other grid, handed the oldest row the
+ /// grid shows so that the notice never names a time later than it (, #4989). The decision itself is
/// , which this hands the two ways out: the banner worded in the tab's picker
/// zone from the oldest row, and that shared step.
///
@@ -589,7 +615,7 @@ private System.Threading.Tasks.Task RefreshCappedGridBannerAsync(
IReadOnlyCollection rows, int rowCap, Func rowTimeUtc) =>
CappedGridBannerAsync(rows, rowCap, rowTimeUtc,
oldestRowShown => ApplyCappedWindowFloorToBanner(banner, oldestRowShown, startUtc, GetPickerZone()),
- () => RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc));
+ () => RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc, EarliestRowShown(rows, rowTimeUtc)));
///
/// #4966: the decision inside . A read that reached its cap
@@ -1063,8 +1089,11 @@ private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hours
{
var collectionHealthTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Health", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId))));
var collectionLogTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Log", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack, fromDate, toDate))));
+ /* #4989: the Duration Trends chart reads its own buckets over the whole range, beside the grid's read. The grid's
+ page is the newest CollectionLogGridCap runs, a sliver of a long range, so the chart is not fed from it. */
+ var collectorDurationTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.DurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCollectorDurationTrendAsync(_serverId, hoursBack, fromDate, toDate))));
- await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask);
+ await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask, collectorDurationTask);
/* #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,
@@ -1075,7 +1104,10 @@ and the stamped one also holds when the display mode flips or another tab is sel
_collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
_collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
- UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate);
+ UpdateCollectorDurationChart(collectorDurationTask.Result, hoursBack, fromDate, toDate);
+ /* #4989: the grid reads only the newest CollectionLogGridCap runs, so its notice goes through the cap-aware step. */
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshCappedGridBannerAsync(QueryWindowRelation.CollectionLog, CollectionLogWindowTruncatedBanner, windowStart, windowEnd, collectionLogTask.Result, LocalDataService.CollectionLogGridCap, row => row.CollectionTime);
}
catch (Exception ex)
{
diff --git a/Lite/Controls/ServerTab.SystemEvents.cs b/Lite/Controls/ServerTab.SystemEvents.cs
index 6692539c6..5e7792e3d 100644
--- a/Lite/Controls/ServerTab.SystemEvents.cs
+++ b/Lite/Controls/ServerTab.SystemEvents.cs
@@ -106,6 +106,7 @@ private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(int hoursBack
_seSchedulerFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(SchedulerIssuesNoDataMessage, data.Count);
SchedulerIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SchedulerIssuesWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadSevereErrorsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -114,6 +115,7 @@ private async System.Threading.Tasks.Task LoadSevereErrorsAsync(int hoursBack, D
_seSevereErrorFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(SevereErrorsNoDataMessage, data.Count);
SevereErrorsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SevereErrorsWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -122,6 +124,7 @@ private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(int hoursBac
_seMemoryConditionsFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(MemoryConditionsNoDataMessage, data.Count);
MemoryConditionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryConditionsWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -130,6 +133,7 @@ private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(int hoursBack, D
_seMemoryBrokerFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(MemoryBrokerNoDataMessage, data.Count);
MemoryBrokerCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryBrokerWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -138,6 +142,7 @@ private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(int hoursBack,
_seMemoryNodeOomFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(MemoryNodeOomNoDataMessage, data.Count);
MemoryNodeOomCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryNodeOomWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -146,6 +151,7 @@ private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(int hoursBac
_seSignificantWaitsFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(SignificantWaitsNoDataMessage, data.Count);
SignificantWaitsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SignificantWaitsWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadCpuTasksAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -154,6 +160,7 @@ private async System.Threading.Tasks.Task LoadCpuTasksAsync(int hoursBack, DateT
_seCpuTasksFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(CpuTasksNoDataMessage, data.Count);
CpuTasksCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, CpuTasksWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadIoIssuesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -162,6 +169,7 @@ private async System.Threading.Tasks.Task LoadIoIssuesAsync(int hoursBack, DateT
_seIoIssuesFilterMgr!.UpdateData(data);
ShowSystemHealthEmptyState(IoIssuesNoDataMessage, data.Count);
IoIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, IoIssuesWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
@@ -172,6 +180,7 @@ private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursB
DefaultTraceNoDataMessage.Text = gap;
DefaultTraceNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
DefaultTraceCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : "";
+ await RefreshStoredWindowBannerAsync(QueryWindowRelation.DefaultTraceEvents, DefaultTraceWindowTruncatedBanner, hoursBack, fromDate, toDate);
}
///
@@ -199,6 +208,22 @@ private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursB
internal static string? SystemHealthGapNote(string serverName, bool isAzureSqlDatabase) =>
EngineGapNote(serverName, isAzureSqlDatabase, "system_health_events");
+ ///
+ /// #4966: the "Showing since" banner of one grid that reads stored rows over the toolbar's window (the System Events and
+ /// Default Trace grids and the Config Changes grids: none of them caps its read; the Collection Log and Long Queries
+ /// read a capped page and go through over the same window, #4989). It probes
+ /// the UTC window the grid's read took, the same pair
+ /// (), through , so a
+ /// probe that throws hides the banner and the refresh goes on. Each surface calls it after its rows are bound, from the one
+ /// method its read runs through, so every read path of the surface (the sub-tab switch, a range change and the Refresh
+ /// button) refreshes it.
+ ///
+ private System.Threading.Tasks.Task RefreshStoredWindowBannerAsync(QueryWindowRelation relation, TextBlock banner, int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ var (startUtc, endUtc) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ return RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc);
+ }
+
/// A system_health grid's empty state: shown when the window has no rows. On an Azure SQL Database it says
/// that the collector does not run there, in place of the grid's "no events in this window" text.
private void ShowSystemHealthEmptyState(TextBlock message, int rowCount)
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 498974e50..9604793e2 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -2647,6 +2647,7 @@
+
+
@@ -2772,6 +2774,7 @@
+
@@ -2801,6 +2804,7 @@
+
@@ -2852,6 +2856,7 @@
+
@@ -2884,6 +2889,7 @@
+
@@ -2931,6 +2937,7 @@
+
@@ -2960,6 +2967,7 @@
+
@@ -2990,6 +2998,7 @@
+
@@ -3016,6 +3025,7 @@
+
@@ -3064,6 +3074,7 @@
+
@@ -3094,6 +3105,7 @@
+
@@ -3119,6 +3131,7 @@
+
@@ -3152,8 +3165,14 @@
-
+
+
+
+
+
+ /// The Collection Log grid's row cap (#4989): the default maxRows of ,
+ /// so the grid reads the newest this many runs of the window. The grid's "Showing since" notice reads it too, as the
+ /// cap that says the grid dropped older runs (ServerTab.RefreshCappedGridBannerAsync), so the read's
+ /// LIMIT and the notice's cap are the one value.
+ ///
+ public const int CollectionLogGridCap = 500;
+
///
/// Gets recent collection log entries for a server, most recent first, bounded to the tab's
/// settable window. A preset ends "now" ( from now); a custom range
@@ -518,7 +526,7 @@ FROM v_collection_log
///
/// The desktop Collection Log tab passes neither and is unaffected: no filter, newest first.
///
- public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = 500, DateTime? asOfUtc = null, string? collectorName = null, double? minDurationMs = null, string? status = null)
+ public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = CollectionLogGridCap, DateTime? asOfUtc = null, string? collectorName = null, double? minDurationMs = null, string? status = null)
{
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -582,6 +590,66 @@ FROM v_collection_log
return items;
}
+ ///
+ /// The Duration Trends chart's own read (#4989): per collector and time bucket over the WHOLE asked range, the slowest
+ /// run, the average run and the run count, over the successful runs that carry a duration (the runs the chart has always
+ /// drawn). The chart used to be handed the Collection Log grid's page, the newest
+ /// runs, while its X axis is pinned to the asked range: at about twenty runs a minute that page holds the newest
+ /// twenty-five minutes, so "Last 24 hours" drew the newest 25 minutes and left 23 hours and 35 minutes of axis empty
+ /// though the store holds those runs. This read has no row cap and answers at the width the other desktop trend charts
+ /// use for the range (, one series' share of , as the
+ /// Performance Trends charts size their buckets), so the number of points is bounded by the range, not by the runs.
+ ///
+ /// Each bucket's start is clamped to the window's start (GREATEST, as the bucketed duration-trend reads
+ /// do), so an unaligned window's first, partial bucket does not draw before it. The chart draws each bucket's MAXIMUM,
+ /// so a slow run still shows as it did when every run was a point; the average and the count ride along for its hover.
+ /// The window is the grid's own (), so the chart and the grid beside it describe one span.
+ ///
+ public async Task> GetCollectorDurationTrendAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc);
+
+ command.CommandText = $@"
+SELECT
+ collector_name,
+ GREATEST(time_bucket(to_minutes(CAST($4 AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start,
+ MAX(duration_ms) AS max_duration_ms,
+ AVG(duration_ms) AS average_duration_ms,
+ COUNT(*) AS run_count
+FROM v_collection_log
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+AND status = 'SUCCESS'
+AND duration_ms IS NOT NULL
+GROUP BY 1, 2
+ORDER BY 1, 2";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+ command.Parameters.Add(new DuckDBParameter { Value = AutoChartBucketMinutes(startTime, endTime) });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new CollectorDurationBucket
+ {
+ CollectorName = reader.GetString(0),
+ BucketStart = reader.GetDateTime(1),
+ MaxDurationMs = Convert.ToDouble(reader.GetValue(2)),
+ AverageDurationMs = Convert.ToDouble(reader.GetValue(3)),
+ RunCount = Convert.ToInt64(reader.GetValue(4))
+ });
+ }
+
+ return items;
+ }
+
///
/// Whether ANY of the given (enabled) servers has EVER recorded a collector run — the fleet-wide twin of
/// (#4199), used the same way: to tell a genuinely quiet fleet-wide
@@ -780,6 +848,21 @@ public class CollectionLogRow
public string DuckDbDurationFormatted => DuckDbDurationMs.HasValue ? $"{DuckDbDurationMs.Value} ms" : "";
}
+///
+/// One point of the Duration Trends chart (#4989): a collector's successful runs inside one time bucket of the asked
+/// range (). The chart draws ; the
+/// hover also names and . is a UTC
+/// instant, clamped to the start of the range.
+///
+public class CollectorDurationBucket
+{
+ public string CollectorName { get; set; } = "";
+ public DateTime BucketStart { get; set; }
+ public double MaxDurationMs { get; set; }
+ public double AverageDurationMs { get; set; }
+ public long RunCount { get; set; }
+}
+
///
/// One Collection Health grid row — a collector's 7-day roll-up with its health band.
/// delegates to the shared in
diff --git a/Lite/Services/LocalDataService.LongQueries.cs b/Lite/Services/LocalDataService.LongQueries.cs
index ed60eb080..39a774dbb 100644
--- a/Lite/Services/LocalDataService.LongQueries.cs
+++ b/Lite/Services/LocalDataService.LongQueries.cs
@@ -17,6 +17,14 @@ namespace PerformanceMonitorLite.Services;
public partial class LocalDataService
{
+ ///
+ /// The Long Queries grid's row cap (#4989): returns the newest this many
+ /// completions of the window, by event_time. It is the one value behind the read's LIMIT and behind the
+ /// grid's "Showing since" notice, which names the oldest completion the grid shows once a read comes back at the cap
+ /// (ServerTab.RefreshCappedGridBannerAsync), so the two cannot drift apart.
+ ///
+ public const int LongQueryGridCap = 200;
+
///
/// Recent long-running query completions (#1496) from v_long_query_completions (the archive
/// union view), for the Long Queries grid. Reads the most recent completions in the window; the grid
@@ -38,9 +46,9 @@ public async Task> GetRecentLongQueryCompletionsAsy
var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues);
command.CommandText = LongQueryCompletionsSelect + @"
-FROM " + StoredEventCopies.LongQueryCompletions("server_id = $1 AND collection_time <= $3" + dbClause, collectedFrom: "$2") + @" AS ev
+FROM " + StoredEventCopies.LongQueryCompletions("server_id = $1 AND collection_time <= $3" + dbClause, collectedFrom: "$2") + $@" AS ev
ORDER BY event_time DESC
-LIMIT 200";
+LIMIT {LongQueryGridCap}";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
command.Parameters.Add(new DuckDBParameter { Value = startTime });
diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs
index b453fc20e..fdca2e885 100644
--- a/Lite/Services/LocalDataService.QueryWindowFloor.cs
+++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs
@@ -9,12 +9,16 @@
using System;
using System.Threading.Tasks;
using DuckDB.NET.Data;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitorLite.Database;
namespace PerformanceMonitorLite.Services;
///
/// The relations a window-floor probe () may read. Each is a
-/// closed member that maps to ONE v_ view with server_id and collection_time columns, so no
+/// closed member that maps to ONE v_ view with server_id and a time column (collection_time,
+/// capture_time on the three config snapshots, or event_time on the two event tables: the column the
+/// surface's grid filters on, ), so no
/// caller hands a view name, and nothing a user typed, into the probe's SQL. #4231 began with the three RAW-ONLY
/// relations (, , ): every
/// Queries-tab grid and MCP tool that reads one has no rollup underneath it to fall back on when the requested
@@ -33,25 +37,22 @@ public enum QueryWindowRelation
ProcedureStats,
QueryStoreStats,
QuerySnapshots,
+
+ /* Group C of #4966: Collection Health, System Events, Config Changes and Long Queries. */
+ CollectionLog,
+ SystemHealthEvents,
+ DefaultTraceEvents,
+ ServerConfig,
+ DatabaseConfig,
+ TraceFlags,
+ LongQueryCompletions,
+
WaitingTasks,
MemoryPressureEvents
}
public partial class LocalDataService
{
- ///
- /// The column the probe windows a relation's view on: collection_time for every relation but Memory
- /// Pressure Events, whose chart filters on sample_time (the ring-buffer event's own time, which can sit long
- /// before the first collection that stored it), so the probe asks the question the chart's own read asks. The
- /// coverage side (v_collection_log) always reads collection_time. A closed map, so nothing a caller
- /// passes reaches the probe's SQL.
- ///
- internal static string QueryWindowRelationTimeColumn(QueryWindowRelation relation) => relation switch
- {
- QueryWindowRelation.MemoryPressureEvents => "sample_time",
- _ => "collection_time"
- };
-
internal static string QueryWindowRelationView(QueryWindowRelation relation) => relation switch
{
QueryWindowRelation.QueryStats => "v_query_stats",
@@ -60,6 +61,14 @@ public partial class LocalDataService
QueryWindowRelation.ProcedureStats => "v_procedure_stats",
QueryWindowRelation.QueryStoreStats => "v_query_store_stats",
QueryWindowRelation.QuerySnapshots => "v_query_snapshots",
+ /* Group C of #4966: Collection Health, System Events, Config Changes and Long Queries. */
+ QueryWindowRelation.CollectionLog => "v_collection_log",
+ QueryWindowRelation.SystemHealthEvents => "v_system_health_events",
+ QueryWindowRelation.DefaultTraceEvents => "v_default_trace_events",
+ QueryWindowRelation.ServerConfig => "v_server_config",
+ QueryWindowRelation.DatabaseConfig => "v_database_config",
+ QueryWindowRelation.TraceFlags => "v_trace_flags",
+ QueryWindowRelation.LongQueryCompletions => "v_long_query_completions",
QueryWindowRelation.WaitingTasks => "v_waiting_tasks",
QueryWindowRelation.MemoryPressureEvents => "v_memory_pressure_events",
_ => throw new ArgumentOutOfRangeException(nameof(relation), relation, "unknown QueryWindowRelation")
@@ -68,10 +77,10 @@ public partial class LocalDataService
///
/// The collector whose runs collection_log records for a relation the probe reads by coverage
/// (, ,
- /// and ), or
- /// null for the three Queries-tab relations (the Query Heatmap reads the first of them), which keep the row-only
- /// probe. A closed map, so nothing a caller passes
- /// reaches the probe's SQL.
+ /// , , and #4966's
+ /// System Events, Config Changes and Long Queries relations), or null for the three Queries-tab relations (the Query
+ /// Heatmap reads the first of them) and , which keep the row-only
+ /// probe. A closed map, so nothing a caller passes reaches the probe's SQL.
///
internal static string? QueryWindowRelationCollector(QueryWindowRelation relation) => relation switch
{
@@ -80,9 +89,60 @@ public partial class LocalDataService
QueryWindowRelation.QuerySnapshots => "query_snapshots",
QueryWindowRelation.WaitingTasks => "waiting_tasks",
QueryWindowRelation.MemoryPressureEvents => "memory_pressure_events",
+ /* Group C of #4966. Every event and snapshot table below holds a row only when something happened or changed
+ (system_health and default trace events, long-query completions, and the config snapshots, which the
+ collectors take at load), so a collector's runs are what show the store covered a quiet start.
+ CollectionLog is the run log itself, one dense row per run, so it keeps the row-only probe. */
+ QueryWindowRelation.SystemHealthEvents => "system_health_events",
+ QueryWindowRelation.DefaultTraceEvents => "default_trace_events",
+ QueryWindowRelation.ServerConfig => "server_config",
+ QueryWindowRelation.DatabaseConfig => "database_config",
+ QueryWindowRelation.TraceFlags => "trace_flags",
+ QueryWindowRelation.LongQueryCompletions => "long_query_completions",
_ => null
};
+ ///
+ /// The time column of a relation's view that the probe compares against the window (#4966): the column the surface's
+ /// grid filters on, so a banner never names a time later than the earliest row its grid shows. That is
+ /// collection_time for most relations, capture_time for the three config snapshots (their collectors stamp
+ /// it instead, ICollectorSchemaInfo.PrefixTimeColumnName, and the archive purges by it), and event_time
+ /// for the two event relations (#4989): the System Events grids that read system_health events and the Default Trace
+ /// grid filter on the event's own time, not on the time a run stored it. A server's first run can store events from
+ /// before itself, every row stamped with that run's collection_time while its event_time is older, and the
+ /// probe reading collection_time there named the run above rows from before it. How far back depends on the
+ /// collector: the Default Trace's first run stores the history the trace files already hold, which can go back days,
+ /// while a first run of the system_health collector (and of Long Queries) reads back only
+ /// , 10 minutes. Long Queries stays on
+ /// collection_time, the column its grid filters on; the grid's notice also takes the oldest completion it shows
+ /// into account (ServerTab.EarlierOfFloorAndRowShown). The archive still purges those two tables by
+ /// collection_time. The system_health event_time is the XE @timestamp,
+ /// UTC, like every other column here. The Default Trace's event_time is the exception: it is the monitored server's
+ /// wall clock as stored (its grid converts each row through the server's clock), so the probe does the same
+ /// (, #4989): it reads that relation's rows over the grid's padded
+ /// server-local window, converts each one to UTC through the clock, and compares the converted times with the UTC
+ /// window. Memory Pressure Events windows on sample_time (the ring-buffer event's own time, which can sit long
+ /// before the first collection that stored it), so the probe asks the question the chart's own read asks. A closed map,
+ /// so nothing a caller passes reaches the probe's SQL. The collector's own runs in v_collection_log always read
+ /// collection_time, which is UTC.
+ ///
+ internal static string QueryWindowRelationTimeColumn(QueryWindowRelation relation) => relation switch
+ {
+ QueryWindowRelation.ServerConfig or QueryWindowRelation.DatabaseConfig or QueryWindowRelation.TraceFlags => "capture_time",
+ QueryWindowRelation.SystemHealthEvents or QueryWindowRelation.DefaultTraceEvents => "event_time",
+ QueryWindowRelation.MemoryPressureEvents => "sample_time",
+ _ => "collection_time"
+ };
+
+ ///
+ /// Whether the relation's time column () holds the monitored server's LOCAL
+ /// wall-clock time instead of UTC (#4989). Only the Default Trace does: ft.StartTime is stored as the trace wrote
+ /// it, and converts each row to UTC through the server's clock. Every other
+ /// relation's column is UTC, system_health's included (the XE @timestamp), and keeps the probe's single-query path.
+ ///
+ internal static bool QueryWindowRelationTimeIsServerLocal(QueryWindowRelation relation) =>
+ relation == QueryWindowRelation.DefaultTraceEvents;
+
///
/// Where this server's data starts for the requested window, as far as any caller needs to know it. NULL when
/// the server holds no row inside [, ] at all (nothing was
@@ -136,8 +196,18 @@ public partial class LocalDataService
/// an old row or the start, either of which would read as the window having been served) or when its first row
/// is already at the start. Only when that first row sits after the start does a second query ask whether the
/// server holds ANY row before the start, LIMIT 1, which stops at the first hit.
+ ///
+ /// A time column in the server's wall clock (the Default Trace, #4989). The Default Trace's event_time
+ /// is the monitored server's local time as stored, and its grid () converts each
+ /// row to UTC through the server's clock, so the probe does too, through : the server's OWN
+ /// clock (#4766), or when it is null, which is what that grid
+ /// falls back to. For that relation the two steps above read the candidate rows in the grid's padded server-local window,
+ /// convert each to UTC with the offset at the row's own date (so the answer is exact across a daylight-saving change), and
+ /// take the earlier of the first row and the collector's first run, which is UTC, in C#
+ /// (GetServerLocalQueryWindowFloorAsync). Every UTC relation, system_health's included (the XE @timestamp),
+ /// keeps the single-query path and ignores the clock.
///
- public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc)
+ public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc, ServerClock? serverClock = null)
{
var view = QueryWindowRelationView(relation);
using var _q = TimeQuery("GetQueryWindowFloorAsync", $"{view} window floor");
@@ -145,6 +215,15 @@ public partial class LocalDataService
var collector = QueryWindowRelationCollector(relation);
var timeColumn = QueryWindowRelationTimeColumn(relation);
+
+ /* #4989: a time column that holds the server's wall clock (the Default Trace) goes through the clock, the way the
+ grid reads it. Every UTC relation keeps the single-query path below. */
+ if (QueryWindowRelationTimeIsServerLocal(relation))
+ {
+ return await GetServerLocalQueryWindowFloorAsync(
+ connection, view, timeColumn, collector, serverId, startUtc, endUtc, serverClock ?? ServerTimeHelper.ActiveServerClock);
+ }
+
DateTime? firstInWindow;
using (var windowCommand = connection.CreateCommand())
{
@@ -194,6 +273,13 @@ SELECT 1
}
/* No older row: an older run of the collector still proves the window is covered. */
+ return await HasCollectorRunBeforeAsync(connection, collector, serverId, startUtc) ? startUtc : firstRow;
+ }
+
+ /// Whether the collector's runs in v_collection_log (collection_time, UTC) include one before
+ /// .
+ private static async Task HasCollectorRunBeforeAsync(LockedConnection connection, string collector, int serverId, DateTime startUtc)
+ {
using var olderRunCommand = connection.CreateCommand();
olderRunCommand.CommandText = $@"
SELECT 1
@@ -204,6 +290,114 @@ AND collection_time < $2
LIMIT 1";
olderRunCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
olderRunCommand.Parameters.Add(new DuckDBParameter { Value = startUtc });
- return await olderRunCommand.ExecuteScalarAsync() is null ? firstRow : startUtc;
+ return await olderRunCommand.ExecuteScalarAsync() is not null;
+ }
+
+ ///
+ /// for a relation whose time column is the monitored server's wall clock (#4989:
+ /// the Default Trace's event_time). It answers what the UTC path answers, from the rows the grid shows
+ /// (), so a banner never names a time later than the earliest row its grid shows.
+ ///
+ /// The candidate rows are read over the grid's pre-filter: the server-local bounds of the UTC window, an hour wider
+ /// on each side, which covers a daylight-saving change inside the span. Each row is converted to UTC through
+ /// , which takes the offset in force at the row's own date (#4766), and the converted times give
+ /// the earliest row inside [, ] and whether a row sits before the
+ /// start. The scan reads the one column over a span the grid reads whole.
+ /// The collector's runs in v_collection_log are UTC, so they are not converted: the earlier of the first run
+ /// and the first row is taken here, in C#, not by one SQL LEAST over a wall-clock column and a UTC one.
+ /// When that first time is after the start, the server holds an older row if the scan found one that converts to a
+ /// time before the start, or if any row sits below the pre-filter's lower bound (more than an hour before the start at
+ /// any offset the clock holds, so it needs no conversion). Then an older run of the collector counts, as on the UTC path.
+ ///
+ ///
+ private static async Task GetServerLocalQueryWindowFloorAsync(
+ LockedConnection connection, string view, string timeColumn, string? collector, int serverId,
+ DateTime startUtc, DateTime endUtc, ServerClock clock)
+ {
+ /* The grid's pre-filter, bound for bound (GetDefaultTraceEventsAsync). */
+ var padStart = clock.ToServerLocal(startUtc).AddHours(-1);
+ var padEnd = clock.ToServerLocal(endUtc).AddHours(1);
+
+ DateTime? firstRow = null;
+ var olderRowInPad = false;
+ using (var rowsCommand = connection.CreateCommand())
+ {
+ rowsCommand.CommandText = $@"
+SELECT {timeColumn}
+FROM {view}
+WHERE server_id = $1
+AND {timeColumn} >= $2
+AND {timeColumn} <= $3";
+ rowsCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ rowsCommand.Parameters.Add(new DuckDBParameter { Value = padStart });
+ rowsCommand.Parameters.Add(new DuckDBParameter { Value = padEnd });
+ using var reader = await rowsCommand.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var eventUtc = clock.ToUtc(reader.GetDateTime(0));
+ if (eventUtc < startUtc)
+ {
+ olderRowInPad = true;
+ }
+ else if (eventUtc <= endUtc && (firstRow is null || eventUtc < firstRow))
+ {
+ firstRow = eventUtc;
+ }
+ }
+ }
+
+ DateTime? firstRun = null;
+ if (collector is not null)
+ {
+ using var runCommand = connection.CreateCommand();
+ runCommand.CommandText = $@"
+SELECT MIN(collection_time)
+FROM v_collection_log
+WHERE server_id = $1
+AND collector_name = '{collector}'
+AND collection_time >= $2
+AND collection_time <= $3";
+ runCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ runCommand.Parameters.Add(new DuckDBParameter { Value = startUtc });
+ runCommand.Parameters.Add(new DuckDBParameter { Value = endUtc });
+ firstRun = await runCommand.ExecuteScalarAsync() is DateTime run ? run : null;
+ }
+
+ var first = firstRow;
+ if (firstRun is DateTime firstRunUtc && (first is null || firstRunUtc < first))
+ {
+ first = firstRunUtc;
+ }
+
+ /* As the UTC path: nothing in the window answers NULL, and a first time at the start is the answer. */
+ if (first is not DateTime firstInWindow || firstInWindow <= startUtc)
+ {
+ return first;
+ }
+
+ if (olderRowInPad)
+ {
+ return startUtc;
+ }
+
+ using (var olderCommand = connection.CreateCommand())
+ {
+ olderCommand.CommandText = $@"
+SELECT 1
+FROM {view}
+WHERE server_id = $1
+AND {timeColumn} < $2
+LIMIT 1";
+ olderCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ olderCommand.Parameters.Add(new DuckDBParameter { Value = padStart });
+ if (await olderCommand.ExecuteScalarAsync() is not null)
+ {
+ return startUtc;
+ }
+ }
+
+ return collector is not null && await HasCollectorRunBeforeAsync(connection, collector, serverId, startUtc)
+ ? startUtc
+ : firstInWindow;
}
}
diff --git a/PerformanceMonitor.Ui/ChartHoverHelper.cs b/PerformanceMonitor.Ui/ChartHoverHelper.cs
index 61900c599..a3fe55df6 100644
--- a/PerformanceMonitor.Ui/ChartHoverHelper.cs
+++ b/PerformanceMonitor.Ui/ChartHoverHelper.cs
@@ -26,10 +26,12 @@ namespace PerformanceMonitor.Ui;
internal sealed class ChartHoverHelper
{
/// A registered series: the scatter, its full (untruncated) label, and the unmutated
- /// identity color captured at registration (see ).
+ /// identity color captured at registration (see ). is an optional
+ /// extra hover line per point, keyed by the point's X (see ).
internal readonly record struct SeriesEntry(
ScottPlot.Plottables.Scatter Scatter, string Label, ScottPlot.Color Identity,
- ScottPlot.Color OrigLineColor, float OrigLineWidth, float OrigMarkerSize, bool OrigFillY);
+ ScottPlot.Color OrigLineColor, float OrigLineWidth, float OrigMarkerSize, bool OrigFillY,
+ IReadOnlyDictionary? PointDetails = null);
private readonly ScottPlot.WPF.WpfPlot _chart;
private readonly List _series = new();
@@ -180,7 +182,17 @@ public void Clear()
PlainTimeAt = null;
}
- public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
+ /// The series to hover.
+ /// The series' name, the popup's first line.
+ ///
+ /// Optional, keyed by the X a point of is drawn at (#4989): a fourth popup line for the nearest
+ /// point, for a series whose point stands for more than its value (a bucket's maximum drawn over the runs behind it).
+ /// The key is the point's X and not its position in the scatter, because the scatter can hold points the caller never
+ /// gave it (Add.TimeSeries inserts one into every gap), which would put every position after the first gap one
+ /// point out. A point with no entry, such as an inserted one, shows no fourth line. Build the keys from the same X
+ /// values the scatter was built from, so the lookup is exact. Left out, the popup is the three lines it always was.
+ ///
+ public void Add(ScottPlot.Plottables.Scatter scatter, string label, IReadOnlyDictionary? pointDetails = null) =>
/* Capture the IDENTITY color from the marker fill, NOT scatter.Color: Add runs after
ChartStyle.StyleScatter, which has already mutated the line color to identity.WithAlpha(215)
but never touches the marker fill — so MarkerStyle.FillColor still holds the pure identity
@@ -189,7 +201,7 @@ but never touches the marker fill — so MarkerStyle.FillColor still holds the p
no fill, and never call StyleScatter) as well as the StyleScatter'd fill charts. */
_series.Add(new SeriesEntry(
scatter, label, scatter.MarkerStyle.FillColor,
- scatter.LineColor, scatter.LineWidth, scatter.MarkerSize, scatter.FillY));
+ scatter.LineColor, scatter.LineWidth, scatter.MarkerSize, scatter.FillY, pointDetails));
public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) =>
_barPlots.Add((barPlot, label));
@@ -270,6 +282,66 @@ per plot (lazily on the first bar) instead of two GetPixel calls per bar. */
}
}
+ ///
+ /// The popup's text for the mouse at (the chart's own pixel space), or null when no point
+ /// is near enough to say anything. The one place the hover decides what it shows, internal so the tests ask it
+ /// what a hover says without a mouse.
+ ///
+ internal string? HoverTextAt(ScottPlot.Pixel pixel)
+ {
+ var mouseCoords = _chart.Plot.GetCoordinates(pixel);
+
+ /* Use X-axis (time) proximity as the primary filter, Y-axis distance
+ as tiebreaker. This makes tooltips appear reliably when hovering at
+ any Y position near a data point's time — standard for time-series. */
+ double bestYDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestLabel = "";
+ string? bestDetail = null;
+ bool found = false;
+
+ foreach (var entry in _series)
+ {
+ var nearest = entry.Scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+
+ var nearestPixel = _chart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = Math.Abs(nearestPixel.X - pixel.X);
+ double dy = Math.Abs(nearestPixel.Y - pixel.Y);
+
+ /* Must be within 80px horizontally (time axis). Among matches,
+ pick the series closest in Y (nearest line to cursor). */
+ if (dx < 80 && dy < bestYDistance)
+ {
+ bestYDistance = dy;
+ bestPoint = nearest;
+ bestLabel = entry.Label;
+ bestDetail = entry.PointDetails is { } details && details.TryGetValue(nearest.X, out var detail)
+ ? detail
+ : null;
+ found = true;
+ }
+ }
+
+ /* A bar that beats the series' nearest point (it lowers the Y distance) carries no detail line. */
+ var distanceBeforeBars = bestYDistance;
+ FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found);
+ if (bestYDistance < distanceBeforeBars)
+ {
+ bestDetail = null;
+ }
+
+ if (!found) return null;
+
+ var plottedX = DateTime.FromOADate(bestPoint.X);
+ var time = FormatHoverTime(plottedX, _displayZone, PlainTimeAt?.Invoke(plottedX) == true);
+ string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y))
+ ? bestPoint.Y.ToString("N0")
+ : bestPoint.Y.ToString("N1");
+ return HoverText(bestLabel, valueFormatted, _unit, time, bestDetail);
+ }
+
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_series.Count == 0 && _barPlots.Count == 0) return;
@@ -284,47 +356,11 @@ private void OnMouseMove(object sender, MouseEventArgs e)
var pixel = new ScottPlot.Pixel(
(float)(pos.X * dpi.DpiScaleX),
(float)(pos.Y * dpi.DpiScaleY));
- var mouseCoords = _chart.Plot.GetCoordinates(pixel);
-
- /* Use X-axis (time) proximity as the primary filter, Y-axis distance
- as tiebreaker. This makes tooltips appear reliably when hovering at
- any Y position near a data point's time — standard for time-series. */
- double bestYDistance = double.MaxValue;
- ScottPlot.DataPoint bestPoint = default;
- string bestLabel = "";
- bool found = false;
- foreach (var entry in _series)
+ var text = HoverTextAt(pixel);
+ if (text is not null)
{
- var nearest = entry.Scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
- if (!nearest.IsReal) continue;
-
- var nearestPixel = _chart.Plot.GetPixel(
- new ScottPlot.Coordinates(nearest.X, nearest.Y));
- double dx = Math.Abs(nearestPixel.X - pixel.X);
- double dy = Math.Abs(nearestPixel.Y - pixel.Y);
-
- /* Must be within 80px horizontally (time axis). Among matches,
- pick the series closest in Y (nearest line to cursor). */
- if (dx < 80 && dy < bestYDistance)
- {
- bestYDistance = dy;
- bestPoint = nearest;
- bestLabel = entry.Label;
- found = true;
- }
- }
-
- FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found);
-
- if (found)
- {
- var plottedX = DateTime.FromOADate(bestPoint.X);
- var time = FormatHoverTime(plottedX, _displayZone, PlainTimeAt?.Invoke(plottedX) == true);
- string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y))
- ? bestPoint.Y.ToString("N0")
- : bestPoint.Y.ToString("N1");
- _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time}";
+ _text.Text = text;
_popup.HorizontalOffset = pos.X + 15;
_popup.VerticalOffset = pos.Y + 15;
/* Updating the offsets above moves an already-open popup, so only toggle IsOpen
@@ -357,6 +393,14 @@ private void OnMouseLeave(object sender, MouseEventArgs e)
_popup.IsOpen = false;
}
+ ///
+ /// The popup's text: the series' , the value with its , the time and,
+ /// when the nearest point has one (),
+ /// its line (#4989). Without a detail it is the three lines it always was.
+ ///
+ internal static string HoverText(string label, string valueFormatted, string unit, string time, string? detail = null) =>
+ detail is null ? $"{label}\n{valueFormatted} {unit}\n{time}" : $"{label}\n{valueFormatted} {unit}\n{time}\n{detail}";
+
///
/// The time line of a hover for the plotted X . With no zone, X is the app's
/// server-time value and the line is the display-mode conversion of it, as it has always been. With a zone, X