diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs new file mode 100644 index 00000000..9e97cea1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs @@ -0,0 +1,84 @@ +using Akka.Actor; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients; + +/// What one /alarms-historian poll learned. +public enum HistorianSinkAvailability +{ + /// The sink answered; is current. + Available, + + /// This node runs no historian adapter actor (an admin-only node, without the driver role). + /// Not transient: polling again cannot change it. + NotOnThisRole, + + /// The actor exists but did not answer (timeout or failure); the last good status, if any, is stale. + Unavailable, +} + +/// The outcome of one status poll. +/// What the poll learned. +/// The sink status when ; otherwise null. +/// Why the status is missing, for the page; null when available. +public sealed record HistorianSinkReading(HistorianSinkAvailability Availability, HistorianSinkStatus? Status, string? Message); + +/// +/// Reads the local historian sink status for /alarms-historian (R04 L3). The page used to wrap the +/// Ask in a bare catch {}, so every failure — including "this role has no historian actor" — left +/// it on "Loading…" forever. This classifies the failure so the page can say which it is. +/// +public static class HistorianSinkStatusReader +{ + /// The per-poll Ask timeout. + public static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(2); + + /// Asks the historian adapter actor for its status. + /// Resolves the actor; throws when this node registers none. + /// The Ask timeout. + /// Cancellation (the page's lifetime); a cancellation propagates. + /// The classified reading. + public static async Task ReadAsync( + Func resolveActor, TimeSpan timeout, CancellationToken ct) + { + IActorRef actor; + try + { + actor = resolveActor(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Akka.Hosting throws when the registry has no entry for the key: the actor is spawned only on + // driver-role nodes, so an admin-only node never has one. + return new HistorianSinkReading(HistorianSinkAvailability.NotOnThisRole, null, + "This node does not run the alarm historian sink (it has no driver role). Open this page on a driver node."); + } + + if (actor.IsNobody()) + { + return new HistorianSinkReading(HistorianSinkAvailability.NotOnThisRole, null, + "This node does not run the alarm historian sink (it has no driver role). Open this page on a driver node."); + } + + try + { + var status = await actor.Ask(HistorianAdapterActor.GetStatus.Instance, timeout, ct); + return new HistorianSinkReading(HistorianSinkAvailability.Available, status, null); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (AskTimeoutException) + { + return new HistorianSinkReading(HistorianSinkAvailability.Unavailable, null, + $"The historian sink did not answer within {timeout.TotalSeconds:0} s."); + } + catch (Exception ex) + { + return new HistorianSinkReading(HistorianSinkAvailability.Unavailable, null, + $"The historian sink status could not be read: {ex.Message}"); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor index f1fcf87e..453fc140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -5,11 +5,12 @@ @rendermode RenderMode.InteractiveServer @using Akka.Actor @using Akka.Hosting +@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 @inject IRequiredActor HistorianActor -@implements IDisposable +@implements IAsyncDisposable
@@ -18,12 +19,26 @@
Polling every @PollSeconds s.
-@if (_status is null) +@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole }) +{ +
@_reading.Message
+} +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 {"); + } +}