diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor index 1219b527..d4db4d28 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor @@ -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
- @@ -20,7 +21,7 @@ else { - Failed · @TruncatedMessage() + Failed · @DisplayText.Abbreviate(_result.Message, 60) } } @@ -33,12 +34,25 @@ /// Callback that returns the current form config as JSON. Called at click time. [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; - /// Per-probe timeout forwarded to the actor (actor clamps to [1, 60] s). Default 10 s. - [Parameter] public int TimeoutSeconds { get; set; } = 10; + /// + /// Fallback probe budget, used only when the config JSON carries no positive + /// ProbeTimeoutSeconds. 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. + /// + [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(); + /// + public async ValueTask DisposeAsync() + { + _disposed = true; + if (_clearTimer is not null) await _clearTimer.DisposeAsync(); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor index 705f70c7..b27d5dcc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor @@ -138,7 +138,7 @@
-
Caps the Test Connect probe deadline (the probe's own budget still applies, so this only shortens it). Max 60. Default 30.
+
The Test Connect probe budget (1–60 s). Default 30.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index 95506d0e..2b0f6809 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -219,7 +219,7 @@
-
Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.
+
The Test Connect probe budget (1–60 s). Default 15.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs new file mode 100644 index 00000000..b6c00635 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +/// +/// Resolves the Test Connect probe budget (G1-F5 / F-FU2). Every driver's options document +/// ProbeTimeoutSeconds as "Timeout for the AdminUI Test Connect probe", but the button always sent +/// a fixed 10 s, and the Galaxy and OpcUaClient probes take min(caller budget, configured) — so the +/// knob could shorten a probe but never lengthen it (Galaxy's default of 30 s was silently 10 s). The +/// button now sends the configured value, read from the root of the config JSON it is about to probe, +/// clamped to the probe actor's own [1, 60] s range. +/// +public static class TestConnectBudget +{ + /// The budget used when the config carries no (valid) ProbeTimeoutSeconds. + public const int DefaultSeconds = 10; + + /// The probe actor's lower clamp. + public const int MinSeconds = 1; + + /// The probe actor's upper clamp. + public const int MaxSeconds = 60; + + /// The config property that carries the budget (matched case-insensitively at the JSON root). + public const string PropertyName = "ProbeTimeoutSeconds"; + + /// Returns the configured budget, or when there is none. + /// The (merged) config JSON the probe will run against. + /// The budget when the config has no positive ProbeTimeoutSeconds. + /// The budget in seconds, within [, ]. + public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds) + { + var configured = TryReadConfigured(configJson); + var seconds = configured is > 0 ? configured.Value : fallbackSeconds; + return Math.Clamp(seconds, MinSeconds, MaxSeconds); + } + + private static int? TryReadConfigured(string? configJson) + { + if (string.IsNullOrWhiteSpace(configJson)) return null; + try + { + using var doc = JsonDocument.Parse(configJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (!string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase)) continue; + return prop.Value.ValueKind switch + { + 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, + }; + } + } + catch (JsonException) + { + // An unparseable config is the probe's problem to report; the budget just falls back. + } + + return null; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs new file mode 100644 index 00000000..e0f6a271 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs @@ -0,0 +1,55 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers; + +/// +/// G1-F5 / F-FU2: Test Connect honours the driver's configured ProbeTimeoutSeconds (so it can +/// lengthen the budget, not only shorten it), aligned across Galaxy and OpcUaClient, and the button +/// drains its clear timer (R04 L2). +/// +[Trait("Category", "Unit")] +public sealed class TestConnectBudgetTests +{ + [Theory] + [InlineData("{\"ProbeTimeoutSeconds\":30}", 30)] // Galaxy default — was silently 10 s + [InlineData("{\"probeTimeoutSeconds\":15}", 15)] // OpcUaClient default, camelCase + [InlineData("{\"ProbeTimeoutSeconds\":\"25\"}", 25)] + [InlineData("{\"ProbeTimeoutSeconds\":2.5}", 3)] + [InlineData("{\"ProbeTimeoutSeconds\":600}", 60)] // clamped to the actor's max + public void A_configured_timeout_is_the_budget(string json, int expected) + => TestConnectBudget.Resolve(json).ShouldBe(expected); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("{}")] + [InlineData("{\"ProbeTimeoutSeconds\":0}")] + [InlineData("{\"ProbeTimeoutSeconds\":-5}")] + [InlineData("{\"ProbeTimeoutSeconds\":null}")] + [InlineData("not json")] + [InlineData("[1,2]")] + [InlineData("{\"Nested\":{\"ProbeTimeoutSeconds\":40}}")] // only the root key counts + public void No_valid_configured_timeout_falls_back(string? json) + => TestConnectBudget.Resolve(json, fallbackSeconds: 10).ShouldBe(10); + + [Fact] + public void The_fallback_is_clamped_too() + { + TestConnectBudget.Resolve("{}", fallbackSeconds: 0).ShouldBe(TestConnectBudget.MinSeconds); + TestConnectBudget.Resolve("{}", fallbackSeconds: 999).ShouldBe(TestConnectBudget.MaxSeconds); + } + + [Fact] + public void Button_resolves_its_budget_from_the_config_and_drains_a_threading_timer() + { + var src = AdminUiSource.Read("Components", "Shared", "Drivers", "DriverTestConnectButton.razor"); + + src.ShouldContain("TestConnectBudget.Resolve(json, TimeoutSeconds)"); + src.ShouldContain("@implements IAsyncDisposable"); + src.ShouldContain("System.Threading.Timer"); + src.ShouldNotContain("System.Timers.Timer"); + src.ShouldNotContain("async (_, _)"); + } +}