fix(adminui): Test Connect honours the configured probe timeout; drain its clear timer (R04 L2, G1-F5, F-FU2)
DriverTestConnectButton always sent a fixed 10 s, and the Galaxy/OpcUaClient probes run under min(caller, configured), so ProbeTimeoutSeconds - documented on every driver as the Test Connect timeout - could shorten a probe but never lengthen it (Galaxy's 30 s default was silently 10 s). TestConnectBudget reads the root ProbeTimeoutSeconds from the merged config at click time, clamped to the actor's [1, 60] s, falling back to TimeoutSeconds. DeviceModal (the only host) needs no change. The Galaxy/OpcUaClient form hints are reworded. The 30 s result-clear timer moves from System.Timers.Timer + an async void Elapsed lambda to System.Threading.Timer, drained in DisposeAsync, with a generation so a superseded fire cannot clear a newer result. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+49
-19
@@ -1,10 +1,11 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
|
||||
@inject AdminProbeService Probe
|
||||
@implements IDisposable
|
||||
@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">
|
||||
<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")">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Test Connect
|
||||
</button>
|
||||
@@ -20,7 +21,7 @@
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad" title="@_result.Message">
|
||||
Failed · @TruncatedMessage()
|
||||
Failed · @DisplayText.Abbreviate(_result.Message, 60)
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@@ -33,12 +34,25 @@
|
||||
/// <summary>Callback that returns the current form config as JSON. Called at click time.</summary>
|
||||
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||
|
||||
/// <summary>Per-probe timeout forwarded to the actor (actor clamps to [1, 60] s). Default 10 s.</summary>
|
||||
[Parameter] public int TimeoutSeconds { get; set; } = 10;
|
||||
/// <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
|
||||
/// 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;
|
||||
private System.Timers.Timer? _clearTimer;
|
||||
|
||||
// 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()
|
||||
{
|
||||
@@ -48,7 +62,9 @@
|
||||
try
|
||||
{
|
||||
var json = GetConfigJson() ?? "{}";
|
||||
_result = await Probe.TestAsync(DriverType, json, TimeoutSeconds);
|
||||
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds);
|
||||
_lastBudgetSeconds = budget;
|
||||
_result = await Probe.TestAsync(DriverType, json, budget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -58,25 +74,39 @@
|
||||
{
|
||||
_busy = false;
|
||||
ScheduleClear();
|
||||
StateHasChanged();
|
||||
if (!_disposed) StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleClear()
|
||||
{
|
||||
if (_disposed) return;
|
||||
var generation = ++_resultGeneration;
|
||||
_clearTimer?.Dispose();
|
||||
_clearTimer = new System.Timers.Timer(30_000) { AutoReset = false };
|
||||
_clearTimer.Elapsed += async (_, _) =>
|
||||
{
|
||||
_result = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
};
|
||||
_clearTimer.Start();
|
||||
_clearTimer = new System.Threading.Timer(_ => _ = ClearAsync(generation), null, ClearAfterMs, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private string TruncatedMessage()
|
||||
=> _result?.Message is null ? "" :
|
||||
(_result.Message.Length > 60 ? _result.Message[..60] + "…" : _result.Message);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _clearTimer?.Dispose();
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
if (_clearTimer is not null) await _clearTimer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user