diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md
index 5bd7241..0f6d16e 100644
--- a/docs/GatewayDashboardDesign.md
+++ b/docs/GatewayDashboardDesign.md
@@ -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 |
| `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:
`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`);
- the alarms page's status feed resubscribes one second after its
`IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a
- subscriber's stream when it falls behind and again when it restarts, both
- recoverable by resubscribing — and the badge and banner hold their last values in
- between. That feed carries both gateway-status frames: `provider_status` drives the
+ subscriber's stream only when that subscriber has fallen behind, which resubscribing
+ recovers (a monitor restart keeps the channel and pushes cleared status frames through
+ 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
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
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
index 8c3ad85..d12ecbd 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
@@ -258,10 +258,13 @@
}
catch
{
- // The monitor completes a subscriber's stream when it falls behind, and
- // again when the monitor restarts. Both are recoverable by resubscribing;
- // the badge and banner hold their last values in the meantime, and the
- // resubscribe is primed with the current ones.
+ // The monitor drops a subscriber whose queue it cannot write to, completing
+ // that stream with an error; short of cancellation or disposal that is the
+ // only way this enumeration ends. A monitor restart is NOT one of them — it
+ // 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
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
index 8a7e7fc..d14339b 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
@@ -134,6 +134,44 @@ public sealed class AlarmsPageTruncationBannerTests
"banner to clear after a complete snapshot_status frame");
}
+ ///
+ /// Attach while the verdict is already truncated. The page carries no
+ /// priming logic of its own — it relies on StreamAsync opening every
+ /// subscription with a snapshot_status 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.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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());
+
+ HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
+ () => renderer.RenderComponentAsync());
+
+ 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)
{
return new AlarmFeedMessage
@@ -280,6 +318,14 @@ public sealed class AlarmsPageTruncationBannerTests
///
public bool SnapshotTruncated { get; set; }
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList Priming { get; init; } = [];
+
/// Pushes one frame onto the feed the page is subscribed to.
/// The feed frame to deliver.
/// A task that represents the asynchronous operation.
@@ -290,6 +336,12 @@ public sealed class AlarmsPageTruncationBannerTests
string? alarmFilterPrefix,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
+ foreach (AlarmFeedMessage primed in Priming)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return primed;
+ }
+
await foreach (AlarmFeedMessage message in _frames.Reader
.ReadAllAsync(cancellationToken)
.ConfigureAwait(false))