From 0f1f8ad8fac99894fad8af3cd8178fbef7c07561 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 18:21:46 -0400 Subject: [PATCH] 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 --- .../Clients/HistorianSinkStatusReader.cs | 16 +++- .../Components/Pages/AlarmsHistorian.razor | 24 +++-- .../Components/Pages/Hosts.razor | 20 +++-- .../Components/Pages/Scripts.razor | 26 +++++- .../Drivers/DriverTestConnectButton.razor | 6 +- .../Shared/Drivers/TestConnectBudget.cs | 79 +++++++++++++---- .../Components/Shared/MonacoEditor.razor | 4 + .../Shared/Raw/RawBrowseModal.razor | 7 +- .../Shared/Raw/RawCsvExportModal.razor | 9 +- .../Shared/Uns/ImportEquipmentModal.razor | 2 +- .../Hubs/IDriverResilienceStatusStore.cs | 15 ++-- .../Hubs/IDriverStatusSnapshotStore.cs | 15 ++-- .../Hubs/IInProcessBroadcaster.cs | 5 +- .../InMemoryDriverResilienceStatusStore.cs | 11 ++- .../Hubs/InMemoryDriverStatusSnapshotStore.cs | 11 ++- .../Hubs/SafeMulticast.cs | 29 +++++++ .../Uns/RawTagCsvExport.cs | 22 +++-- .../AlarmsHistorianReaderTests.cs | 17 +++- .../Drivers/TestConnectBudgetTests.cs | 21 ++++- .../Hubs/StoreEvictionAndIsolationTests.cs | 87 ++++++++++++++----- .../Pickers/BrowseOpenGuardTests.cs | 2 +- .../Raw/RawBrowseRebirthRefreshTests.cs | 47 +++++++++- .../Raw/RawCsvRenderingTests.cs | 16 ++++ .../ScriptList/ScriptListQueriesTests.cs | 14 +++ .../Telemetry/TelemetryDialDeadlineTests.cs | 4 +- .../Telemetry/TelemetryDialSupervisorTests.cs | 4 +- 26 files changed, 411 insertions(+), 102 deletions(-) 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