diff --git a/Lite.Tests/ComparisonWindowUtcTests.cs b/Lite.Tests/ComparisonWindowUtcTests.cs
new file mode 100644
index 0000000000..52b494a8c1
--- /dev/null
+++ b/Lite.Tests/ComparisonWindowUtcTests.cs
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4284: GetQueryStatsComparisonAsync/GetProcedureStatsComparisonAsync/GetQueryStoreComparisonAsync
+/// (Lite/Services/LocalDataService.QueryStats.cs and .QueryStore.cs) compare their bounds straight
+/// against UTC collection_time, with no offset conversion of their own. Two callers used to hand them a
+/// server-local window instead: a custom range (ServerTab.Comparison.cs's old parameterless
+/// GetComparisonRange(), and ServerTab.Refresh.cs's cStart/cEnd) and a slicer drag
+/// (ServerTab.Slicers.cs's fromServer/toServer). On a server not on UTC, that shifted the
+/// comparison window by the server's offset. Pins 's pure day-shift
+/// arithmetic directly, and the call sites via source scan (the handlers are UI-bound, same approach
+/// QueryWindowTruncationTests took for #4279/#4231).
+///
+public sealed class ComparisonWindowUtcTests
+{
+ ///
+ /// #4284 ruling item 2/6: the shift is a fixed duration (1 or 7 days), so it commutes with whatever UTC
+ /// offset built the "current" window -- a server at UTC+5 and one at UTC-7 both get a baseline that is
+ /// exactly their own current window moved back by the same number of days, covering the SAME span
+ /// (ruling item 3: after a slicer drag, the baseline is the dragged window shifted back, not the whole
+ /// preset range).
+ ///
+ [Theory]
+ [InlineData(300)] // UTC+5
+ [InlineData(-420)] // UTC-7
+ public void ShiftComparisonRange_MatchesGridsUtcWindow_ShiftedByOneOrSevenDays(int utcOffsetMinutes)
+ {
+ // The same derivation CompareToCombo_SelectionChanged and the six ServerTab.Refresh.cs call sites
+ // now use for "current": server-local pickers converted back to UTC by LocalDataService.GetQueriesTabWindowUtc.
+ var fromServerLocal = new DateTime(2026, 3, 10, 9, 0, 0, DateTimeKind.Unspecified);
+ var toServerLocal = new DateTime(2026, 3, 10, 17, 0, 0, DateTimeKind.Unspecified);
+ var (currentStartUtc, currentEndUtc) = LocalDataService.GetQueriesTabWindowUtc(24, fromServerLocal, toServerLocal, utcOffsetMinutes);
+
+ var yesterday = ServerTab.ShiftComparisonRange(1, currentStartUtc, currentEndUtc);
+ Assert.NotNull(yesterday);
+ Assert.Equal(currentStartUtc.AddDays(-1), yesterday!.Value.From);
+ Assert.Equal(currentEndUtc.AddDays(-1), yesterday.Value.To);
+ Assert.Equal(currentEndUtc - currentStartUtc, yesterday.Value.To - yesterday.Value.From);
+
+ var lastWeek = ServerTab.ShiftComparisonRange(2, currentStartUtc, currentEndUtc);
+ Assert.NotNull(lastWeek);
+ Assert.Equal(currentStartUtc.AddDays(-7), lastWeek!.Value.From);
+ Assert.Equal(currentEndUtc.AddDays(-7), lastWeek.Value.To);
+
+ var sameDayLastWeek = ServerTab.ShiftComparisonRange(3, currentStartUtc, currentEndUtc);
+ Assert.Equal(lastWeek, sameDayLastWeek);
+
+ Assert.Null(ServerTab.ShiftComparisonRange(0, currentStartUtc, currentEndUtc));
+ Assert.Null(ServerTab.ShiftComparisonRange(-1, currentStartUtc, currentEndUtc));
+ }
+
+ ///
+ /// #4284 ruling item 6's source pin: every Refresh...ComparisonAsync call site passes a UTC bound -- never
+ /// the server-local fromServer/toServer a slicer drag produces, nor a cStart built from fromDate in
+ /// ServerTab.Refresh.cs. Confirmed by checking out ServerTab.Comparison.cs, ServerTab.Refresh.cs and
+ /// ServerTab.Slicers.cs from origin/dev at 80948a66 (pre-#4284) and re-running this test: every assertion
+ /// below failed against that pre-fix source.
+ ///
+ [Fact]
+ public void ComparisonCallSites_TakeUtcBounds_NotServerLocalOnes()
+ {
+ var comparisonSource = File.ReadAllText(ControlsFile("ServerTab.Comparison.cs"));
+ var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
+ var slicersSource = File.ReadAllText(ControlsFile("ServerTab.Slicers.cs"));
+ var combinedSource = comparisonSource + "\n" + refreshSource + "\n" + slicersSource;
+
+ // No caller anywhere still calls the old parameterless GetComparisonRange() -- it now requires the
+ // caller's own current UTC window as an argument.
+ Assert.False(Regex.IsMatch(combinedSource, @"GetComparisonRange\(\)"),
+ "a caller still invokes the parameterless GetComparisonRange() (#4284) -- it now takes the " +
+ "caller's current UTC window as an argument.");
+
+ // IsQueryStatsComparisonActive tests the combo directly (ruling item 2), not through GetComparisonRange.
+ Assert.Contains("IsQueryStatsComparisonActive => CompareToCombo != null && CompareToCombo.SelectedIndex > 0;", comparisonSource);
+
+ // Slicer handlers: comparisons take e.StartUtc/e.EndUtc, never the server-local fromServer/toServer
+ // the grid read beside them uses.
+ var slicerComparisonCallsOnUtc = Regex.Matches(slicersSource,
+ @"Refresh(?:QueryStats|ProcStats|QueryStore)ComparisonAsync\(e\.StartUtc,\s*e\.EndUtc\)").Count;
+ Assert.True(slicerComparisonCallsOnUtc == 3,
+ $"expected all 3 OnXSlicerChanged comparison calls to pass e.StartUtc, e.EndUtc (found {slicerComparisonCallsOnUtc}) " +
+ "-- fromServer/toServer are server-local and the comparison reads compare straight against UTC " +
+ "collection_time (#4284).");
+ Assert.False(Regex.IsMatch(slicersSource, @"Refresh(?:QueryStats|ProcStats|QueryStore)ComparisonAsync\([^)]*fromServer,\s*toServer\)"),
+ "a slicer comparison call still passes server-local fromServer/toServer (#4284).");
+
+ // ServerTab.Refresh.cs: no comparison call site builds its own cStart from fromDate any more.
+ Assert.False(Regex.IsMatch(refreshSource, @"var\s+cStart\d?\s*="),
+ "a comparison call site in ServerTab.Refresh.cs still builds a server-local cStart from fromDate (#4284).");
+
+ // The six Refresh.cs comparison calls each share the IDENTICAL windowStart/windowEnd tuple the
+ // banner call right beside them uses -- computed once, handed to both (ruling item 1).
+ var pairedCalls = Regex.Matches(refreshSource,
+ @"Refresh(?:QueryStats|ProcStats|QueryStore)ComparisonAsync\((windowStart\d?),\s*(windowEnd\d?)\);\s*" +
+ @"await RefreshWindowTruncatedBannerAsync\(QueryWindowRelation\.\w+,\s*\w+,\s*\1,\s*\2\);");
+ Assert.True(pairedCalls.Count == 6,
+ $"expected all 6 ServerTab.Refresh.cs comparison calls to share the SAME windowStart/windowEnd tuple " +
+ $"the adjacent banner call uses (found {pairedCalls.Count}) -- a divergent tuple means the comparison " +
+ "and the banner could disagree about the window again (#4284).");
+
+ // CompareToCombo_SelectionChanged routes its three comparison refreshes through the same
+ // GetQueriesTabWindowUtc tuple the grid reads use, not a bare DateTime.UtcNow/fromDate pair.
+ Assert.Contains(
+ "var (currentStart, currentEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);",
+ comparisonSource);
+ }
+
+ private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name);
+
+ private static string ControlsDir([CallerFilePath] string thisFile = "") =>
+ Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls"));
+}
diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs
index 529b0c48a8..79249a4a6e 100644
--- a/Lite.Tests/QueryWindowTruncationTests.cs
+++ b/Lite.Tests/QueryWindowTruncationTests.cs
@@ -446,10 +446,15 @@ public void SlicerBannerWindow_MatchesTheGridsUtcWindow_ForANonUtcServer()
///
/// #4279 revert-proof: text-scans SOURCE (matching 's
- /// mechanism) so a future edit that quietly goes back to feeding the banner server-local
- /// fromServer/toServer or cStart/cStart2/cStart3 fails a test even though those names still compile fine
- /// (they are plain DateTimes either way). Confirmed by reverting ServerTab.Slicers.cs and
- /// ServerTab.Refresh.cs to 3b8d9e12 (pre-fix): both assertions below failed before the fix.
+ /// mechanism) so a future edit that quietly goes back to feeding the banner server-local fromServer/toServer
+ /// fails a test even though those names still compile fine (they are plain DateTimes either way).
+ /// Confirmed by reverting ServerTab.Slicers.cs and ServerTab.Refresh.cs to 3b8d9e12 (pre-#4279-fix): both
+ /// assertions below failed before that fix.
+ ///
+ /// #4284 updated the ServerTab.Refresh.cs half: the banner call now shares its windowStart/windowEnd
+ /// tuple (formerly bannerStart/bannerEnd) with the comparison call beside it, rather than the comparison
+ /// keeping its own server-local cStart -- see
+ /// for that half's pin.
///
[Fact]
public void WindowTruncatedBannerCallSites_TakeUtcBounds_NotServerLocalOnes()
@@ -466,12 +471,11 @@ public void WindowTruncatedBannerCallSites_TakeUtcBounds_NotServerLocalOnes()
var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
var refreshBannerCallsOnHelperOutput = Regex.Matches(refreshSource,
- @"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*bannerStart\d?,\s*bannerEnd\d?\)").Count;
+ @"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*windowStart\d?,\s*windowEnd\d?\)").Count;
Assert.True(refreshBannerCallsOnHelperOutput == 6,
$"expected all 6 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " +
- $"(bannerStart/bannerEnd) (found {refreshBannerCallsOnHelperOutput}) -- cStart/cStart2/cStart3 are " +
- "server-local under a custom range and feed the (deliberately untouched -- see ServerTab.Comparison.cs) " +
- "comparison calls on the same lines, not the banner (#4279).");
+ $"(windowStart/windowEnd) (found {refreshBannerCallsOnHelperOutput}) -- a server-local cStart must " +
+ "not feed the banner (#4279/#4284).");
Assert.Equal(6, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count);
}
diff --git a/Lite/Controls/ServerTab.Comparison.cs b/Lite/Controls/ServerTab.Comparison.cs
index e161bda2e5..b5e4ae5513 100644
--- a/Lite/Controls/ServerTab.Comparison.cs
+++ b/Lite/Controls/ServerTab.Comparison.cs
@@ -39,8 +39,12 @@ private async void CompareToCombo_SelectionChanged(object sender, SelectionChang
// Also refresh comparison grids
try
{
- var currentEnd = toDate ?? DateTime.UtcNow;
- var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack);
+ /* #4284: GetQueryStatsComparisonAsync/GetProcedureStatsComparisonAsync/GetQueryStoreComparisonAsync
+ compare currentStart/currentEnd straight against UTC collection_time, with no offset conversion
+ of their own -- the SAME UTC window the Top Queries/Top Procedures/Query Store grid reads get via
+ LocalDataService.GetQueriesTabWindowUtc, computed once here and handed to all three so the
+ current window matches the grid on any server not on UTC. */
+ var (currentStart, currentEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
await RefreshQueryStatsComparisonAsync(currentStart, currentEnd);
await RefreshProcStatsComparisonAsync(currentStart, currentEnd);
await RefreshQueryStoreComparisonAsync(currentStart, currentEnd);
@@ -52,38 +56,38 @@ private async void CompareToCombo_SelectionChanged(object sender, SelectionChang
}
///
- /// Computes the reference time range for the comparison overlay based on the
- /// current Compare dropdown selection and the active time range.
- /// Returns null if "None" is selected.
+ /// #4284: pure day-shift for the Compare-to selection -- Yesterday is 1 day back, Last week and Same day
+ /// last week are 7 days back, None (index <= 0) is no comparison. Basis-agnostic: the shift is a fixed
+ /// duration, so it commutes with whatever basis (UTC or server-local) the caller's start/end are already
+ /// in. Pulled out of as a static helper, the way #4279 pulled
+ /// out of its instance callers, so a test can drive
+ /// it directly without constructing the WPF UserControl.
///
- private (DateTime From, DateTime To)? GetComparisonRange()
- {
- if (CompareToCombo == null || CompareToCombo.SelectedIndex <= 0) return null;
-
- var hoursBack = GetHoursBack();
- DateTime? fromDate = null, toDate = null;
- if (IsCustomRange)
- {
- var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo);
- var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo);
- if (fromLocal.HasValue && toLocal.HasValue)
- {
- fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode);
- toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode);
- }
- }
-
- var currentEnd = toDate ?? DateTime.UtcNow;
- var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack);
-
- return CompareToCombo.SelectedIndex switch
+ internal static (DateTime From, DateTime To)? ShiftComparisonRange(int selectedIndex, DateTime currentStart, DateTime currentEnd)
+ => selectedIndex switch
{
1 => (currentStart.AddDays(-1), currentEnd.AddDays(-1)), // Yesterday
2 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Last week
3 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Same day last week
_ => null
};
- }
+
+ ///
+ /// #4284: computes the comparison baseline for the Queries tab's three comparison reads
+ /// (GetQueryStatsComparisonAsync / GetProcedureStatsComparisonAsync / GetQueryStoreComparisonAsync), which
+ /// compare their bounds straight against UTC collection_time with no offset conversion of their own.
+ /// / MUST be the same UTC window the
+ /// grid beside the caller read (, or a slicer's own
+ /// SlicerRangeEventArgs.StartUtc/EndUtc) -- a server-local pair here silently shifts the
+ /// baseline by the server's UTC offset, which is what #4284 fixed. Returns null if "None" is selected.
+ ///
+ /// Not used by RefreshOverviewAsync's correlated-lanes comparison (ServerTab.Refresh.cs):
+ /// that caller's underlying reads (GetCpuUtilizationAsync, GetTotalWaitTrendAsync) take server-local
+ /// fromDate/toDate, not UTC, so it derives its own range with in that
+ /// basis instead of calling through here.
+ ///
+ private (DateTime From, DateTime To)? GetComparisonRange(DateTime currentStartUtc, DateTime currentEndUtc)
+ => CompareToCombo == null ? null : ShiftComparisonRange(CompareToCombo.SelectedIndex, currentStartUtc, currentEndUtc);
/* #2933: CompareToCombo_SelectionChanged sets no in-flight flag, so two Compare changes overlap and
the LATER-STARTING read can land first, leaving the grid showing the baseline the operator moved
@@ -100,7 +104,9 @@ Only the comparison loaders take this. ServerTab's main-tab Refresh*Async loader
serves in TimeDisplayMode_SelectionChanged, ServerTab.DrillDown.cs and ServerTab.Grids.cs. */
private readonly PerformanceMonitor.Ui.ScopedLoadGenerations _loads = new();
- private bool IsQueryStatsComparisonActive => GetComparisonRange() != null;
+ /* #4284: tests the combo directly rather than through GetComparisonRange, which now needs the caller's
+ current UTC window as an argument and has none to give here. */
+ private bool IsQueryStatsComparisonActive => CompareToCombo != null && CompareToCombo.SelectedIndex > 0;
private void SetQueryStatsComparisonMode(bool active, (DateTime From, DateTime To)? baselineRange = null)
{
@@ -119,7 +125,7 @@ private void SetQueryStatsComparisonMode(bool active, (DateTime From, DateTime T
private async System.Threading.Tasks.Task RefreshQueryStatsComparisonAsync(DateTime currentStart, DateTime currentEnd)
{
var gen = _loads.Claim(nameof(RefreshQueryStatsComparisonAsync));
- var baselineRange = GetComparisonRange();
+ var baselineRange = GetComparisonRange(currentStart, currentEnd);
if (baselineRange == null)
{
SetQueryStatsComparisonMode(false);
@@ -159,7 +165,7 @@ private void SetProcStatsComparisonMode(bool active, (DateTime From, DateTime To
private async System.Threading.Tasks.Task RefreshProcStatsComparisonAsync(DateTime currentStart, DateTime currentEnd)
{
var gen = _loads.Claim(nameof(RefreshProcStatsComparisonAsync));
- var baselineRange = GetComparisonRange();
+ var baselineRange = GetComparisonRange(currentStart, currentEnd);
if (baselineRange == null)
{
SetProcStatsComparisonMode(false);
@@ -198,7 +204,7 @@ private void SetQueryStoreComparisonMode(bool active, (DateTime From, DateTime T
private async System.Threading.Tasks.Task RefreshQueryStoreComparisonAsync(DateTime currentStart, DateTime currentEnd)
{
var gen = _loads.Claim(nameof(RefreshQueryStoreComparisonAsync));
- var baselineRange = GetComparisonRange();
+ var baselineRange = GetComparisonRange(currentStart, currentEnd);
if (baselineRange == null)
{
SetQueryStoreComparisonMode(false);
diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs
index 3517ecec5b..ff85b39510 100644
--- a/Lite/Controls/ServerTab.Refresh.cs
+++ b/Lite/Controls/ServerTab.Refresh.cs
@@ -260,17 +260,12 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat
SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_ = LoadQueryStatsSlicerAsync();
{
- var cEnd = toDate ?? DateTime.UtcNow;
- var cStart = fromDate ?? cEnd.AddHours(-hoursBack);
- await RefreshQueryStatsComparisonAsync(cStart, cEnd);
- /* #4279: the banner probes UTC collection_time, so it takes the SAME UTC window
- GetTopQueriesByCpuAsync just read (LocalDataService.GetQueriesTabWindowUtc), not
- cStart/cEnd -- those are server-local under a custom range (GetCurrentWindow
- converts the pickers into server time) and stay that way here on purpose,
- because RefreshQueryStatsComparisonAsync's baseline (GetComparisonRange,
- ServerTab.Comparison.cs) is server-local under a custom range too. */
- var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, bannerStart, bannerEnd);
+ /* #4284: the comparison and the banner both read UTC collection_time, so they
+ share the SAME UTC window GetTopQueriesByCpuAsync just read
+ (LocalDataService.GetQueriesTabWindowUtc) -- computed once and handed to both. */
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshQueryStatsComparisonAsync(windowStart, windowEnd);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, windowStart, windowEnd);
}
break;
case 3: // Top Procedures by Duration
@@ -279,12 +274,11 @@ GetTopQueriesByCpuAsync just read (LocalDataService.GetQueriesTabWindowUtc), not
SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_ = LoadProcStatsSlicerAsync();
{
- var cEnd = toDate ?? DateTime.UtcNow;
- var cStart = fromDate ?? cEnd.AddHours(-hoursBack);
- await RefreshProcStatsComparisonAsync(cStart, cEnd);
- /* #4279: UTC probe, UTC bounds -- see the twin comment on the Top Queries case above. */
- var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, bannerStart, bannerEnd);
+ /* #4284: UTC comparison and banner window, computed once -- see the twin comment
+ on the Top Queries case above. */
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshProcStatsComparisonAsync(windowStart, windowEnd);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, windowStart, windowEnd);
}
break;
case 4: // Query Store by Duration
@@ -293,12 +287,11 @@ GetTopQueriesByCpuAsync just read (LocalDataService.GetQueriesTabWindowUtc), not
SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
_ = LoadQueryStoreSlicerAsync();
{
- var cEnd = toDate ?? DateTime.UtcNow;
- var cStart = fromDate ?? cEnd.AddHours(-hoursBack);
- await RefreshQueryStoreComparisonAsync(cStart, cEnd);
- /* #4279: UTC probe, UTC bounds -- see the twin comment on the Top Queries case above. */
- var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, bannerStart, bannerEnd);
+ /* #4284: UTC comparison and banner window, computed once -- see the twin comment
+ on the Top Queries case above. */
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshQueryStoreComparisonAsync(windowStart, windowEnd);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, windowStart, windowEnd);
}
break;
case 5: // Plan Corrections
@@ -348,34 +341,31 @@ await System.Threading.Tasks.Task.WhenAll(
SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_ = LoadQueryStatsSlicerAsync();
{
- var cEnd = toDate ?? DateTime.UtcNow;
- var cStart = fromDate ?? cEnd.AddHours(-hoursBack);
- await RefreshQueryStatsComparisonAsync(cStart, cEnd);
- /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */
- var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, bannerStart, bannerEnd);
+ /* #4284: UTC comparison and banner window, computed once -- see the twin comment on the
+ sub-tab-switch case above. */
+ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshQueryStatsComparisonAsync(windowStart, windowEnd);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, windowStart, windowEnd);
}
_procStatsFilterMgr!.UpdateData(procStatsTask.Result);
SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_ = LoadProcStatsSlicerAsync();
{
- var cEnd2 = toDate ?? DateTime.UtcNow;
- var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack);
- await RefreshProcStatsComparisonAsync(cStart2, cEnd2);
- /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */
- var (bannerStart2, bannerEnd2) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, bannerStart2, bannerEnd2);
+ /* #4284: UTC comparison and banner window, computed once -- see the twin comment on the
+ sub-tab-switch case above. */
+ var (windowStart2, windowEnd2) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshProcStatsComparisonAsync(windowStart2, windowEnd2);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, windowStart2, windowEnd2);
}
_queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
_ = LoadQueryStoreSlicerAsync();
{
- var cEnd3 = toDate ?? DateTime.UtcNow;
- var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack);
- await RefreshQueryStoreComparisonAsync(cStart3, cEnd3);
- /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */
- var (bannerStart3, bannerEnd3) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
- await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, bannerStart3, bannerEnd3);
+ /* #4284: UTC comparison and banner window, computed once -- see the twin comment on the
+ sub-tab-switch case above. */
+ var (windowStart3, windowEnd3) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes);
+ await RefreshQueryStoreComparisonAsync(windowStart3, windowEnd3);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, windowStart3, windowEnd3);
}
_planCorrectionFilterMgr!.UpdateData(planCorrectionTask.Result);
SetDefaultSortIfNone(PlanCorrectionGrid, "Score", ListSortDirection.Descending);
@@ -435,7 +425,23 @@ private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, Da
{
try
{
- var comparison = GetComparisonRange();
+ /* #4284: unlike the Queries tab's three comparison reads (ServerTab.Comparison.cs's
+ GetComparisonRange(DateTime, DateTime), UTC in/out), the correlated lanes' own reads
+ (GetCpuUtilizationAsync, GetTotalWaitTrendAsync's GetTimeRange custom-range branch) take
+ server-local fromDate/toDate -- the same basis this method already received them in. Derived
+ here, in that basis, with ShiftComparisonRange (the day-shift GetComparisonRange also uses)
+ instead of routing through GetComparisonRange itself, which is now UTC-only. Byte-identical to
+ this method's pre-#4284 derivation: pickers -> server time under a custom range, UtcNow under a
+ preset. #4296 tracks a separate, pre-existing mismatch this does not fix: under a PRESET
+ (non-custom) range on a server not on UTC, this still hands the lanes a window basis their
+ ghost-line time-shift (CorrelatedTimelineLanesControl.RefreshAsync ~line 221) doesn't expect. */
+ (DateTime From, DateTime To)? comparison = null;
+ if (CompareToCombo != null && CompareToCombo.SelectedIndex > 0)
+ {
+ var currentEnd = toDate ?? DateTime.UtcNow;
+ var currentStart = fromDate ?? currentEnd.AddHours(-hoursBack);
+ comparison = ShiftComparisonRange(CompareToCombo.SelectedIndex, currentStart, currentEnd);
+ }
await CorrelatedLanes.RefreshAsync(hoursBack, fromDate, toDate, comparison);
}
catch (Exception ex)
diff --git a/Lite/Controls/ServerTab.Slicers.cs b/Lite/Controls/ServerTab.Slicers.cs
index ba78bfec13..0fa8dafe12 100644
--- a/Lite/Controls/ServerTab.Slicers.cs
+++ b/Lite/Controls/ServerTab.Slicers.cs
@@ -172,11 +172,10 @@ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRang
var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter));
_queryStatsFilterMgr!.UpdateData(queryStats);
- await RefreshQueryStatsComparisonAsync(fromServer, toServer);
- /* #4279: the banner probes UTC collection_time directly (LocalDataService.GetQueryWindowFloorAsync),
- so it takes e.StartUtc/e.EndUtc, not the server-local fromServer/toServer the grid read and the
- comparison call above use (those convert back to UTC themselves, or -- for the comparison -- share
- GetComparisonRange's own basis; see ServerTab.Comparison.cs). */
+ /* #4284: the comparison reads UTC collection_time directly, with no offset conversion of its own
+ (same as the banner below), so it takes e.StartUtc/e.EndUtc -- not the server-local
+ fromServer/toServer the grid read above uses (that one converts back to UTC itself). */
+ await RefreshQueryStatsComparisonAsync(e.StartUtc, e.EndUtc);
await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, e.StartUtc, e.EndUtc);
}
catch (Exception ex)
@@ -228,8 +227,8 @@ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRang
var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer, SelectedDatabaseFilter));
_queryStoreFilterMgr!.UpdateData(qsData);
- await RefreshQueryStoreComparisonAsync(fromServer, toServer);
- /* #4279: UTC probe, UTC bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */
+ /* #4284: UTC comparison and banner bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */
+ await RefreshQueryStoreComparisonAsync(e.StartUtc, e.EndUtc);
await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, e.StartUtc, e.EndUtc);
}
catch (Exception ex)
@@ -281,8 +280,8 @@ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRange
var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter));
_procStatsFilterMgr!.UpdateData(procStats);
- await RefreshProcStatsComparisonAsync(fromServer, toServer);
- /* #4279: UTC probe, UTC bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */
+ /* #4284: UTC comparison and banner bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */
+ await RefreshProcStatsComparisonAsync(e.StartUtc, e.EndUtc);
await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, e.StartUtc, e.EndUtc);
}
catch (Exception ex)