diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
index 62db008c..d081ea9f 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
@@ -47,6 +47,14 @@ private async Task ShowConnectionDialogAsync()
_selectedDatabase = dialog.ResultDatabase;
_connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase);
+ /* A new holder for a new connection, made here rather than at the fetch below so a
+ document opened while the fetch is still in flight already has the right one to
+ read, and handed to the fetch as a parameter so an offset that lands after ANOTHER
+ reconnect fills this connection's holder and not the newer one's (E5). The
+ connection string is captured for the same reason. */
+ var serverOffset = BeginServerConnection();
+ var offsetConnectionString = _connectionString;
+
/* A database metadata fetch still running from before this reconnect was built from
the previous server's connection string. Left alone it can land after
FetchServerMetadataAsync below has replaced _serverMetadata, and write the old
@@ -81,7 +89,7 @@ whose swallowed failure left a green toolbar over a dead database picker. */
DatabaseBox.IsEnabled = true;
await FetchServerMetadataAsync();
- await FetchServerUtcOffset();
+ await FetchServerUtcOffset(offsetConnectionString, serverOffset);
if (_selectedDatabase != null)
{
@@ -159,18 +167,36 @@ private async Task FetchServerMetadataAsync()
}
}
- private async Task FetchServerUtcOffset()
+ ///
+ /// Starts the offset holder for a new connection. A connect replaces the holder instead of
+ /// reusing it: documents already open keep the old one, because their data came from the old
+ /// server, and documents opened from here on get this one. Internal so a test can connect
+ /// without a server — the connect block calls this and nothing else makes a holder (E5).
+ ///
+ internal ServerUtcOffset BeginServerConnection()
{
- if (_connectionString == null) return;
+ _serverOffset = new ServerUtcOffset();
+ return _serverOffset;
+ }
+
+ ///
+ /// Asks the server it just connected to for its offset from UTC and fills
+ /// with it. Takes the connection string and the holder rather than
+ /// reading the session's fields, so the answer lands on the connection that asked even if the
+ /// session has reconnected in the meantime. A failed query leaves the holder at zero.
+ ///
+ private static async Task FetchServerUtcOffset(string? connectionString, ServerUtcOffset target)
+ {
+ if (connectionString == null) return;
try
{
- await using var conn = new SqlConnection(_connectionString);
+ await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new SqlCommand(
"SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn);
var offset = await cmd.ExecuteScalarAsync();
if (offset is int mins)
- PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins;
+ target.Minutes = mins;
}
catch { }
}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
index 8218ab95..81e48a11 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
@@ -146,14 +146,23 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini
var databases = DatabaseBox.Items.OfType().ToList();
- var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
- database, databases, supportsWaitStats);
+ var grid = NewQueryStoreGrid(database, databases, supportsWaitStats);
if (initialStartUtc.HasValue && initialEndUtc.HasValue)
grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value);
AddQueryStoreDocument(grid, database);
}
+ ///
+ /// Builds a Query Store grid on the connection the session is on now, which is what gives it
+ /// that connection's offset holder to keep (E5). Said once because the toolbar's Query Store
+ /// button and the Overview's drill-down both build one, and a second copy of this line is a
+ /// second place to forget the holder. Internal so a test can build one without the server
+ /// check that comes before it in both callers.
+ ///
+ internal QueryStoreGridControl NewQueryStoreGrid(string database, List databases, bool supportsWaitStats) =>
+ new(_serverConnection!, _credentialService, _serverOffset, database, databases, supportsWaitStats);
+
///
/// Puts a Query Store grid into the strip as a document and shows it.
///
@@ -231,8 +240,7 @@ family as #448. */
// Build database list from the current DatabaseBox
var databases = DatabaseBox.Items.OfType().ToList();
- var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
- _selectedDatabase!, databases, supportsWaitStats);
+ var grid = NewQueryStoreGrid(_selectedDatabase!, databases, supportsWaitStats);
AddQueryStoreDocument(grid, _selectedDatabase!);
}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs
index 3834a2a1..f14f7234 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs
@@ -186,7 +186,7 @@ already reports itself. */
private QueryStoreOverviewControl BuildOverviewView()
{
var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
- var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService,
+ var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService, _serverOffset,
supportsWaitStats: supportsWaitStats);
overview.DrillDownRequested += async (_, args) =>
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index 12119642..1ee9b7bc 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -105,6 +105,16 @@ public void MarkClean()
private ServerConnection? _serverConnection;
private string? _connectionString;
private string? _selectedDatabase;
+
+ ///
+ /// The offset from UTC to the server this session is connected to, for Server time display
+ /// (E5). Every connect replaces it (), and every document
+ /// opened on a connection keeps the one that connection had, so a reconnect — or another
+ /// session on a server in a different time zone — cannot move times that are already on
+ /// screen. Never null: a session that has not connected holds an empty one, which is zero.
+ ///
+ private ServerUtcOffset _serverOffset = new();
+
private int _planCounter;
///
/// The run in flight, or the last one that was: the source of the query or plan capture the
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs
index d68d6f87..8ced2934 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs
@@ -169,7 +169,7 @@ private async System.Threading.Tasks.Task FetchFlatPlansAsync(
}
foreach (var plan in plans)
- _rows.Add(new QueryStoreRow(plan));
+ _rows.Add(new QueryStoreRow(plan, _serverOffset));
ApplyFilters();
LoadButton.IsEnabled = true;
@@ -377,7 +377,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
{
var leafPlan = GroupedRowToPlan(leaf);
leafChildren.Add(new QueryStoreRow(leafPlan, 2,
- $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List(), _serverOffset));
}
// Sort leaf children by metric descending
@@ -388,7 +388,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
midPlan.QueryText = topLeafForMid.QueryText;
- midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren, _serverOffset));
}
// Sort mid children by metric descending
@@ -403,7 +403,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
if (topLeafForRoot != null)
aggPlan.QueryText = topLeafForRoot.QueryText;
- roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
+ roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren, _serverOffset));
}
}
else // Module
@@ -431,7 +431,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
{
var leafPlan = GroupedRowToPlan(leaf);
leafChildren.Add(new QueryStoreRow(leafPlan, 2,
- $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List(), _serverOffset));
}
// Sort leaf children by metric descending
@@ -442,7 +442,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
midPlan.QueryText = topLeafForMid.QueryText;
- midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren, _serverOffset));
}
// Sort mid children by metric descending
@@ -457,7 +457,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
if (topLeafForRoot != null)
aggPlan.QueryText = topLeafForRoot.QueryText;
- roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
+ roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren, _serverOffset));
}
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
index 5ab5d115..4ca2bc8a 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
@@ -94,11 +94,14 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e)
var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
+ /* The grid's own holder, not the session's current one: this History reads the grid's
+ server, and the session may have reconnected somewhere else since the grid opened (E5). */
var control = new QueryStoreHistoryControl(
_connectionString,
row.QueryHash,
row.FullQueryText,
_database,
+ _serverOffset,
initialMetricTag: metricTag,
slicerStartUtc: _slicerStartUtc,
slicerEndUtc: _slicerEndUtc,
@@ -120,6 +123,7 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e)
row.QueryHash,
row.FullQueryText,
_database,
+ _serverOffset,
initialMetricTag: metricTag,
slicerStartUtc: _slicerStartUtc,
slicerEndUtc: _slicerEndUtc,
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs
index 68de6e53..a25a5ed9 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs
@@ -98,6 +98,8 @@ private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventA
}
// Refresh slicer labels
TimeRangeSlicer.Redraw();
+ // Refresh the wait ribbon's labels and tips, which kept the old mode until a resize
+ WaitStatsProfile.RedrawRibbon();
}
private void UpdateStatusText()
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index fd78a9c2..758e08d2 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -24,6 +24,12 @@ public partial class QueryStoreGridControl : UserControl
{
private readonly ServerConnection _serverConnection;
private readonly ICredentialService _credentialService;
+ ///
+ /// The offset holder of the connection this grid was opened on (E5). Kept for the grid's whole
+ /// life and handed to everything it builds — rows, slicer, ribbon, History — because they show
+ /// this server's data, whichever connection the session is on by the time they draw.
+ ///
+ private readonly ServerUtcOffset _serverOffset;
private string _connectionString;
private string _database;
private CancellationTokenSource? _fetchCts;
@@ -62,10 +68,11 @@ public partial class QueryStoreGridControl : UserControl
public string Database => _database;
public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService,
- string initialDatabase, List databases, bool supportsWaitStats = false)
+ ServerUtcOffset serverOffset, string initialDatabase, List databases, bool supportsWaitStats = false)
{
_serverConnection = serverConnection;
_credentialService = credentialService;
+ _serverOffset = serverOffset;
_database = initialDatabase;
_connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
_waitStatsSupported = supportsWaitStats;
@@ -75,6 +82,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
InitializeComponent();
+ // The slicer and the ribbon are declared in XAML, so they are handed the holder here,
+ // before any data reaches them.
+ TimeRangeSlicer.ServerOffset = serverOffset;
+ WaitStatsProfile.ServerOffset = serverOffset;
+
// Apply user defaults to UI controls
TopNBox.Value = userSettings.QueryStoreTopLimit;
SelectComboByTag(OrderByBox, userSettings.QueryStoreDefaultMetric);
@@ -86,6 +98,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
_ => "query-hash"
});
+ /* The time display mode is one setting for the whole app, so the box opens on the mode in
+ effect. It used to open on Local whatever the setting or another grid had chosen, and
+ could say Local beside times shown in Server mode. The tags are the mode names. */
+ SelectComboByTag(TimeDisplayBox, TimeDisplayHelper.Current.ToString());
+
// Restore the server-filter panel's expanded state, then subscribe — restoring first
// means the restore itself never triggers a save.
ServerFilterExpander.IsExpanded = userSettings.QueryStoreFilterPanelExpanded;
@@ -366,19 +383,26 @@ public class QueryStoreRow : INotifyPropertyChanged
private bool _isExpanded;
private int _indentLevel;
+ // The connection's offset holder (E5): read each time the row formats its time, so Server
+ // mode shows this row's own server's time whatever else the process is connected to.
+ private readonly ServerUtcOffset _serverOffset;
+
/// Standard constructor for flat (ungrouped) rows.
- public QueryStoreRow(QueryStorePlan plan)
+ public QueryStoreRow(QueryStorePlan plan, ServerUtcOffset serverOffset)
{
Plan = plan;
+ _serverOffset = serverOffset;
}
/// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
- public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
+ public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children,
+ ServerUtcOffset serverOffset)
{
Plan = syntheticPlan;
_indentLevel = indentLevel;
GroupLabel = groupLabel;
Children = children;
+ _serverOffset = serverOffset;
}
public QueryStorePlan Plan { get; }
@@ -552,7 +576,7 @@ public double WaitMaxGrandTotal
public long TotalMemSort => Plan.TotalMemoryGrantPages;
public double AvgMemSort => Plan.AvgMemoryGrantPages;
- public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc);
+ public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc, _serverOffset.Minutes);
public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal));
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs
index 1c043a10..8fb48879 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs
@@ -49,7 +49,7 @@ private void UpdateChart()
var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList();
- var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
+ var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray();
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
@@ -158,7 +158,7 @@ private void HighlightDotsOnChart(HashSet rowIndices)
foreach (var group in groups)
{
var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]);
- var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
+ var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray();
var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs
index b88d110b..7ff4b138 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs
@@ -46,6 +46,12 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_connectionString, _queryHash, _maxHoursBack, ct);
}
+ /* The rows come back from Core, which knows nothing about connections. Each one is given
+ this control's holder here, before anything binds them, so its own time columns read
+ the server it was fetched from (E5). */
+ foreach (var row in _historyData)
+ row.ServerOffset = _serverOffset;
+
BuildColorMap();
HistoryDataGrid.ItemsSource = _historyData;
ApplyColorIndicators();
@@ -54,8 +60,8 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count();
var totalExec = _historyData.Sum(r => r.CountExecutions);
- var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc));
- var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc));
+ var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc), _serverOffset.Minutes);
+ var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc), _serverOffset.Minutes);
StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " +
$"{totalExec:N0} total executions | " +
$"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}";
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs
index 7e2bc7f9..cdb1fdda 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs
@@ -128,7 +128,7 @@ private void HandleSingleClickSelection(Point clickPoint)
for (int i = 0; i < _historyData.Count; i++)
{
var row = _historyData[i];
- var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc);
+ var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes);
if (row.QueryPlanHash == bestPlanHash &&
Math.Abs((displayTime - clickedTime).TotalMinutes) < 1)
{
@@ -154,7 +154,7 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
for (int i = 0; i < _historyData.Count; i++)
{
var row = _historyData[i];
- var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate();
+ var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes).ToOADate();
var yVal = GetMetricValue(row, tag);
if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2)
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
index 155eff91..ed475c14 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
@@ -22,6 +22,11 @@ public partial class QueryStoreHistoryControl : UserControl
private readonly string _queryHash;
private readonly string _database;
private readonly string _queryText;
+ ///
+ /// The offset holder of the connection this History was opened from (E5): the grid's, not the
+ /// session's current one, because the data it fetches comes from the grid's server.
+ ///
+ private readonly ServerUtcOffset _serverOffset;
private readonly DateTime? _slicerStartUtc;
private readonly DateTime? _slicerEndUtc;
private readonly int _maxHoursBack;
@@ -117,11 +122,12 @@ public QueryStoreHistoryControl()
_queryHash = "";
_database = "";
_queryText = "";
+ _serverOffset = new ServerUtcOffset();
InitializeComponent();
}
public QueryStoreHistoryControl(string connectionString, string queryHash,
- string queryText, string database,
+ string queryText, string database, ServerUtcOffset serverOffset,
string initialMetricTag = "AvgCpuMs",
DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
int slicerDaysBack = 30)
@@ -130,6 +136,7 @@ public QueryStoreHistoryControl(string connectionString, string queryHash,
_queryHash = queryHash;
_database = database;
_queryText = queryText;
+ _serverOffset = serverOffset;
_slicerStartUtc = slicerStartUtc;
_slicerEndUtc = slicerEndUtc;
_maxHoursBack = slicerDaysBack * 24;
diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs
index 917551d6..fa2ea9a8 100644
--- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs
@@ -169,7 +169,7 @@ private void DrawWaitStatsChart()
});
var tb = new TextBlock
{
- Text = TimeDisplayHelper.FormatForDisplay(allHours[i], "MM/dd"),
+ Text = TimeDisplayHelper.FormatForDisplay(allHours[i], _serverOffset.Minutes, "MM/dd"),
FontSize = 8,
Foreground = labelBrush,
};
diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
index cd1b23c2..52e44a81 100644
--- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
@@ -18,6 +18,12 @@ public partial class QueryStoreOverviewControl : UserControl
{
private readonly ServerConnection _serverConnection;
private readonly ICredentialService _credentialService;
+ ///
+ /// The offset holder of the connection this Overview was built on (E5). A reconnect throws the
+ /// Overview away and builds a new one (QuerySessionControl.InvalidateOverviewView), so this is
+ /// always the holder of the server whose data is on screen.
+ ///
+ private readonly ServerUtcOffset _serverOffset;
private readonly string _masterConnectionString;
private readonly int _maxDop;
private readonly int _topN;
@@ -86,10 +92,12 @@ public class DrillDownEventArgs(string database, DateTime startUtc, DateTime end
public event EventHandler? DrillDownRequested;
public QueryStoreOverviewControl(ServerConnection serverConnection,
- ICredentialService credentialService, int maxDop = 8, int? topN = null, bool supportsWaitStats = true)
+ ICredentialService credentialService, ServerUtcOffset serverOffset,
+ int maxDop = 8, int? topN = null, bool supportsWaitStats = true)
{
_serverConnection = serverConnection;
_credentialService = credentialService;
+ _serverOffset = serverOffset;
_masterConnectionString = serverConnection.GetConnectionString(credentialService, "master");
_maxDop = maxDop;
@@ -114,6 +122,9 @@ that a colour identifies a database everywhere. A database this palette cannot n
InitializeComponent();
+ // Declared in XAML, so handed the holder here, before any data reaches it.
+ OverviewTimeSlicer.ServerOffset = serverOffset;
+
/* Only the wait stats chart is drawn into a Canvas at absolute coordinates, so it is the
only thing left that has to be redrawn when the control resizes. The state card and the
metric cards are laid out by the panels they live in and reflow on their own. */
diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
index bb48cfaf..cc658a45 100644
--- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
@@ -58,6 +58,13 @@ private enum DragMode { None, MoveRange, DragStart, DragEnd, SelectRect }
public event EventHandler? RangeChanged;
+ ///
+ /// The offset holder of the connection whose data this slicer shows (E5). Assigned by the
+ /// owner right after InitializeComponent, before any data is loaded. A slicer nobody assigns
+ /// keeps a holder of its own at zero, which reads as UTC in Server mode.
+ ///
+ public ServerUtcOffset ServerOffset { get; set; } = new();
+
public TimeRangeSlicerControl()
{
_activeFilterTag = AppSettingsService.Load().QueryStoreDefaultTimeRange;
@@ -321,8 +328,8 @@ private void PopulatePickersFromSelection()
{
if (_data.Count == 0) return;
- var startDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart));
- var endDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd));
+ var startDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart), ServerOffset.Minutes);
+ var endDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd), ServerOffset.Minutes);
StartDatePicker.SelectedDate = startDisplay.Date;
StartTimePicker.SelectedTime = startDisplay.TimeOfDay;
@@ -331,21 +338,26 @@ private void PopulatePickersFromSelection()
EndTimePicker.SelectedTime = endDisplay.TimeOfDay;
// Set display date range limits from data bounds
- var firstDisplay = TimeDisplayHelper.ConvertForDisplay(_data[0].IntervalStartUtc);
- var lastDisplay = TimeDisplayHelper.ConvertForDisplay(_data[^1].IntervalStartUtc.AddHours(1));
+ var firstDisplay = TimeDisplayHelper.ConvertForDisplay(_data[0].IntervalStartUtc, ServerOffset.Minutes);
+ var lastDisplay = TimeDisplayHelper.ConvertForDisplay(_data[^1].IntervalStartUtc.AddHours(1), ServerOffset.Minutes);
StartDatePicker.DisplayDateStart = firstDisplay.Date;
StartDatePicker.DisplayDateEnd = lastDisplay.Date;
EndDatePicker.DisplayDateStart = firstDisplay.Date;
EndDatePicker.DisplayDateEnd = lastDisplay.Date;
}
- private static DateTime ConvertFromDisplay(DateTime displayTime)
+ ///
+ /// The inverse of : what the
+ /// custom-range popup was typed in, back to UTC. Server mode subtracts this slicer's own
+ /// connection's offset (E5). Internal so a test can pin it without driving two pickers.
+ ///
+ internal DateTime ConvertFromDisplay(DateTime displayTime)
{
return TimeDisplayHelper.Current switch
{
TimeDisplayMode.Local => displayTime.ToUniversalTime(),
TimeDisplayMode.Utc => DateTime.SpecifyKind(displayTime, DateTimeKind.Utc),
- TimeDisplayMode.Server => displayTime.AddMinutes(-TimeDisplayHelper.ServerUtcOffsetMinutes),
+ TimeDisplayMode.Server => displayTime.AddMinutes(-ServerOffset.Minutes),
_ => displayTime.ToUniversalTime()
};
}
@@ -464,7 +476,7 @@ public void Redraw()
for (int i = 0; i < n; i += labelInterval)
{
var x = i * stepX + stepX / 2;
- var dt = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc);
+ var dt = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc, ServerOffset.Minutes);
var label = dt.ToString("MM/dd HH:mm");
var tb = new TextBlock
{
@@ -493,8 +505,8 @@ public void Redraw()
var dayLineBrush = TryFindBrush("SlicerLabelBrush", FallbackDayLineBrush);
for (int di = 1; di < n; di++)
{
- var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc);
- var curDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di].IntervalStartUtc);
+ var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc, ServerOffset.Minutes);
+ var curDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di].IntervalStartUtc, ServerOffset.Minutes);
if (curDisplay.Date != prevDisplay.Date)
{
var xDay = di * stepX; // left edge of the bucket where the new day starts
@@ -651,7 +663,7 @@ public void Redraw()
var dotBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush);
for (int i = 0; i < n; i++)
{
- var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc);
+ var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc, ServerOffset.Minutes);
var bucketDisplayEnd = bucketDisplay.AddHours(1);
var val = values[i];
var valText = _metric is "executions" ? $"{val:N0}" : $"{val:N2}";
@@ -1026,8 +1038,8 @@ private void UpdateRangeLabel()
RangeLabel.Text = "";
return;
}
- var start = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart));
- var end = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd));
+ var start = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart), ServerOffset.Minutes);
+ var end = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd), ServerOffset.Minutes);
var span = end - start;
var spanText = span.TotalHours >= 48
? $"{span.TotalDays:F1}d"
diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs
index 690d3ff1..f0ca31b9 100644
--- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs
@@ -8,6 +8,7 @@
using Avalonia.Layout;
using Avalonia.Media;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.App.Controls;
@@ -26,6 +27,16 @@ private enum ViewMode { Bar, Ribbon }
public bool IsCollapsed => _isCollapsed;
+ ///
+ /// The offset holder of the connection whose data this profile shows, handed on to the ribbon
+ /// that draws the times (E5). Assigned by the owning grid right after InitializeComponent.
+ ///
+ public ServerUtcOffset ServerOffset
+ {
+ get => GlobalRibbon.ServerOffset;
+ set => GlobalRibbon.ServerOffset = value;
+ }
+
// All known wait categories in the order they appear in the theme
private static readonly string[] AllWaitCategories =
[
@@ -56,6 +67,9 @@ public void SetRibbonData(List data)
GlobalRibbon.SetData(data);
}
+ /// Redraws the ribbon with the data it has, after the time display mode changes.
+ internal void RedrawRibbon() => GlobalRibbon.Redraw();
+
public void SetHighlight(string? category)
{
GlobalBar.SetHighlight(category);
diff --git a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
index b34d9092..02a93e71 100644
--- a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
@@ -19,6 +19,13 @@ public partial class WaitStatsRibbonControl : UserControl
private List _data = new();
private string? _highlightCategory;
+ ///
+ /// The offset holder of the connection whose data this ribbon shows (E5). Assigned by the
+ /// owner right after InitializeComponent. A ribbon nobody assigns keeps a holder of its own at
+ /// zero, which reads as UTC in Server mode.
+ ///
+ public ServerUtcOffset ServerOffset { get; set; } = new();
+
public event EventHandler? CategoryClicked;
public event EventHandler? CategoryDoubleClicked;
@@ -43,7 +50,7 @@ public void SetHighlight(string? category)
Redraw();
}
- private void Redraw()
+ internal void Redraw()
{
RibbonCanvas.Children.Clear();
if (_data.Count == 0) return;
@@ -171,10 +178,10 @@ private void Redraw()
var intervalStart = hour;
var intervalEnd = intervalStart.AddHours(1);
- var startDisplay = TimeDisplayHelper.FormatForDisplay(intervalStart, "yyyy-MM-dd HH:mm");
+ var startDisplay = TimeDisplayHelper.FormatForDisplay(intervalStart, ServerOffset.Minutes, "yyyy-MM-dd HH:mm");
var endDisplay = intervalStart.Date == intervalEnd.Date
- ? TimeDisplayHelper.FormatForDisplay(intervalEnd, "HH:mm")
- : TimeDisplayHelper.FormatForDisplay(intervalEnd, "yyyy-MM-dd HH:mm");
+ ? TimeDisplayHelper.FormatForDisplay(intervalEnd, ServerOffset.Minutes, "HH:mm")
+ : TimeDisplayHelper.FormatForDisplay(intervalEnd, ServerOffset.Minutes, "yyyy-MM-dd HH:mm");
var tipBlock = new TextBlock
{
Text = $"{cat}: {WaitRatioFormatter.Format(ratio)}\n{startDisplay} \u2013 {endDisplay}",
@@ -261,7 +268,7 @@ private void Redraw()
var dt = allHours[i];
var tb = new TextBlock
{
- Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd"),
+ Text = TimeDisplayHelper.FormatForDisplay(dt, ServerOffset.Minutes, "MM/dd"),
FontSize = 8,
Foreground = labelBrush,
};
@@ -279,7 +286,7 @@ private void Redraw()
var dt = allHours[i];
var tb = new TextBlock
{
- Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd HH:mm"),
+ Text = TimeDisplayHelper.FormatForDisplay(dt, ServerOffset.Minutes, "MM/dd HH:mm"),
FontSize = 8,
Foreground = labelBrush,
};
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 840d8444..73225306 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -2,6 +2,7 @@
using Avalonia.Controls;
using PlanViewer.App.Controls;
using PlanViewer.App.Services;
+using PlanViewer.Core.Services;
namespace PlanViewer.App.Dialogs;
@@ -18,7 +19,7 @@ public QueryStoreHistoryWindow()
}
public QueryStoreHistoryWindow(string connectionString, string queryHash,
- string queryText, string database,
+ string queryText, string database, ServerUtcOffset serverOffset,
string? initialMetricTag = null,
DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
int? slicerDaysBack = null)
@@ -30,7 +31,7 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
var daysBack = slicerDaysBack ?? settings.QueryStoreSlicerDays;
var control = new QueryStoreHistoryControl(
- connectionString, queryHash, queryText, database,
+ connectionString, queryHash, queryText, database, serverOffset,
metricTag, slicerStartUtc, slicerEndUtc, daysBack);
control.ShowCloseButton(true);
Content = control;
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index e89b5b77..eae266b9 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -43,6 +43,14 @@ public class QueryStoreHistoryRow
public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N0");
public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N0");
public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
- public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
- public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
+
+ ///
+ /// The offset holder of the connection this row was fetched on. Set by whoever fetched it
+ /// (the History control), which keeps this model free of anything from the app. Unset reads
+ /// as zero, which is UTC in Server mode.
+ ///
+ public ServerUtcOffset? ServerOffset { get; set; }
+
+ public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc, ServerOffset?.Minutes ?? 0);
+ public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value, ServerOffset?.Minutes ?? 0) : "";
}
diff --git a/src/PlanViewer.Core/Services/TimeDisplayHelper.cs b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs
index b5dd608b..b016a98b 100644
--- a/src/PlanViewer.Core/Services/TimeDisplayHelper.cs
+++ b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs
@@ -9,30 +9,53 @@ public enum TimeDisplayMode
Server
}
-public static class TimeDisplayHelper
+///
+/// One connection's offset in minutes from UTC to its server's local time (E5).
+///
+/// Each connect makes a new one, and every document opened on that connection keeps it. That
+/// is the point of it being an object rather than a number: the offset is fetched a moment after
+/// the connect lands, and the documents that read it must be reading the connection they got their
+/// data from, not whichever connection the process touched last. It used to be a process-wide
+/// static, so two sessions on servers in different time zones — or one session that reconnected —
+/// shifted each other's Query Store times by the wrong server's offset.
+///
+/// Zero until the fetch lands, and zero if it fails, which reads as UTC in Server mode.
+/// Readers take the value each time they format a time, so a document opened before the fetch
+/// finished picks the real offset up the next time it redraws.
+///
+public sealed class ServerUtcOffset
{
- public static TimeDisplayMode Current { get; set; } = TimeDisplayMode.Local;
+ public int Minutes { get; set; }
+}
+public static class TimeDisplayHelper
+{
///
- /// Offset in minutes from UTC to the connected SQL Server's local time.
- /// Set after connecting to a server.
+ /// The user's display preference. Global on purpose: it is one setting, not something a
+ /// connection owns. The offset Server mode needs is not global, and is passed in by whoever
+ /// is formatting the time (see ).
///
- public static int ServerUtcOffsetMinutes { get; set; }
+ public static TimeDisplayMode Current { get; set; } = TimeDisplayMode.Local;
+
+ public static DateTime ConvertForDisplay(DateTime utcTime, int serverUtcOffsetMinutes)
+ {
+ return ConvertForDisplay(utcTime, Current, serverUtcOffsetMinutes);
+ }
- public static DateTime ConvertForDisplay(DateTime utcTime)
+ public static DateTime ConvertForDisplay(DateTime utcTime, TimeDisplayMode mode, int serverUtcOffsetMinutes)
{
- return Current switch
+ return mode switch
{
TimeDisplayMode.Local => utcTime.ToLocalTime(),
TimeDisplayMode.Utc => DateTime.SpecifyKind(utcTime, DateTimeKind.Utc),
- TimeDisplayMode.Server => utcTime.AddMinutes(ServerUtcOffsetMinutes),
+ TimeDisplayMode.Server => utcTime.AddMinutes(serverUtcOffsetMinutes),
_ => utcTime.ToLocalTime()
};
}
- public static string FormatForDisplay(DateTime utcTime, string format = "yyyy-MM-dd HH:mm")
+ public static string FormatForDisplay(DateTime utcTime, int serverUtcOffsetMinutes, string format = "yyyy-MM-dd HH:mm")
{
- return ConvertForDisplay(utcTime).ToString(format);
+ return ConvertForDisplay(utcTime, serverUtcOffsetMinutes).ToString(format);
}
public static string Suffix => Current switch
diff --git a/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs
index 77a9fa71..2d63fcb1 100644
--- a/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs
+++ b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs
@@ -5,6 +5,7 @@
using PlanViewer.App.Controls;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -104,6 +105,7 @@ public void GetActualPlanResolvesAGridPlanToItsGridsDatabaseAndOthersToTheToolba
var grid = new QueryStoreGridControl(
new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
new NoCredentials(),
+ new ServerUtcOffset(),
initialDatabase: "Sales",
databases: new List { "master", "Sales" });
diff --git a/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs
index 272326d7..b71f5c88 100644
--- a/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs
+++ b/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs
@@ -5,6 +5,7 @@
using PlanViewer.App.Controls;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -29,6 +30,7 @@ public void PickingASecondDatabaseCancelsTheFirstOnesCheck()
var grid = new QueryStoreGridControl(
new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
new NoCredentials(),
+ new ServerUtcOffset(),
initialDatabase: "master",
databases: new List { "master", "A", "B" });
@@ -67,6 +69,7 @@ public void ReselectingTheCurrentDatabaseStartsNoCheck()
var grid = new QueryStoreGridControl(
new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
new NoCredentials(),
+ new ServerUtcOffset(),
initialDatabase: "master",
databases: new List { "master", "A" });
diff --git a/tests/PlanViewer.Core.Tests/QueryStoreErrorDisplayTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreErrorDisplayTests.cs
index 5eda7b70..99e52c40 100644
--- a/tests/PlanViewer.Core.Tests/QueryStoreErrorDisplayTests.cs
+++ b/tests/PlanViewer.Core.Tests/QueryStoreErrorDisplayTests.cs
@@ -3,6 +3,7 @@
using PlanViewer.App.Controls;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -29,6 +30,7 @@ public void TheStatusStripMirrorsItsFullTextIntoTheTooltip()
var grid = new QueryStoreGridControl(
new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
new NoCredentials(),
+ new ServerUtcOffset(),
initialDatabase: "master",
databases: new List { "master" });
diff --git a/tests/PlanViewer.Core.Tests/QueryStoreGroupedRowIdDisplayTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreGroupedRowIdDisplayTests.cs
index 15adc7a4..18e04eb8 100644
--- a/tests/PlanViewer.Core.Tests/QueryStoreGroupedRowIdDisplayTests.cs
+++ b/tests/PlanViewer.Core.Tests/QueryStoreGroupedRowIdDisplayTests.cs
@@ -1,5 +1,6 @@
using PlanViewer.App.Controls;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -11,12 +12,12 @@ namespace PlanViewer.Core.Tests;
public class QueryStoreGroupedRowIdDisplayTests
{
private static QueryStoreRow LeafRow(long queryId, long planId) =>
- new(new QueryStorePlan { QueryId = queryId, PlanId = planId });
+ new(new QueryStorePlan { QueryId = queryId, PlanId = planId }, new ServerUtcOffset());
// How QueryStoreGridControl builds a grouped parent: AggregateGroupedRows returns a
// QueryStorePlan with the metric totals summed and no ids set at all.
private static QueryStoreRow AggregateRow(params QueryStoreRow[] children) =>
- new(new QueryStorePlan { QueryHash = "0x1A2B3C" }, 0, "0x1A2B3C", children.ToList());
+ new(new QueryStorePlan { QueryHash = "0x1A2B3C" }, 0, "0x1A2B3C", children.ToList(), new ServerUtcOffset());
[Fact]
public void LeafRowsShowTheIdsTheyHave()
diff --git a/tests/PlanViewer.Core.Tests/QueryStoreOverviewCardsTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreOverviewCardsTests.cs
index a382e31e..9e2eb654 100644
--- a/tests/PlanViewer.Core.Tests/QueryStoreOverviewCardsTests.cs
+++ b/tests/PlanViewer.Core.Tests/QueryStoreOverviewCardsTests.cs
@@ -6,6 +6,7 @@
using PlanViewer.App.Controls;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -451,7 +452,7 @@ private static DatabaseQueryStoreState NewState(string name, QueryStoreState sta
///
private static QueryStoreOverviewControl NewOverview() =>
new(new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
- new NoCredentials(), topN: 3);
+ new NoCredentials(), new ServerUtcOffset(), topN: 3);
///
/// One overview in one window, laid out so its styles are applied and its toggle segments are
diff --git a/tests/PlanViewer.Core.Tests/ServerFilterPanelPersistenceTests.cs b/tests/PlanViewer.Core.Tests/ServerFilterPanelPersistenceTests.cs
index 4fc07c85..567ead03 100644
--- a/tests/PlanViewer.Core.Tests/ServerFilterPanelPersistenceTests.cs
+++ b/tests/PlanViewer.Core.Tests/ServerFilterPanelPersistenceTests.cs
@@ -37,6 +37,7 @@ public void ExpandingTheServerFilterPanelSurvivesAnUnrelatedMainWindowSave()
var grid = new QueryStoreGridControl(
new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" },
CredentialServiceFactory.Create(),
+ new ServerUtcOffset(),
initialDatabase: "master",
databases: new List { "master" });
diff --git a/tests/PlanViewer.Core.Tests/ServerUtcOffsetPerConnectionTests.cs b/tests/PlanViewer.Core.Tests/ServerUtcOffsetPerConnectionTests.cs
new file mode 100644
index 00000000..83bbec8e
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/ServerUtcOffsetPerConnectionTests.cs
@@ -0,0 +1,525 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reflection;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using PlanViewer.App.Controls;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.Core.Tests;
+
+///
+/// E5: Server time display used to read one process-wide number, written by whichever connection
+/// connected last. Two sessions on servers in different time zones, or one session that
+/// reconnected, shifted each other's Query Store grid, slicer and History times by the wrong
+/// server's offset. The offset now belongs to a connection (), and
+/// every document opened on that connection keeps the holder it got.
+///
+/// Why the tests that show times run inside .
+/// is the user's display preference and stays global, so a
+/// test that needs Server mode has to set it. Every UI body runs whole on the one UI thread, which
+/// keeps another test from changing the mode halfway through, and puts it
+/// back. It is set AFTER a window is built, because MainWindow's constructor applies the
+/// saved preference. The conversion tests at the top take the mode as an argument and touch no
+/// global at all.
+///
+/// What is not covered. The offset query itself, and the History control copying its
+/// holder onto the rows a fetch returns, both need a server. The rows are tested with the holder
+/// already on them, and the History control is tested for which holder it was given.
+///
+public class ServerUtcOffsetPerConnectionTests
+{
+ private static readonly DateTime Noon = new(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ // Minutes from UTC, the way DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) reports them.
+ private const int Berlin = 120;
+ private const int NewYork = -300;
+
+ private static readonly List Databases = new() { "master", "Sales", "Ops" };
+
+ // ---- The conversion takes the offset it is given ---------------------------------------------
+
+ [Theory]
+ [InlineData(Berlin)]
+ [InlineData(NewYork)]
+ [InlineData(330)] // a half-hour zone: the offset is minutes, not hours
+ [InlineData(0)]
+ public void ServerModeAddsTheOffsetItWasGiven(int minutes)
+ {
+ var shown = TimeDisplayHelper.ConvertForDisplay(Noon, TimeDisplayMode.Server, minutes);
+
+ Assert.Equal(Noon.AddMinutes(minutes), shown);
+ }
+
+ [Theory]
+ [InlineData(Berlin)]
+ [InlineData(NewYork)]
+ public void UtcModeShowsUtcWhateverOffsetItWasGiven(int minutes)
+ {
+ var shown = TimeDisplayHelper.ConvertForDisplay(Noon, TimeDisplayMode.Utc, minutes);
+
+ Assert.Equal(Noon, shown);
+ Assert.Equal(DateTimeKind.Utc, shown.Kind);
+ }
+
+ [Theory]
+ [InlineData(Berlin)]
+ [InlineData(NewYork)]
+ public void LocalModeShowsThisMachinesTimeWhateverOffsetItWasGiven(int minutes)
+ {
+ var shown = TimeDisplayHelper.ConvertForDisplay(Noon, TimeDisplayMode.Local, minutes);
+
+ Assert.Equal(Noon.ToLocalTime(), shown);
+ }
+
+ ///
+ /// The overloads the controls call read the mode from the global and the offset from the
+ /// argument. Nothing about the offset is global any more.
+ ///
+ [Fact]
+ public void TheModeIsGlobalAndTheOffsetIsAnArgument()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+
+ Assert.Equal(Noon.AddMinutes(Berlin), TimeDisplayHelper.ConvertForDisplay(Noon, Berlin));
+ Assert.Equal(Noon.AddMinutes(NewYork), TimeDisplayHelper.ConvertForDisplay(Noon, NewYork));
+ Assert.Equal("14:00", TimeDisplayHelper.FormatForDisplay(Noon, Berlin, "HH:mm"));
+ Assert.Equal("07:00", TimeDisplayHelper.FormatForDisplay(Noon, NewYork, "HH:mm"));
+ });
+ }
+
+ // ---- What shows a time reads its own connection's holder ------------------------------------
+
+ [Fact]
+ public void GridRowsOnTwoConnectionsEachShowTheirOwnServersTime()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+ var plan = new QueryStorePlan { LastExecutedUtc = Noon };
+ var berlin = new ServerUtcOffset { Minutes = Berlin };
+ var newYork = new ServerUtcOffset { Minutes = NewYork };
+
+ var leafOnBerlin = new QueryStoreRow(plan, berlin);
+ var leafOnNewYork = new QueryStoreRow(plan, newYork);
+ var groupOnNewYork = new QueryStoreRow(plan, 0, "0x1", new List { leafOnNewYork }, newYork);
+
+ Assert.Equal("2026-09-28 14:00", leafOnBerlin.LastExecutedLocal);
+ Assert.Equal("2026-09-28 07:00", leafOnNewYork.LastExecutedLocal);
+ Assert.Equal("2026-09-28 07:00", groupOnNewYork.LastExecutedLocal);
+ });
+ }
+
+ ///
+ /// The offset arrives a moment after the connect, so a row can exist before it does. It reads
+ /// the holder each time it formats, so it shows the real offset from then on.
+ ///
+ [Fact]
+ public void ARowBuiltBeforeTheOffsetArrivesShowsItOnceItDoes()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+ var holder = new ServerUtcOffset();
+ var row = new QueryStoreRow(new QueryStorePlan { LastExecutedUtc = Noon }, holder);
+
+ Assert.Equal("2026-09-28 12:00", row.LastExecutedLocal); // zero: reads as UTC
+
+ holder.Minutes = Berlin;
+
+ Assert.Equal("2026-09-28 14:00", row.LastExecutedLocal);
+ });
+ }
+
+ [Fact]
+ public void HistoryRowsOnTwoConnectionsEachShowTheirOwnServersTime()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+ var onBerlin = HistoryRow(new ServerUtcOffset { Minutes = Berlin });
+ var onNewYork = HistoryRow(new ServerUtcOffset { Minutes = NewYork });
+ var unset = HistoryRow(null);
+
+ Assert.Equal("2026-09-28 14:00", onBerlin.IntervalStartLocal);
+ Assert.Equal("2026-09-28 14:00", onBerlin.LastExecutionLocal);
+ Assert.Equal("2026-09-28 07:00", onNewYork.IntervalStartLocal);
+ Assert.Equal("2026-09-28 07:00", onNewYork.LastExecutionLocal);
+
+ // A row nobody gave a holder reads as UTC rather than borrowing someone else's offset.
+ Assert.Equal("2026-09-28 12:00", unset.IntervalStartLocal);
+ Assert.Equal("2026-09-28 12:00", unset.LastExecutionLocal);
+ });
+ }
+
+ [Fact]
+ public void SlicersOnTwoConnectionsEachLabelTheirRangeInTheirOwnServersTime()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+ var onBerlin = new TimeRangeSlicerControl { ServerOffset = new ServerUtcOffset { Minutes = Berlin } };
+ var onNewYork = new TimeRangeSlicerControl { ServerOffset = new ServerUtcOffset { Minutes = NewYork } };
+
+ Assert.Contains("2026-01-01 02:00 → 2026-01-03 02:00", RangeLabelAfterLoading(onBerlin));
+ Assert.Contains("2025-12-31 19:00 → 2026-01-02 19:00", RangeLabelAfterLoading(onNewYork));
+ });
+ }
+
+ ///
+ /// The custom-range popup turns what was typed back into UTC, and it has to subtract the
+ /// slicer's own connection's offset: that was the one place the old static was read backwards.
+ ///
+ [Fact]
+ public void TheCustomRangePopupConvertsBackWithItsOwnConnectionsOffset()
+ {
+ HeadlessUi.Run(() =>
+ {
+ using var _ = new ServerMode();
+ var onBerlin = new TimeRangeSlicerControl { ServerOffset = new ServerUtcOffset { Minutes = Berlin } };
+ var onNewYork = new TimeRangeSlicerControl { ServerOffset = new ServerUtcOffset { Minutes = NewYork } };
+ var typed = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Unspecified);
+
+ Assert.Equal(new DateTime(2026, 9, 28, 10, 0, 0), onBerlin.ConvertFromDisplay(typed));
+ Assert.Equal(new DateTime(2026, 9, 28, 17, 0, 0), onNewYork.ConvertFromDisplay(typed));
+ });
+ }
+
+ [Fact]
+ public void RibbonsOnTwoConnectionsEachTipTheirBarsInTheirOwnServersTime()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var onBerlin = new WaitStatsRibbonControl { ServerOffset = new ServerUtcOffset { Minutes = Berlin } };
+ var onNewYork = new WaitStatsRibbonControl { ServerOffset = new ServerUtcOffset { Minutes = NewYork } };
+ var berlinWindow = new Window { Content = onBerlin, Width = 600, Height = 200 };
+ var newYorkWindow = new Window { Content = onNewYork, Width = 600, Height = 200 };
+ berlinWindow.Show();
+ newYorkWindow.Show();
+ try
+ {
+ using var _ = new ServerMode();
+
+ Assert.Contains("2026-01-01 02:00 – 03:00", FirstBarTip(onBerlin, berlinWindow));
+ Assert.Contains("2025-12-31 19:00 – 20:00", FirstBarTip(onNewYork, newYorkWindow));
+ }
+ finally
+ {
+ berlinWindow.Close();
+ newYorkWindow.Close();
+ }
+ });
+ }
+
+ // ---- A connection owns its holder ------------------------------------------------------------
+
+ ///
+ /// The finding's first failure: two sessions on servers in different time zones. Each session's
+ /// documents carry the holder of the connection the session made, so neither can move the
+ /// other's times.
+ ///
+ [Fact]
+ public void TwoSessionsOnServersInDifferentTimeZonesKeepTheirOwnOffsets()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var (windowA, sessionA) = SessionHarness.NewSession();
+ var (windowB, sessionB) = SessionHarness.NewSession();
+ try
+ {
+ SessionHarness.PretendConnected(sessionA);
+ SessionHarness.PretendConnected(sessionB);
+ var berlin = sessionA.BeginServerConnection();
+ berlin.Minutes = Berlin;
+ var newYork = sessionB.BeginServerConnection();
+ newYork.Minutes = NewYork;
+
+ var gridA = sessionA.NewQueryStoreGrid("Sales", Databases, supportsWaitStats: false);
+ var gridB = sessionB.NewQueryStoreGrid("Sales", Databases, supportsWaitStats: false);
+ sessionA.AddQueryStoreDocument(gridA, "Sales");
+ sessionB.AddQueryStoreDocument(gridB, "Sales");
+
+ Assert.NotSame(berlin, newYork);
+ Assert.Same(berlin, HolderOf(gridA));
+ Assert.Same(newYork, HolderOf(gridB));
+
+ // What each grid hands the controls it draws with.
+ Assert.Same(berlin, gridA.FindControl("TimeRangeSlicer")!.ServerOffset);
+ Assert.Same(berlin, gridA.FindControl("WaitStatsProfile")!.ServerOffset);
+ Assert.Same(newYork, gridB.FindControl("TimeRangeSlicer")!.ServerOffset);
+ Assert.Same(newYork, gridB.FindControl("WaitStatsProfile")!.ServerOffset);
+
+ // Sessions connected after the fact do not reach back into the first one's documents.
+ Assert.Equal(Berlin, berlin.Minutes);
+ Assert.Equal(NewYork, newYork.Minutes);
+
+ // And the two times a row on each of them would show, now that the mode is Server.
+ using var _ = new ServerMode();
+ var plan = new QueryStorePlan { LastExecutedUtc = Noon };
+ Assert.Equal("2026-09-28 14:00", new QueryStoreRow(plan, HolderOf(gridA)).LastExecutedLocal);
+ Assert.Equal("2026-09-28 07:00", new QueryStoreRow(plan, HolderOf(gridB)).LastExecutedLocal);
+ }
+ finally
+ {
+ ChromeTestCleanup.PutAway(windowA);
+ ChromeTestCleanup.PutAway(windowB);
+ }
+ });
+ }
+
+ ///
+ /// The finding's second failure: one session that reconnects. A document already open keeps
+ /// the holder of the connection it was opened on, so the new server's offset cannot shift its
+ /// times; a document opened after the reconnect gets the new connection's holder. The
+ /// Overview is included because it is the one view the session rebuilds itself on a connect.
+ ///
+ [Fact]
+ public void AfterAReconnectAnOpenDocumentKeepsItsOffsetAndANewOneGetsTheNewOne()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var (window, session) = SessionHarness.NewSession();
+ try
+ {
+ SessionHarness.PretendConnected(session);
+ var first = session.BeginServerConnection();
+ first.Minutes = Berlin;
+
+ SessionHarness.OverviewSegment(session).IsChecked = true;
+ var overviewBefore = SessionHarness.OverviewView(session)!;
+ SessionHarness.StopOverviewLoad(overviewBefore);
+
+ var gridBefore = session.NewQueryStoreGrid("Sales", Databases, supportsWaitStats: false);
+ session.AddQueryStoreDocument(gridBefore, "Sales");
+ var tabBefore = SessionHarness.Documents(session).Last();
+
+ // Reconnect somewhere else, the way the connect block does it.
+ SessionHarness.PretendConnected(session, serverName: "tcp:127.0.0.1,2");
+ var second = session.BeginServerConnection();
+ second.Minutes = NewYork;
+ SessionHarness.InvalidateOverview(session);
+
+ SessionHarness.OverviewSegment(session).IsChecked = true;
+ var overviewAfter = SessionHarness.OverviewView(session)!;
+ SessionHarness.StopOverviewLoad(overviewAfter);
+
+ var gridAfter = session.NewQueryStoreGrid("Ops", Databases, supportsWaitStats: false);
+ session.AddQueryStoreDocument(gridAfter, "Ops");
+
+ Assert.NotSame(first, second);
+ Assert.Equal(Berlin, first.Minutes); // the reconnect did not write to it
+
+ // Already open: still the old server's.
+ Assert.Same(first, HolderOf(gridBefore));
+ Assert.Same(first, gridBefore.FindControl("TimeRangeSlicer")!.ServerOffset);
+ Assert.Same(first, HolderOf(overviewBefore));
+
+ // Opened after: the new server's.
+ Assert.Same(second, HolderOf(gridAfter));
+ Assert.Same(second, gridAfter.FindControl("TimeRangeSlicer")!.ServerOffset);
+ Assert.Same(second, HolderOf(overviewAfter));
+ Assert.NotSame(overviewBefore, overviewAfter);
+
+ /* A History opened from the OLD grid after the reconnect reads the old grid's
+ server, so it gets the old holder, not the session's current one. */
+ SessionHarness.PressHeader(session, tabBefore);
+ window.UpdateLayout();
+ var row = new QueryStoreRow(
+ new QueryStorePlan { QueryHash = "0xABC", QueryText = "select 1;", LastExecutedUtc = Noon },
+ first);
+ ((ObservableCollection)FieldOf(gridBefore, "_filteredRows").GetValue(gridBefore)!).Add(row);
+ gridBefore.FindControl("ResultsGrid")!.SelectedItem = row;
+ typeof(QueryStoreGridControl)
+ .GetMethod("ViewHistory_Click", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(gridBefore, new object?[] { null, new RoutedEventArgs() });
+
+ var history = Assert.IsType(SessionHarness.Documents(session).Last().Content);
+ Assert.Same(first, HolderOf(history));
+
+ // What that comes to on screen, in Server mode: each document its own server's time.
+ using var _ = new ServerMode();
+ var plan = new QueryStorePlan { LastExecutedUtc = Noon };
+ Assert.Equal("2026-09-28 14:00", new QueryStoreRow(plan, HolderOf(gridBefore)).LastExecutedLocal);
+ Assert.Equal("2026-09-28 14:00", HistoryRow(HolderOf(history)).IntervalStartLocal);
+ Assert.Equal("2026-09-28 07:00", new QueryStoreRow(plan, HolderOf(gridAfter)).LastExecutedLocal);
+ }
+ finally
+ {
+ ChromeTestCleanup.PutAway(window);
+ }
+ });
+ }
+
+ ///
+ /// The connect block hands the holder to the fetch as an argument instead of letting the fetch
+ /// read the session's field afterwards. The seam is what makes that safe: every call to it is
+ /// a new holder, so an answer that lands late fills the connection that asked.
+ ///
+ [Fact]
+ public void EveryConnectMakesANewHolder()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var (window, session) = SessionHarness.NewSession();
+ try
+ {
+ var first = session.BeginServerConnection();
+ first.Minutes = Berlin;
+ var second = session.BeginServerConnection();
+
+ Assert.NotSame(first, second);
+ Assert.Equal(0, second.Minutes);
+ Assert.Equal(Berlin, first.Minutes);
+ }
+ finally
+ {
+ ChromeTestCleanup.PutAway(window);
+ }
+ });
+ }
+
+ // ---- The mode the grid shows -----------------------------------------------------------------
+
+ ///
+ /// The mode is one setting for the whole app, so a grid's box opens on the mode in effect. It
+ /// used to open on Local whatever the setting or another grid had chosen, and could say Local
+ /// beside times shown in Server mode. Opening a grid must not change the mode either.
+ ///
+ [Theory]
+ [InlineData(TimeDisplayMode.Local)]
+ [InlineData(TimeDisplayMode.Utc)]
+ [InlineData(TimeDisplayMode.Server)]
+ public void AGridsTimeDisplayBoxOpensOnTheModeInEffect(TimeDisplayMode mode)
+ {
+ HeadlessUi.Run(() =>
+ {
+ var (window, session) = SessionHarness.NewSession();
+ var before = TimeDisplayHelper.Current;
+ try
+ {
+ SessionHarness.PretendConnected(session);
+ TimeDisplayHelper.Current = mode;
+
+ var grid = session.NewQueryStoreGrid("Sales", Databases, supportsWaitStats: false);
+
+ var box = grid.FindControl("TimeDisplayBox")!;
+ Assert.Equal(mode.ToString(), (box.SelectedItem as ComboBoxItem)?.Tag?.ToString());
+ Assert.Equal(mode, TimeDisplayHelper.Current);
+ }
+ finally
+ {
+ TimeDisplayHelper.Current = before;
+ ChromeTestCleanup.PutAway(window);
+ }
+ });
+ }
+
+ ///
+ /// A mode change redraws the wait ribbon too. The grid redrew its rows and its slicer, and the
+ /// ribbon kept the old mode's labels and tips until something resized it.
+ ///
+ [Fact]
+ public void RedrawingTheRibbonAfterAModeChangeShowsTheNewMode()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var profile = new WaitStatsProfileControl { ServerOffset = new ServerUtcOffset { Minutes = Berlin } };
+ var window = new Window { Content = profile, Width = 600, Height = 200 };
+ window.Show();
+ var before = TimeDisplayHelper.Current;
+ try
+ {
+ TimeDisplayHelper.Current = TimeDisplayMode.Utc;
+ var ribbon = profile.FindControl("GlobalRibbon")!;
+ Assert.Contains("2026-01-01 00:00 – 01:00", FirstBarTip(ribbon, window));
+
+ TimeDisplayHelper.Current = TimeDisplayMode.Server;
+ profile.RedrawRibbon();
+
+ Assert.Contains("2026-01-01 02:00 – 03:00", BarTip(ribbon));
+ }
+ finally
+ {
+ TimeDisplayHelper.Current = before;
+ window.Close();
+ }
+ });
+ }
+
+ // ---- Helpers ---------------------------------------------------------------------------------
+
+ /// Sets Server mode for a test's body and puts the user's preference back after.
+ private sealed class ServerMode : IDisposable
+ {
+ private readonly TimeDisplayMode _before = TimeDisplayHelper.Current;
+
+ public ServerMode() => TimeDisplayHelper.Current = TimeDisplayMode.Server;
+
+ public void Dispose() => TimeDisplayHelper.Current = _before;
+ }
+
+ private static QueryStoreHistoryRow HistoryRow(ServerUtcOffset? holder) => new()
+ {
+ IntervalStartUtc = Noon,
+ LastExecutionUtc = Noon,
+ ServerOffset = holder,
+ };
+
+ ///
+ /// Loads two days of hourly data and selects all of it, so both ends of the range sit exactly
+ /// on bucket edges and the label carries no rounding.
+ ///
+ private static string RangeLabelAfterLoading(TimeRangeSlicerControl slicer)
+ {
+ var data = Enumerable.Range(0, 48).Select(i => new QueryStoreTimeSlice
+ {
+ IntervalStartUtc = Epoch.AddHours(i),
+ TotalCpu = 100,
+ TotalDuration = 200,
+ TotalExecutions = 10,
+ }).ToList();
+
+ slicer.LoadData(data, "cpu", Epoch, Epoch.AddHours(48));
+ return slicer.FindControl("RangeLabel")!.Text ?? "";
+ }
+
+ /// The tooltip text on the first bar a laid-out ribbon draws for one hour of data.
+ private static string FirstBarTip(WaitStatsRibbonControl ribbon, Window window)
+ {
+ ribbon.SetData(new List
+ {
+ new() { IntervalStartUtc = Epoch, WaitCategory = 1, WaitCategoryDesc = "CPU", WaitRatio = 1.0 },
+ });
+ window.UpdateLayout();
+ ribbon.SetData(new List
+ {
+ new() { IntervalStartUtc = Epoch, WaitCategory = 1, WaitCategoryDesc = "CPU", WaitRatio = 1.0 },
+ });
+
+ return BarTip(ribbon);
+ }
+
+ /// The tooltip text on the first bar the ribbon has drawn, without loading new data.
+ private static string BarTip(WaitStatsRibbonControl ribbon)
+ {
+ var canvas = ribbon.FindControl