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
This commit is contained in:
Joseph Doherty
2026-10-22 18:21:46 -04:00
parent 01f7e3d62e
commit 0f1f8ad8fa
26 changed files with 411 additions and 102 deletions
@@ -17,11 +17,11 @@
<h4 class="mb-0">Alarms historian sink</h4>
</div>
<div class="text-muted small mb-3">Polling every @PollSeconds s.</div>
<div class="text-muted small mb-3">Polling every @((int)(_interval == TimeSpan.Zero ? PollSeconds : _interval.TotalSeconds)) s.</div>
@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole })
{
<section class="panel notice rise mt-3" style="animation-delay:.04s">@_reading.Message</section>
<section class="panel notice rise mt-3" style="animation-delay:.04s">@_reading.Message Checking again every @((int)HistorianSinkStatusReader.NotRegisteredPollInterval.TotalSeconds) s in case the node is still starting.</section>
}
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
@@ -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<MemberRow>? _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)
@@ -89,7 +89,11 @@ else
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseSource"></button>
</div>
<div class="modal-body">
@if (src is null || (_sourceLoaded && _sourceCode is null))
@if (_sourceError is not null)
{
<div class="panel notice notice-alert mb-0">@_sourceError</div>
}
else if (src is null || (_sourceLoaded && _sourceCode is null))
{
<p class="text-muted mb-0">This script is no longer loaded.</p>
}
@@ -123,6 +127,7 @@ else
// operator opened another row, or closed the dialog, while the first one was in flight).
private string? _sourceCode;
private bool _sourceLoaded;
private string? _sourceError;
private int _sourceGeneration;
protected override async Task OnInitializedAsync()
@@ -147,8 +152,22 @@ else
_sourceCode = null;
_sourceLoaded = false;
await using var db = await DbFactory.CreateDbContextAsync();
var source = await ScriptListQueries.LoadSourceAsync(db, scriptId);
_sourceError = null;
string? source;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
source = await ScriptListQueries.LoadSourceAsync(db, scriptId);
}
catch (Exception ex)
{
// A ConfigDb hiccup must not escape the click handler into the page's error boundary (review r0 M7).
if (generation != _sourceGeneration) return;
_sourceError = $"The script source could not be loaded: {ex.Message}";
_sourceLoaded = true;
return;
}
if (generation != _sourceGeneration) return;
_sourceCode = source;
_sourceLoaded = true;
@@ -160,5 +179,6 @@ else
_sourceScriptId = null;
_sourceCode = null;
_sourceLoaded = false;
_sourceError = null;
}
}
@@ -5,7 +5,7 @@
<div class="d-inline-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync"
title="@($"Probe budget: {_lastBudgetSeconds?.ToString() ?? "the driver's configured probe timeout"} s")">
title="@(_lastBudgetSeconds is { } b ? $"Probe budget: {b} s" : "Probe budget: the driver's configured probe timeout")">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Test Connect
</button>
@@ -36,7 +36,7 @@
/// <summary>
/// Fallback probe budget, used only when the config JSON carries no positive
/// <c>ProbeTimeoutSeconds</c>. The configured value wins (it is every driver's documented "Test Connect
/// <c>ProbeTimeoutSeconds</c> and the driver has no documented default (<c>TestConnectBudget.DriverDefaults</c>). The configured value wins (it is every driver's documented "Test Connect
/// probe timeout"), so it can lengthen the budget as well as shorten it. Clamped to [1, 60] s.
/// </summary>
[Parameter] public int TimeoutSeconds { get; set; } = TestConnectBudget.DefaultSeconds;
@@ -62,7 +62,7 @@
try
{
var json = GetConfigJson() ?? "{}";
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds);
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType);
_lastBudgetSeconds = budget;
_result = await Probe.TestAsync(DriverType, json, budget);
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
@@ -24,34 +25,80 @@ public static class TestConnectBudget
/// <summary>The config property that carries the budget (matched case-insensitively at the JSON root).</summary>
public const string PropertyName = "ProbeTimeoutSeconds";
/// <summary>Returns the configured budget, or <paramref name="fallbackSeconds"/> when there is none.</summary>
/// <summary>The root per-request timeout some probes (Modbus) use as their own deadline, in ms.</summary>
public const string RequestTimeoutPropertyName = "timeoutMs";
/// <summary>
/// Each driver's documented <c>ProbeTimeoutSeconds</c> default (its options type's initializer), used
/// when the config carries no value — legacy rows and raw-JSON/seeded configs omit the key, and the
/// form hints promise these defaults (review r0 M1). Drivers not listed fall back to the caller's value.
/// </summary>
public static readonly IReadOnlyDictionary<string, int> DriverDefaults =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Galaxy] = 30,
[DriverTypeNames.OpcUaClient] = 15,
[DriverTypeNames.TwinCAT] = 10,
[DriverTypeNames.FOCAS] = 10,
[DriverTypeNames.Modbus] = 5,
[DriverTypeNames.AbCip] = 5,
[DriverTypeNames.AbLegacy] = 5,
[DriverTypeNames.S7] = 5,
};
/// <summary>Returns the configured budget, else the driver's documented default, else <paramref name="fallbackSeconds"/>.</summary>
/// <param name="configJson">The (merged) config JSON the probe will run against.</param>
/// <param name="fallbackSeconds">The budget when the config has no positive <c>ProbeTimeoutSeconds</c>.</param>
/// <param name="fallbackSeconds">The budget when neither the config nor <see cref="DriverDefaults"/> supplies one.</param>
/// <param name="driverType">The driver type, selecting its documented default; null skips that step.</param>
/// <returns>The budget in seconds, within [<see cref="MinSeconds"/>, <see cref="MaxSeconds"/>].</returns>
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds)
/// <remarks>
/// A probe that runs under the config's own <c>timeoutMs</c> (Modbus, factory parity) is hard-cancelled
/// at this budget by the probe actor, so the budget is never set below <c>ceil(timeoutMs / 1000) + 1</c>
/// (review r0 M2): a slow gateway that the deployed driver reaches within its 8 s request timeout must
/// not fail Test Connect at a 5 s budget.
/// </remarks>
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds, string? driverType = null)
{
var configured = TryReadConfigured(configJson);
var seconds = configured is > 0 ? configured.Value : fallbackSeconds;
var (configured, requestTimeoutMs) = TryRead(configJson);
var seconds = configured is > 0
? configured.Value
: driverType is not null && DriverDefaults.TryGetValue(driverType, out var d) ? d : fallbackSeconds;
if (requestTimeoutMs is > 0)
{
var floor = (int)Math.Min(Math.Ceiling(requestTimeoutMs.Value / 1000.0) + 1, int.MaxValue);
seconds = Math.Max(seconds, floor);
}
return Math.Clamp(seconds, MinSeconds, MaxSeconds);
}
private static int? TryReadConfigured(string? configJson)
private static (int? ProbeTimeoutSeconds, double? RequestTimeoutMs) TryRead(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return null;
if (string.IsNullOrWhiteSpace(configJson)) return (null, null);
int? probe = null;
double? request = null;
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (null, null);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase)) continue;
return prop.Value.ValueKind switch
if (string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase))
{
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => (int)Math.Min(Math.Ceiling(d), int.MaxValue),
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
_ => null,
};
probe = prop.Value.ValueKind switch
{
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
JsonValueKind.Number when prop.Value.TryGetDouble(out var dd) => (int)Math.Min(Math.Ceiling(dd), int.MaxValue),
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
_ => null,
};
}
else if (string.Equals(prop.Name, RequestTimeoutPropertyName, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetDouble(out var ms))
{
request = ms;
}
}
}
catch (JsonException)
@@ -59,6 +106,6 @@ public static class TestConnectBudget
// An unparseable config is the probe's problem to report; the budget just falls back.
}
return null;
return (probe, request);
}
}
@@ -225,6 +225,10 @@
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
// R04 H3 (accepted, cosmetic): Monaco themes are page-global — the JS side calls
// monaco.editor.setTheme, which re-themes EVERY editor on the page (e.g. a VirtualTagModal open over
// ScriptEdit), not just this one, and the other instance's _dark flag then no longer matches what it shows.
// Monaco has no per-editor theme; a fix would need a page-level theme service shared by all instances.
private async Task ToggleTheme()
{
_dark = !_dark;
@@ -457,12 +457,15 @@
{
_opening = true;
_openError = null;
// Captured BEFORE the await: a close-then-reopen while this open is in flight leaves Visible true
// again, so only the generation tells this open's result apart from the new modal's (review r0 M5).
var generation = _openGeneration;
StateHasChanged();
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
// Host disposed or modal closed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || !Visible, BrowserService)) { return; }
// Host disposed, modal closed, or modal re-opened mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)) { return; }
if (result.Ok)
{
_token = result.Token;
@@ -116,8 +116,10 @@
try
{
_groupFilter = null;
// The same builder the download endpoint runs, so the preview matches the file.
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType);
// The same builder the download endpoint runs, so the preview matches the file — but mapping only
// the preview's rows (the header takes the first line), never the whole export (review r0 M8).
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType,
maxRows: RawTagCsvExport.PreviewLines - 1);
if (IsStale(generation)) { return; }
if (export is null)
{
@@ -129,7 +131,8 @@
_rowCount = export.RowCount;
_fileName = export.FileName;
// Only the bounded preview is kept on the component; the full CSV is not held by the circuit.
(_preview, _truncated) = RawTagCsvExport.Preview(export.Csv);
(_preview, var cut) = RawTagCsvExport.Preview(export.Csv);
_truncated = cut || export.RowCount > RawTagCsvExport.PreviewLines - 1;
_downloadUrl = RawTagCsvExport.DownloadUrl(DeviceId, TagGroupId, DriverType);
}
catch (Exception ex)
@@ -31,7 +31,7 @@
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
<div class="form-text">RFC 4180 CSV: wrap a field containing commas, quotes or line breaks in double quotes (a quote inside is doubled: <span class="mono">""</span>). The opening quote must come straight after the comma — <span class="mono">a, "b"</span> is rejected.</div>
@if (!string.IsNullOrWhiteSpace(_parseError))
{