From 4b374fd1772aba1c9795daff41e29ffcbd83b28b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:02:49 -0400 Subject: [PATCH] feat(adminui): Test Connect button on every typed driver page - AdminProbeService routes TestDriverConnect through IAdminOperationsClient with a 65s outer guard (actor side already clamps to [1,60]). - Added generic AskAsync to IAdminOperationsClient interface and AdminOperationsClient impl, delegating straight to the Akka proxy. - DriverTestConnectButton renders the button + inline result chip, auto-clears after 30s, disables during in-flight. - Wired into all 9 typed driver pages directly under the identity section. Sources timeout from the form's ProbeTimeoutSeconds; sources config JSON from the form's current Options (operator can test BEFORE saving). --- .../Interfaces/IAdminOperationsClient.cs | 10 +++ .../Clients/AdminOperationsClient.cs | 8 ++ .../Clients/AdminProbeService.cs | 55 +++++++++++++ .../Clients/ServiceCollectionExtensions.cs | 1 + .../Clusters/Drivers/AbCipDriverPage.razor | 10 +++ .../Clusters/Drivers/AbLegacyDriverPage.razor | 10 +++ .../Clusters/Drivers/FocasDriverPage.razor | 10 +++ .../Clusters/Drivers/GalaxyDriverPage.razor | 10 +++ .../HistorianWonderwareDriverPage.razor | 10 +++ .../Clusters/Drivers/ModbusDriverPage.razor | 10 +++ .../Drivers/OpcUaClientDriverPage.razor | 10 +++ .../Pages/Clusters/Drivers/S7DriverPage.razor | 10 +++ .../Clusters/Drivers/TwinCATDriverPage.razor | 10 +++ .../Drivers/DriverTestConnectButton.razor | 82 +++++++++++++++++++ 14 files changed, 246 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs index 3957b121..be992f84 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs @@ -14,4 +14,14 @@ public interface IAdminOperationsClient /// The cancellation token. /// A task representing the asynchronous operation containing the deployment start result. Task StartDeploymentAsync(string createdBy, CancellationToken ct); + + /// + /// Generic Ask: forwards to the AdminOperationsActor + /// cluster-singleton proxy and awaits a reply of type . + /// The caller is responsible for applying any outer timeout via . + /// + /// Expected reply type. + /// The message to send. + /// Cancellation token (caller-controlled timeout). + Task AskAsync(object message, CancellationToken ct); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs index e3d1e61f..651ec286 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs @@ -36,4 +36,12 @@ public sealed class AdminOperationsClient : IAdminOperationsClient linked.CancelAfter(AskTimeout); return await _proxy.Ask(msg, AskTimeout, linked.Token); } + + /// + /// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy. + /// Uses the caller-supplied for cancellation; does not impose an + /// additional internal timeout beyond what the proxy itself enforces. + /// + public Task AskAsync(object message, CancellationToken ct) + => _proxy.Ask(message, cancellationToken: ct); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs new file mode 100644 index 00000000..49415b66 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs @@ -0,0 +1,55 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients; + +/// +/// Thin AdminUI-side wrapper for the Test Connect operation. Dispatches a +/// through IAdminOperationsClient, applies a +/// 65-second outer wall (the actor itself clamps to [1,60]s; this guards against the +/// Ask never replying), and surfaces a friendly result for the Razor button to render. +/// +public sealed class AdminProbeService +{ + private readonly IAdminOperationsClient _client; + + /// Initializes a new instance of the . + /// The admin operations client used to dispatch probe requests. + public AdminProbeService(IAdminOperationsClient client) => _client = client; + + /// + /// Dispatches a Test Connect probe for the supplied driver type and config JSON, + /// waiting up to 65 seconds for a reply before surfacing a timeout failure. + /// + /// Driver type key (must match an installed IDriverProbe.DriverType). + /// Driver config as JSON (same shape as DriverInstance.DriverConfig). + /// Per-probe timeout; actor clamps to [1, 60]. + /// Optional cancellation token from the caller. + public async Task TestAsync( + string driverType, + string configJson, + int timeoutSeconds, + CancellationToken ct = default) + { + var correlationId = Guid.NewGuid(); + var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId); + + // 65s outer guard — the actor's CTS clamps to 60s; if the Ask never returns we still want + // a deterministic failure surface for the UI. + using var outerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + outerCts.CancelAfter(TimeSpan.FromSeconds(65)); + + try + { + return await _client.AskAsync(msg, outerCts.Token); + } + catch (OperationCanceledException) + { + return new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId); + } + catch (Exception ex) + { + return new TestDriverConnectResult(false, $"Probe dispatch failed: {ex.Message}", null, correlationId); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/ServiceCollectionExtensions.cs index 77dc5168..c0d947a2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index 4f92011b..d7bd17e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Operation timeout *@
Operation settings
@@ -304,6 +311,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); + private static AbCipDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor index 3763380b..99aa96e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -42,6 +43,12 @@ else } +
+ +
+ @* Operation settings *@
Operation settings
@@ -273,6 +280,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); + private static AbLegacyDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor index d6a5a0af..3a4e11f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Connection *@
Connection
@@ -344,6 +351,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + private static FocasDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor index 2014072f..dd3dc70b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* mxaccessgw connection *@
mxaccessgw connection
@@ -331,6 +338,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts); + private static GalaxyDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor index 12b3cc13..a2dad941 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Connection *@
Connection
@@ -260,6 +267,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts); + private static WonderwareHistorianClientOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor index 48541af7..6ba38b3a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Transport *@
Transport
@@ -415,6 +422,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts); + private static ModbusDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor index 8479d06c..4d516334 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Endpoint *@
Endpoint
@@ -379,6 +386,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.OpcUa.ToRecord(), _jsonOpts); + private static OpcUaClientDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor index 67304ea4..58867fd0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Connection *@
Connection
@@ -280,6 +287,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + private static S7DriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor index 1f5db0da..3fe62517 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -41,6 +42,12 @@ else } +
+ +
+ @* Options *@
Options
@@ -286,6 +293,9 @@ else finally { _busy = false; } } + private string SerializeCurrentConfig() + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + private static TwinCATDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } 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 new file mode 100644 index 00000000..1219b527 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor @@ -0,0 +1,82 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients +@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin +@inject AdminProbeService Probe +@implements IDisposable + +
+ + + @if (_result is not null) + { + @if (_result.Ok) + { + + OK · @_result.LatencyMs?.ToString("F0") ms + + } + else + { + + Failed · @TruncatedMessage() + + } + } +
+ +@code { + /// Driver type key — must match an installed IDriverProbe.DriverType. + [Parameter, EditorRequired] public string DriverType { get; set; } = ""; + + /// 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; + + private bool _busy; + private TestDriverConnectResult? _result; + private System.Timers.Timer? _clearTimer; + + private async Task OnClickAsync() + { + _busy = true; + _result = null; + StateHasChanged(); + try + { + var json = GetConfigJson() ?? "{}"; + _result = await Probe.TestAsync(DriverType, json, TimeoutSeconds); + } + catch (Exception ex) + { + _result = new TestDriverConnectResult(false, ex.Message, null, Guid.Empty); + } + finally + { + _busy = false; + ScheduleClear(); + StateHasChanged(); + } + } + + private void ScheduleClear() + { + _clearTimer?.Dispose(); + _clearTimer = new System.Timers.Timer(30_000) { AutoReset = false }; + _clearTimer.Elapsed += async (_, _) => + { + _result = null; + await InvokeAsync(StateHasChanged); + }; + _clearTimer.Start(); + } + + private string TruncatedMessage() + => _result?.Message is null ? "" : + (_result.Message.Length > 60 ? _result.Message[..60] + "…" : _result.Message); + + public void Dispose() => _clearTimer?.Dispose(); +}