fix(adminui): /alarms-historian says when the role runs no historian; Fleet-style lifecycle (R04 L3)

A bare catch {} turned every failure - including an admin-only node with no
historian actor - into 'Loading...' forever, and a tick queued after a sync
timer dispose still issued an Ask. HistorianSinkStatusReader classifies a poll
as Available / NotOnThisRole (permanent: shown once, polling stops) /
Unavailable (shown with the last good status and its read time). The page
follows Fleet.razor: IAsyncDisposable, _disposed, overlap guard, page CTS.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:47:30 -04:00
parent 4b594ec525
commit 1313b94097
3 changed files with 220 additions and 10 deletions
@@ -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;
/// <summary>What one <c>/alarms-historian</c> poll learned.</summary>
public enum HistorianSinkAvailability
{
/// <summary>The sink answered; <see cref="HistorianSinkReading.Status"/> is current.</summary>
Available,
/// <summary>This node runs no historian adapter actor (an admin-only node, without the driver role).
/// Not transient: polling again cannot change it.</summary>
NotOnThisRole,
/// <summary>The actor exists but did not answer (timeout or failure); the last good status, if any, is stale.</summary>
Unavailable,
}
/// <summary>The outcome of one status poll.</summary>
/// <param name="Availability">What the poll learned.</param>
/// <param name="Status">The sink status when <see cref="HistorianSinkAvailability.Available"/>; otherwise null.</param>
/// <param name="Message">Why the status is missing, for the page; null when available.</param>
public sealed record HistorianSinkReading(HistorianSinkAvailability Availability, HistorianSinkStatus? Status, string? Message);
/// <summary>
/// Reads the local historian sink status for <c>/alarms-historian</c> (R04 L3). The page used to wrap the
/// Ask in a bare <c>catch {}</c>, 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.
/// </summary>
public static class HistorianSinkStatusReader
{
/// <summary>The per-poll Ask timeout.</summary>
public static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(2);
/// <summary>Asks the historian adapter actor for its status.</summary>
/// <param name="resolveActor">Resolves the actor; throws when this node registers none.</param>
/// <param name="timeout">The Ask timeout.</param>
/// <param name="ct">Cancellation (the page's lifetime); a cancellation propagates.</param>
/// <returns>The classified reading.</returns>
public static async Task<HistorianSinkReading> ReadAsync(
Func<IActorRef> 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<HistorianSinkStatus>(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}");
}
}
}
@@ -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<HistorianAdapterActorKey> HistorianActor
@implements IDisposable
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -18,12 +19,26 @@
<div class="text-muted small mb-3">Polling every @PollSeconds s.</div>
@if (_status is null)
@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole })
{
<section class="panel notice rise mt-3" style="animation-delay:.04s">@_reading.Message</section>
}
else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable })
{
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">@_reading.Message Retrying every @PollSeconds s.</section>
}
else if (_status is null)
{
<p class="mt-3">Loading…</p>
}
else
{
@if (_reading is { Availability: HistorianSinkAvailability.Unavailable })
{
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">
@_reading.Message Showing the last status read at @_statusReadUtc?.ToString("u").
</section>
}
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card">
<div class="panel-head">Queue</div>
@@ -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<HistorianSinkStatus>(
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();
}
}
@@ -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;
/// <summary>
/// R04 L3: <c>/alarms-historian</c> 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.
/// </summary>
[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<HistorianAdapterActor.GetStatus>(_ => 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<HistorianAdapterActorKey>(), 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<HistorianAdapterActor.GetStatus>();
}
[Fact]
public async Task Page_teardown_cancellation_propagates()
{
var silent = CreateTestProbe();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(() =>
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 {");
}
}