@@ -18,12 +19,26 @@
Polling every @PollSeconds s.
-@if (_status is null)
+@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole })
+{
+
+}
+else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable })
+{
+
@_reading.Message Retrying every @PollSeconds s.
+}
+else if (_status is null)
{
Loading…
}
else
{
+ @if (_reading is { Availability: HistorianSinkAvailability.Unavailable })
+ {
+
+ @_reading.Message Showing the last status read at @_statusReadUtc?.ToString("u").
+
+ }
Queue
@@ -49,30 +64,48 @@ else
@code {
private const int PollSeconds = 5;
+ // The Fleet.razor lifecycle (R04 L3): IAsyncDisposable, a _disposed flag, an overlap guard and a
+ // page-lifetime CTS, so a queued tick after teardown neither Asks nor renders.
private HistorianSinkStatus? _status;
+ private DateTime? _statusReadUtc;
+ private HistorianSinkReading? _reading;
private Timer? _timer;
+ private int _tickRunning;
+ private readonly CancellationTokenSource _cts = new();
+ private volatile bool _disposed;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
+ // No historian on this role is permanent — don't poll for it.
+ if (_reading?.Availability == HistorianSinkAvailability.NotOnThisRole) return;
_timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null,
TimeSpan.FromSeconds(PollSeconds), TimeSpan.FromSeconds(PollSeconds));
}
private async Task RefreshAsync()
{
+ if (_disposed || Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
try
{
- _status = await HistorianActor.ActorRef.Ask
(
- HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2));
- StateHasChanged();
+ _reading = await HistorianSinkStatusReader.ReadAsync(
+ () => HistorianActor.ActorRef, HistorianSinkStatusReader.AskTimeout, _cts.Token);
+ if (_reading.Status is not null)
+ {
+ _status = _reading.Status;
+ _statusReadUtc = DateTime.UtcNow;
+ }
}
- catch
+ catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
- // Actor unavailable (admin-only node, not driver-role) — leave _status null and let
- // the page show "Loading…". A dedicated "this role doesn't run a historian" message
- // would be nicer; lands when we add role gating to the UI.
+ return; // the page went away mid-Ask
}
+ finally
+ {
+ Volatile.Write(ref _tickRunning, 0);
+ }
+
+ if (!_disposed) StateHasChanged();
}
private static string StateChipClass(HistorianDrainState state) => state switch
@@ -84,5 +117,13 @@ else
_ => "chip chip-idle",
};
- public void Dispose() => _timer?.Dispose();
+ public async ValueTask DisposeAsync()
+ {
+ _disposed = true;
+ _cts.Cancel();
+ // Drains an in-flight callback (System.Threading.Timer async dispose, .NET 6+). The callback only
+ // schedules the tick, so a tick may still hold _cts — skip the dispose then (see Hosts.razor).
+ if (_timer is not null) await _timer.DisposeAsync();
+ if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AlarmsHistorianReaderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AlarmsHistorianReaderTests.cs
new file mode 100644
index 00000000..c31c07cc
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AlarmsHistorianReaderTests.cs
@@ -0,0 +1,85 @@
+using Akka.Actor;
+using Akka.Hosting;
+using Akka.TestKit.Xunit2;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
+using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Runtime;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
+
+///
+/// R04 L3: /alarms-historian distinguishes "this role runs no historian" (a permanent state, shown
+/// once, not polled) from a sink that did not answer, instead of swallowing both into "Loading…" forever.
+///
+[Trait("Category", "Unit")]
+public sealed class AlarmsHistorianReaderTests : TestKit
+{
+ private static readonly HistorianSinkStatus Status = new(3, 1, null, null, null, HistorianDrainState.Idle);
+
+ private sealed class StatusActor : ReceiveActor
+ {
+ public StatusActor() => Receive(_ => Sender.Tell(Status));
+ }
+
+ [Fact]
+ public async Task An_answering_sink_is_available()
+ {
+ var actor = Sys.ActorOf(Props.Create(() => new StatusActor()));
+
+ var reading = await HistorianSinkStatusReader.ReadAsync(() => actor, TimeSpan.FromSeconds(5), CancellationToken.None);
+
+ reading.Availability.ShouldBe(HistorianSinkAvailability.Available);
+ reading.Status.ShouldBe(Status);
+ }
+
+ [Fact]
+ public async Task A_node_without_the_registry_entry_reports_not_on_this_role()
+ {
+ // The real Akka.Hosting registry: an admin-only node never registers the historian key.
+ var registry = new ActorRegistry();
+
+ var reading = await HistorianSinkStatusReader.ReadAsync(
+ () => registry.Get(), TimeSpan.FromSeconds(5), CancellationToken.None);
+
+ reading.Availability.ShouldBe(HistorianSinkAvailability.NotOnThisRole);
+ reading.Status.ShouldBeNull();
+ reading.Message!.ShouldContain("driver role");
+ }
+
+ [Fact]
+ public async Task A_silent_sink_is_unavailable_not_loading()
+ {
+ var silent = CreateTestProbe(); // receives the Ask, never replies
+
+ var reading = await HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromMilliseconds(200), CancellationToken.None);
+
+ reading.Availability.ShouldBe(HistorianSinkAvailability.Unavailable);
+ reading.Message!.ShouldContain("did not answer");
+ // Positive control: the Ask really was delivered — the classification is about the missing reply.
+ silent.ExpectMsg();
+ }
+
+ [Fact]
+ public async Task Page_teardown_cancellation_propagates()
+ {
+ var silent = CreateTestProbe();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Should.ThrowAsync(() =>
+ HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromSeconds(30), cts.Token));
+ }
+
+ [Fact]
+ public void Page_uses_the_reader_and_the_async_dispose_lifecycle()
+ {
+ var src = AdminUiSource.Read("Components", "Pages", "AlarmsHistorian.razor");
+ src.ShouldContain("HistorianSinkStatusReader.ReadAsync(");
+ src.ShouldContain("@implements IAsyncDisposable");
+ src.ShouldContain("HistorianSinkAvailability.NotOnThisRole");
+ src.ShouldNotContain("catch\n {");
+ }
+}