Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs
T
Joseph Doherty 0f1f8ad8fa fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver
deleted in /raw but not yet deployed - still running, still publishing -
vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set,
olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is
older than 60 s. Tests cover unconfigured+fresh kept (positive control),
unconfigured+stale evicted, configured+stale kept, and the deploy hand-off.

M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds
default (Galaxy 30, OpcUaClient 15, ...) when the key is absent.
M2: the budget never undercuts the config's root timeoutMs (+1 s).
M5: RawBrowseModal compares the open generation captured before the await,
so a close-then-reopen mid-open closes the stale session.
M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver
node may still be starting) instead of stopping for good.
M7: /scripts source fetch failure shows in the dialog, not the error boundary.
M8: the export modal maps only its preview rows; the endpoint writes via
Results.Text (no second byte[] copy).
M9: /hosts config queries are ordered, so SequenceEqual change detection holds.
N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults,
N5 equipment-import help text now describes RFC 4180 quoting.
H3: MonacoEditor documents that themes are page-global.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 18:21:46 -04:00

97 lines
4.7 KiB
C#

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 has no historian adapter actor registered — an admin-only node, or a driver node
/// that has not finished starting. Re-polled slowly (<see cref="HistorianSinkStatusReader.NotRegisteredPollInterval"/>).</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 normal poll cadence.</summary>
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5);
/// <summary>The slow re-poll while the actor is not registered (admin-only node, or a driver node mid-startup).</summary>
public static readonly TimeSpan NotRegisteredPollInterval = TimeSpan.FromSeconds(30);
/// <summary>The poll interval after a reading with <paramref name="availability"/>.</summary>
/// <param name="availability">The last reading's availability, or null before the first.</param>
/// <returns>The interval until the next poll.</returns>
public static TimeSpan PollIntervalFor(HistorianSinkAvailability? availability) =>
availability == HistorianSinkAvailability.NotOnThisRole ? NotRegisteredPollInterval : PollInterval;
/// <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}");
}
}
}