fix(dashboard): alarms feed doc row + monitor-contract comment + mid-truncation attach test

The In-process page feeds table still described the alarms page's subscription as
provider status only, contradicting the three passages updated in 7b6dfba.

The loop's catch-all comment (and the cadence bullet that repeated it) claimed the
monitor completes a subscriber's stream on restart. It does not: ClearCache pushes
snapshot_status(false) through the still-open channel, and a subscriber is only
completed-with-error on a failed TryWrite, or removed by its own disposal.

The SnapshotStatus arm's priming claim had no test behind it — every push test
attached to an untruncated feed and pushed the edge itself. ScriptedAlarmFeed now
replays an optional open sequence, and a new test attaches to a feed already
primed provider_status then snapshot_status(truncated) and asserts the banner
comes up with no edge pushed after render.
This commit is contained in:
Joseph Doherty
2026-08-18 06:49:20 -04:00
parent d3ac52758c
commit f57a6ae5ff
3 changed files with 63 additions and 8 deletions
+4 -4
View File
@@ -186,7 +186,7 @@ hubs stay for the audience that genuinely needs a wire.
|---|---|---| |---|---|---|
| every page deriving from `DashboardPageBase` | `IDashboardSnapshotFeed.WatchAsync` | `DashboardSnapshotFeed` (singleton) multicasting one `IDashboardSnapshotService.WatchSnapshotsAsync` enumeration | | every page deriving from `DashboardPageBase` | `IDashboardSnapshotFeed.WatchAsync` | `DashboardSnapshotFeed` (singleton) multicasting one `IDashboardSnapshotService.WatchSnapshotsAsync` enumeration |
| `SessionDetailsPage` | `IDashboardSessionEventSubscriber.Subscribe(sessionId)` | `DashboardEventBroadcaster` — the same singleton the session mirror publishes to, registered behind both interfaces | | `SessionDetailsPage` | `IDashboardSessionEventSubscriber.Subscribe(sessionId)` | `DashboardEventBroadcaster` — the same singleton the session mirror publishes to, registered behind both interfaces |
| `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **provider status only**; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll | | `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **gateway-status frames only**`provider_status` for the badge and `snapshot_status` for the truncated-snapshot banner; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll |
The snapshot feed multicasts rather than handing each page its own enumeration: The snapshot feed multicasts rather than handing each page its own enumeration:
`WatchSnapshotsAsync` is not multicast on its own — each enumeration owns a timer `WatchSnapshotsAsync` is not multicast on its own — each enumeration owns a timer
@@ -288,9 +288,9 @@ Both seams consume the same producing services, so they share these cadences:
to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`); to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`);
- the alarms page's status feed resubscribes one second after its - the alarms page's status feed resubscribes one second after its
`IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a `IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a
subscriber's stream when it falls behind and again when it restarts, both subscriber's stream only when that subscriber has fallen behind, which resubscribing
recoverable by resubscribing — and the badge and banner hold their last values in recovers (a monitor restart keeps the channel and pushes cleared status frames through
between. That feed carries both gateway-status frames: `provider_status` drives the it) — and the badge and banner hold their last values in between. That feed carries both gateway-status frames: `provider_status` drives the
badge, and `snapshot_status` drives the truncated-snapshot banner, so the caveat badge, and `snapshot_status` drives the truncated-snapshot banner, so the caveat
appears on the monitor's verdict change rather than up to three seconds later. Every appears on the monitor's verdict change rather than up to three seconds later. Every
subscriber is primed with a `snapshot_status` frame at open, so a page attaching subscriber is primed with a `snapshot_status` frame at open, so a page attaching
@@ -258,10 +258,13 @@
} }
catch catch
{ {
// The monitor completes a subscriber's stream when it falls behind, and // The monitor drops a subscriber whose queue it cannot write to, completing
// again when the monitor restarts. Both are recoverable by resubscribing; // that stream with an error; short of cancellation or disposal that is the
// the badge and banner hold their last values in the meantime, and the // only way this enumeration ends. A monitor restart is NOT one of them — it
// resubscribe is primed with the current ones. // keeps the channel and pushes the cleared status frames through it — so this
// catch is the fell-behind case, recoverable by resubscribing. The badge and
// banner hold their last values meanwhile, and the resubscribe is primed with
// the current ones.
} }
try try
@@ -134,6 +134,44 @@ public sealed class AlarmsPageTruncationBannerTests
"banner to clear after a complete snapshot_status frame"); "banner to clear after a complete snapshot_status frame");
} }
/// <summary>
/// Attach while the verdict is already truncated. The page carries no
/// priming logic of its own — it relies on <c>StreamAsync</c> opening every
/// subscription with a <c>snapshot_status</c> baseline — so the caveat has
/// to come up off the open sequence alone, with no edge pushed afterwards.
/// A page that only handled the edge would show an un-caveated alarm list
/// to every operator who opened it after the truncation began.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_AttachingToAnAlreadyTruncatedFeed_RaisesTheBannerFromThePriming()
{
// The monitor's open sequence: provider status, then the unconditional
// completeness baseline. Nothing is pushed after this.
ScriptedAlarmFeed feed = new()
{
Priming =
[
new AlarmFeedMessage { ProviderStatus = new AlarmProviderStatus() },
SnapshotStatusFrame(truncated: true),
],
};
await using ServiceProvider provider = BuildPushServices(feed);
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
() => renderer.RenderComponentAsync<AlarmsPage>());
await WaitForHtmlAsync(
renderer,
page,
html => html.Contains(BannerMarker, StringComparison.Ordinal),
"banner to appear from the feed's open-time snapshot_status baseline");
}
private static AlarmFeedMessage SnapshotStatusFrame(bool truncated) private static AlarmFeedMessage SnapshotStatusFrame(bool truncated)
{ {
return new AlarmFeedMessage return new AlarmFeedMessage
@@ -280,6 +318,14 @@ public sealed class AlarmsPageTruncationBannerTests
/// <inheritdoc /> /// <inheritdoc />
public bool SnapshotTruncated { get; set; } public bool SnapshotTruncated { get; set; }
/// <summary>
/// Frames replayed at the head of every subscription, standing in for the
/// monitor's open sequence (provider status, then the unconditional
/// completeness baseline). Empty means the subscriber sees only what the
/// test pushes.
/// </summary>
public IReadOnlyList<AlarmFeedMessage> Priming { get; init; } = [];
/// <summary>Pushes one frame onto the feed the page is subscribed to.</summary> /// <summary>Pushes one frame onto the feed the page is subscribed to.</summary>
/// <param name="message">The feed frame to deliver.</param> /// <param name="message">The feed frame to deliver.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -290,6 +336,12 @@ public sealed class AlarmsPageTruncationBannerTests
string? alarmFilterPrefix, string? alarmFilterPrefix,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
foreach (AlarmFeedMessage primed in Priming)
{
cancellationToken.ThrowIfCancellationRequested();
yield return primed;
}
await foreach (AlarmFeedMessage message in _frames.Reader await foreach (AlarmFeedMessage message in _frames.Reader
.ReadAllAsync(cancellationToken) .ReadAllAsync(cancellationToken)
.ConfigureAwait(false)) .ConfigureAwait(false))