feat(dashboard): alarms page consumes snapshot_status feed frame for the truncation banner

The truncated-snapshot caveat moved from poll-only to push-driven. The page
already held an in-process alarm-feed subscription for the provider badge; it
now also handles the feed's snapshot_status frame, so a capped provider fetch is
caveated when the monitor decides it rather than up to three seconds later.

The poll's assignment stays as the reconcile baseline — both sources read the
same monitor verdict, and the frame is consumed, never synthesized page-side.
StreamAsync primes every subscriber with a snapshot_status frame at open, so a
page attaching mid-truncation needs no priming logic of its own; the loop is
renamed StatusFeedLoopAsync because it now feeds two indicators, not one.
This commit is contained in:
Joseph Doherty
2026-08-18 06:43:23 -04:00
parent 2b1efb5e50
commit 7b6dfba654
3 changed files with 266 additions and 22 deletions
@@ -191,13 +191,13 @@
private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
private Task? _providerStatusTask;
private Task? _statusFeedTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
_providerStatusTask = ProviderStatusLoopAsync();
_statusFeedTask = StatusFeedLoopAsync();
}
private string? ProviderStatusTitle()
@@ -210,8 +210,13 @@
// The badge tracks the central monitor directly rather than looping back through
// /hubs/alarms: the alarm service is an in-process multi-subscriber fan-out, so a
// server-rendered page needs no SignalR client, no loopback socket and no auth token.
// Alarm rows still come from the 3-second poll below — this loop only feeds the badge.
private async Task ProviderStatusLoopAsync()
// This loop feeds the two gateway-status indicators — the provider badge and the
// truncation banner — from the feed's own status frames, so both move as soon as the
// monitor's verdict changes instead of on the next 3-second tick. Alarm rows still come
// from the poll below, which also re-asserts the truncation verdict as its reconcile
// baseline: both sources read the same monitor verdict, so they cannot disagree for
// longer than one tick, and neither one is synthesized here.
private async Task StatusFeedLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
@@ -221,16 +226,30 @@
.StreamAsync(alarmFilterPrefix: null, _cts.Token)
.ConfigureAwait(false))
{
if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
switch (message.PayloadCase)
{
continue;
}
case AlarmFeedMessage.PayloadOneofCase.ProviderStatus:
await InvokeAsync(() =>
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
StateHasChanged();
}).ConfigureAwait(false);
break;
await InvokeAsync(() =>
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
StateHasChanged();
}).ConfigureAwait(false);
// Every subscriber is primed with this frame at open, so a page that
// attaches mid-truncation gets the caveat without waiting for an edge —
// no page-side priming needed.
case AlarmFeedMessage.PayloadOneofCase.SnapshotStatus:
await InvokeAsync(() =>
{
_snapshotTruncated = message.SnapshotStatus.Truncated;
StateHasChanged();
}).ConfigureAwait(false);
break;
default:
break;
}
}
}
catch (OperationCanceledException)
@@ -241,7 +260,8 @@
{
// The monitor completes a subscriber's stream when it falls behind, and
// again when the monitor restarts. Both are recoverable by resubscribing;
// the badge holds its last value in the meantime.
// the badge and banner hold their last values in the meantime, and the
// resubscribe is primed with the current ones.
}
try
@@ -321,7 +341,7 @@
};
}
// Fault handling sits inside the loop, matching ProviderStatusLoopAsync: a query or render
// Fault handling sits inside the loop, matching StatusFeedLoopAsync: a query or render
// fault on one tick is transient (a provider blip, a momentarily unavailable session), so it
// is surfaced on the page and retried on the next tick rather than ending polling for the
// life of the page. Cancellation is the only exit. The loop method itself therefore cannot
@@ -398,6 +418,10 @@
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
// Kept alongside the feed's snapshot_status frame rather than replaced by it: this is
// the reconcile baseline. Both read the same monitor verdict, so the poll can only
// confirm what the frame already showed — but it also re-establishes the banner for a
// page whose feed subscription is mid-resubscribe after the monitor dropped it.
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
@@ -416,7 +440,7 @@
// Drained together, not one after the other: the wedged dispatcher this bound exists
// for blocks both loops at once, so sequential drains would time out twice and make
// the real bound 10 seconds. DrainAsync tolerates a null task.
await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_providerStatusTask))
await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_statusFeedTask))
.ConfigureAwait(false);
_cts.Dispose();