diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md
index f69b8c0..5bd7241 100644
--- a/docs/GatewayDashboardDesign.md
+++ b/docs/GatewayDashboardDesign.md
@@ -245,8 +245,9 @@ cancelling, a detach-driven exit leaves the pill to the incoming subscription; w
the pill therefore reports is the case it exists for — the channel completing under
a page that is still watching.
-`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the provider-status
-badge) and bounds their drain at 5 seconds on dispose, for the same reason
+`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the status feed that
+drives the provider badge and the truncated-snapshot banner) and bounds their drain
+at 5 seconds on dispose, for the same reason
`DashboardPageBase` bounds its watch drain: both loops render through the renderer's
dispatcher, and disposal can run on it. The two are drained concurrently, so the
bound on disposal is 5 seconds in total rather than per loop — a wedged dispatcher
@@ -285,11 +286,18 @@ Both seams consume the same producing services, so they share these cadences:
- alarm publisher emits on each transition observed by the central monitor;
- event publisher emits per event fanned by the session's `SessionEventDistributor`
to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`);
-- the alarms page's provider-status badge resubscribes one second after its
+- 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 holds its last value in between. The page's
- alarm rows are independent of that stream and refresh on the 3 s poll.
+ 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
+ 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
+ mid-truncation needs no priming logic of its own. The page's alarm rows are
+ independent of that stream and refresh on the 3 s poll, which also re-asserts the
+ truncation verdict as its reconcile baseline — both sources read the same monitor
+ verdict, and neither is synthesized page-side.
### Idle gating and snapshot cost
@@ -540,8 +548,11 @@ alarm-history store, so the page reflects only the live active set. The page is
read-only; it does not acknowledge alarms. A provider-status badge tracks the
central monitor's health from `IGatewayAlarmService.StreamAsync` in process — the
alarm service is already a multi-subscriber fan-out, so the badge needs no SignalR
-client, no loopback socket, and no hub token — while the alarm rows themselves
-still come from the three-second poll. If `MxGateway:Alarms:Enabled` is
+client, no loopback socket, and no hub token — and the same subscription carries the
+`snapshot_status` frame behind the truncated-snapshot banner ("Alarm snapshot may be
+incomplete"), so a capped provider fetch is caveated the moment the monitor decides
+it. The alarm rows themselves still come from the three-second poll, which also
+re-asserts the truncation verdict as the reconcile baseline. If `MxGateway:Alarms:Enabled` is
false the central monitor never starts, and the page says so instead of showing
an empty list with no explanation.
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 0cabe58..8c3ad85 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
@@ -191,13 +191,13 @@
private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
- private Task? _providerStatusTask;
+ private Task? _statusFeedTask;
///
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();
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
index ebb4e54..8a7e7fc 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
@@ -1,7 +1,11 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -36,6 +40,8 @@ public sealed class AlarmsPageTruncationBannerTests
{
private const string BannerMarker = "Alarm snapshot may be incomplete";
+ private static readonly TimeSpan RenderWaitTimeout = TimeSpan.FromSeconds(10);
+
/// A capped provider fetch puts the completeness caveat on the page.
/// A task that represents the asynchronous operation.
[Fact]
@@ -61,6 +67,120 @@ public sealed class AlarmsPageTruncationBannerTests
Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
}
+ ///
+ /// The push path. A snapshot_status frame arriving on the page's
+ /// in-process alarm-feed subscription raises the caveat on its own, with
+ /// no poll tick behind it — the poll is a 3-second reconcile baseline, and
+ /// an operator should not stare at an un-caveated alarm list for up to
+ /// three seconds after the gateway has already decided the set is capped.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AlarmsPage_WhenFeedPushesTruncated_RaisesTheBannerWithoutAPollTick()
+ {
+ ScriptedAlarmFeed feed = new();
+ await using ServiceProvider provider = BuildPushServices(feed);
+ await using HtmlRenderer renderer = new(
+ provider,
+ provider.GetRequiredService());
+
+ HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
+ () => renderer.RenderComponentAsync());
+
+ // The one poll answer this page will ever get said "complete", so everything
+ // the banner does from here is the feed's doing.
+ Assert.DoesNotContain(BannerMarker, await HtmlAsync(renderer, page), StringComparison.Ordinal);
+
+ await feed.PushAsync(SnapshotStatusFrame(truncated: true));
+
+ await WaitForHtmlAsync(
+ renderer,
+ page,
+ html => html.Contains(BannerMarker, StringComparison.Ordinal),
+ "banner to appear after a truncated snapshot_status frame");
+ }
+
+ ///
+ /// The clearing edge, page-side. Absence authority comes back when the
+ /// gateway says so; a banner that only ever went up would caveat the alarm
+ /// list for the life of the circuit.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AlarmsPage_WhenFeedPushesComplete_ClearsTheBannerWithoutAPollTick()
+ {
+ ScriptedAlarmFeed feed = new();
+ await using ServiceProvider provider = BuildPushServices(feed);
+ await using HtmlRenderer renderer = new(
+ provider,
+ provider.GetRequiredService());
+
+ HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
+ () => renderer.RenderComponentAsync());
+
+ await feed.PushAsync(SnapshotStatusFrame(truncated: true));
+ await WaitForHtmlAsync(
+ renderer,
+ page,
+ html => html.Contains(BannerMarker, StringComparison.Ordinal),
+ "banner to appear before the clearing frame is pushed");
+
+ await feed.PushAsync(SnapshotStatusFrame(truncated: false));
+
+ await WaitForHtmlAsync(
+ renderer,
+ page,
+ html => !html.Contains(BannerMarker, StringComparison.Ordinal),
+ "banner to clear after a complete snapshot_status frame");
+ }
+
+ private static AlarmFeedMessage SnapshotStatusFrame(bool truncated)
+ {
+ return new AlarmFeedMessage
+ {
+ SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated },
+ };
+ }
+
+ private static ServiceProvider BuildPushServices(ScriptedAlarmFeed feed)
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddSingleton();
+ services.AddSingleton(feed);
+ services.AddSingleton>(
+ Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
+ return services.BuildServiceProvider();
+ }
+
+ private static Task HtmlAsync(HtmlRenderer renderer, HtmlRootComponent page)
+ {
+ // Serialization has to happen on the renderer's dispatcher, and it reads the
+ // component's current render tree — so it reflects renders the page's feed
+ // loop queued after the initial quiescent render.
+ return renderer.Dispatcher.InvokeAsync(page.ToHtmlString);
+ }
+
+ private static async Task WaitForHtmlAsync(
+ HtmlRenderer renderer,
+ HtmlRootComponent page,
+ Func predicate,
+ string expectation)
+ {
+ Stopwatch elapsed = Stopwatch.StartNew();
+ while (elapsed.Elapsed < RenderWaitTimeout)
+ {
+ if (predicate(await HtmlAsync(renderer, page)))
+ {
+ return;
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(20));
+ }
+
+ Assert.Fail($"Timed out after {RenderWaitTimeout.TotalSeconds:N0}s waiting for the {expectation}.");
+ }
+
private static async Task RenderAsync(bool snapshotTruncated)
{
ServiceCollection services = new();
@@ -102,4 +222,93 @@ public sealed class AlarmsPageTruncationBannerTests
WorkerProcessId: null,
SnapshotTruncated: snapshotTruncated));
}
+
+ // Answers exactly one poll — the inline first pass — and parks every later tick
+ // until the page's disposal cancels it. The parking is what makes the push tests
+ // measure the push: a second tick would re-assert the poll's own verdict, and
+ // could either mask a banner the feed raised or raise one the feed did not.
+ private sealed class SinglePollLiveDataService : IDashboardLiveDataService
+ {
+ private int _polls;
+
+ ///
+ public Task ReadAsync(
+ IReadOnlyCollection tagAddresses,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardLiveReadResult.Empty);
+
+ ///
+ public async Task QueryAlarmsAsync(CancellationToken cancellationToken)
+ {
+ if (Interlocked.Increment(ref _polls) > 1)
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new DashboardAlarmQueryResult(
+ Alarms: [],
+ Error: null,
+ WorkerProcessId: null,
+ SnapshotTruncated: false);
+ }
+ }
+
+ // A hand-driven stand-in for the alarm feed: the test writes the frames the real
+ // monitor would push. Unbounded and never completed, so a frame written before the
+ // page's loop attaches is still delivered, and the loop never has to resubscribe.
+ private sealed class ScriptedAlarmFeed : IGatewayAlarmService
+ {
+ private readonly Channel _frames =
+ Channel.CreateUnbounded(new UnboundedChannelOptions
+ {
+ SingleReader = false,
+ SingleWriter = false,
+ });
+
+ ///
+ public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
+
+ ///
+ public string? LastError => null;
+
+ ///
+ public int? WorkerProcessId => null;
+
+ ///
+ public IReadOnlyList CurrentAlarms => [];
+
+ ///
+ public bool SnapshotTruncated { get; set; }
+
+ /// Pushes one frame onto the feed the page is subscribed to.
+ /// The feed frame to deliver.
+ /// A task that represents the asynchronous operation.
+ public ValueTask PushAsync(AlarmFeedMessage message) => _frames.Writer.WriteAsync(message);
+
+ ///
+ public async IAsyncEnumerable StreamAsync(
+ string? alarmFilterPrefix,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (AlarmFeedMessage message in _frames.Reader
+ .ReadAllAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return message;
+ }
+ }
+
+ ///
+ public Task AcknowledgeAsync(
+ AcknowledgeAlarmRequest request,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new AcknowledgeAlarmReply
+ {
+ CorrelationId = request.ClientCorrelationId,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ DiagnosticMessage = string.Empty,
+ });
+ }
+ }
}