diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs index 9e97cea1..eb741d83 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs @@ -10,8 +10,8 @@ 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. + /// This node has no historian adapter actor registered — an admin-only node, or a driver node + /// that has not finished starting. Re-polled slowly (). NotOnThisRole, /// The actor exists but did not answer (timeout or failure); the last good status, if any, is stale. @@ -31,6 +31,18 @@ public sealed record HistorianSinkReading(HistorianSinkAvailability Availability /// public static class HistorianSinkStatusReader { + /// The normal poll cadence. + public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + /// The slow re-poll while the actor is not registered (admin-only node, or a driver node mid-startup). + public static readonly TimeSpan NotRegisteredPollInterval = TimeSpan.FromSeconds(30); + + /// The poll interval after a reading with . + /// The last reading's availability, or null before the first. + /// The interval until the next poll. + public static TimeSpan PollIntervalFor(HistorianSinkAvailability? availability) => + availability == HistorianSinkAvailability.NotOnThisRole ? NotRegisteredPollInterval : PollInterval; + /// The per-poll Ask timeout. public static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(2); 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 453fc140..35bcdd04 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 @@ -17,11 +17,11 @@

Alarms historian sink

-
Polling every @PollSeconds s.
+
Polling every @((int)(_interval == TimeSpan.Zero ? PollSeconds : _interval.TotalSeconds)) s.
@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole }) { -
@_reading.Message
+
@_reading.Message Checking again every @((int)HistorianSinkStatusReader.NotRegisteredPollInterval.TotalSeconds) s in case the node is still starting.
} else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable }) { @@ -71,16 +71,17 @@ else private HistorianSinkReading? _reading; private Timer? _timer; private int _tickRunning; + private TimeSpan _interval; 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)); + // "Not registered" is usually permanent (an admin-only node) but can also be a driver node still + // starting up, so it keeps a slow re-poll instead of stopping for good (review r0 M6). + _interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability); + _timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, _interval, _interval); } private async Task RefreshAsync() @@ -105,7 +106,16 @@ else Volatile.Write(ref _tickRunning, 0); } - if (!_disposed) StateHasChanged(); + if (_disposed) return; + var interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability); + if (_timer is not null && interval != _interval) + { + _interval = interval; + try { _timer.Change(interval, interval); } + catch (ObjectDisposedException) { return; } + } + + StateHasChanged(); } private static string StateChipClass(HistorianDrainState state) => state switch diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 67c9be56..c2238207 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -329,6 +329,9 @@ else @code { private const int RefreshIntervalSeconds = 5; + // An unconfigured driver's snapshot must be this old before eviction (≈ 12 health publishes). + private static readonly TimeSpan EvictUnconfiguredAfter = TimeSpan.FromSeconds(60); + private List? _rows; private bool _refreshing; private DateTime? _lastRefreshUtc; @@ -434,22 +437,29 @@ else try { await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); + // Ordered: the change test below compares with SequenceEqual (review r0 M9). _nodes = await db.ClusterNodes.AsNoTracking() + .OrderBy(n => n.ClusterId).ThenBy(n => n.NodeId) .Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort)) .ToListAsync(_cts.Token); _instances = await db.DriverInstances.AsNoTracking() + .OrderBy(d => d.DriverInstanceId) .Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled)) .ToListAsync(_cts.Token); // Evict snapshots of driver instances that are no longer configured (deleted, or re-created - // under a new id). The stores have no other removal path, so without this a removed driver - // stays on this page as a frozen row until the admin node restarts. Only a SUCCESSFUL load - // reaches here; the failure path below blanks _instances and must never sweep the stores. + // under a new id) AND have stopped publishing. The stores have no other removal path, so + // without this a removed driver stays on this page as a frozen row until the admin node + // restarts. DriverInstances is the AUTHORED table: a driver deleted in /raw keeps running (and + // publishing every few seconds) until the next deploy, so staleness is what proves it stopped. + // Only a SUCCESSFUL load reaches here; the failure path below blanks _instances and must + // never sweep the stores. var configured = _instances .Select(i => i.DriverInstanceId) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var evicted = DriverStore.RetainOnly(configured); - ResilienceStore.RetainOnly(configured); + var staleBefore = DateTime.UtcNow - EvictUnconfiguredAfter; + var evicted = DriverStore.EvictUnconfiguredStale(configured, staleBefore); + ResilienceStore.EvictUnconfiguredStale(configured, staleBefore); return evicted > 0 || !_nodes.SequenceEqual(previousNodes) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor index 219b5eaa..38279f86 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -89,7 +89,11 @@ else