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:
@@ -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 {");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user