Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor
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

113 lines
4.1 KiB
Plaintext

@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@inject AdminProbeService Probe
@implements IAsyncDisposable
<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="@(_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>
@if (_result is not null)
{
@if (_result.Ok)
{
<span class="chip chip-ok" title="@($"Probe succeeded in {_result.LatencyMs:F0} ms")">
OK &middot; @_result.LatencyMs?.ToString("F0") ms
</span>
}
else
{
<span class="chip chip-bad" title="@_result.Message">
Failed &middot; @DisplayText.Abbreviate(_result.Message, 60)
</span>
}
}
</div>
@code {
/// <summary>Driver type key — must match an installed <c>IDriverProbe.DriverType</c>.</summary>
[Parameter, EditorRequired] public string DriverType { get; set; } = "";
/// <summary>Callback that returns the current form config as JSON. Called at click time.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>
/// Fallback probe budget, used only when the config JSON carries no positive
/// <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;
private const int ClearAfterMs = 30_000;
private bool _busy;
private int? _lastBudgetSeconds;
private TestDriverConnectResult? _result;
// System.Threading.Timer (it replaced an Elapsed-event timer with an async void lambda): DisposeAsync awaits an
// in-flight callback, and the callback body never throws onto the thread pool (R04 L2). The generation
// makes a superseded timer's late fire a no-op, so it cannot clear a newer result.
private System.Threading.Timer? _clearTimer;
private int _resultGeneration;
private volatile bool _disposed;
private async Task OnClickAsync()
{
_busy = true;
_result = null;
StateHasChanged();
try
{
var json = GetConfigJson() ?? "{}";
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType);
_lastBudgetSeconds = budget;
_result = await Probe.TestAsync(DriverType, json, budget);
}
catch (Exception ex)
{
_result = new TestDriverConnectResult(false, ex.Message, null, Guid.Empty);
}
finally
{
_busy = false;
ScheduleClear();
if (!_disposed) StateHasChanged();
}
}
private void ScheduleClear()
{
if (_disposed) return;
var generation = ++_resultGeneration;
_clearTimer?.Dispose();
_clearTimer = new System.Threading.Timer(_ => _ = ClearAsync(generation), null, ClearAfterMs, Timeout.Infinite);
}
private async Task ClearAsync(int generation)
{
try
{
await InvokeAsync(() =>
{
if (_disposed || generation != _resultGeneration) return;
_result = null;
StateHasChanged();
});
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
{
// The circuit went away between the fire and the render — nothing to clear.
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_disposed = true;
if (_clearTimer is not null) await _clearTimer.DisposeAsync();
}
}