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))