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 new file mode 100644 index 00000000..83231acf --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor @@ -0,0 +1,487 @@ +@page "/clusters/{ClusterId}/drivers/new/opcuaclient" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New OPC UA Client driver" : "Edit OPC UA Client driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Endpoint *@ +
+
Endpoint
+
+
+
+ + +
Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.
+
+
+ + +
Restrict mirroring to a sub-tree.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
Default 3 s — failover sweep budget.
+
+
+ + +
Default 10 s — steady-state reads/writes.
+
+
+ + +
Default 120 s.
+
+
+ + +
Default 5 s.
+
+
+ + +
Initial delay after session drop. Default 5 s.
+
+
+ + +
Default 10000.
+
+
+ + +
Default 10.
+
+
+ @* Endpoint URLs list — read-only JSON view (full list-editor is a follow-up) *@ +
+
+ +
@_endpointUrlsJson
+
+
+
+
+ + @* Security *@ +
+
Security
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+
+
+ + @* Authentication *@ +
+
Authentication
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+ @if (_form.OpcUa.AuthType == OpcUaAuthType.Username) + { +
+ + +
+
+ + +
+ } + @if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate) + { +
+ + +
+
+ + +
+ } +
+
+
+ + @* Namespace mapping *@ +
+
Namespace mapping
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.
+
+
+ +
@_unsMappingTableJson
+
Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.
+
+
+
+
+ + @* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 15.
+
+
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "OpcUaClient"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + // Read-only JSON snippets for collections that have no list editor yet. + private string _endpointUrlsJson = "[]"; + private string _unsMappingTableJson = "{}"; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _identityModel = new() + { + DriverInstanceId = "", + Name = "", + DriverType = DriverTypeKey, + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + }; + _form = new FormModel(); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions(); + _form = new FormModel(); + _form.OpcUa = OpcUaClientFormModel.FromRecord(opts); + _endpointUrlsJson = System.Text.Json.JsonSerializer.Serialize(opts.EndpointUrls, _jsonOpts); + _unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.OpcUa.ToRecord(); + var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static OpcUaClientDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public OpcUaClientFormModel OpcUa { get; set; } = new(); + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + } + + /// + /// Mutable mirror of with int wrappers for + /// TimeSpan fields so Blazor InputNumber can bind them. + /// EndpointUrls and UnsMappingTable are shown as read-only JSON; they survive round-trip + /// via the original deserialized record and are re-serialized unchanged. + /// + public sealed class OpcUaClientFormModel + { + // Connection + public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840"; + public string? BrowseRoot { get; set; } + public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient"; + public string SessionName { get; set; } = "OtOpcUa-Gateway"; + public bool AutoAcceptCertificates { get; set; } = false; + public int PerEndpointConnectTimeoutSeconds { get; set; } = 3; + public int TimeoutSeconds { get; set; } = 10; + public int SessionTimeoutSeconds { get; set; } = 120; + public int KeepAliveIntervalSeconds { get; set; } = 5; + public int ReconnectPeriodSeconds { get; set; } = 5; + public int MaxDiscoveredNodes { get; set; } = 10_000; + public int MaxBrowseDepth { get; set; } = 10; + + // Security + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None; + public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None; + + // Authentication + public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous; + public string? Username { get; set; } + public string? Password { get; set; } + public string? UserCertificatePath { get; set; } + public string? UserCertificatePassword { get; set; } + + // Namespace mapping + public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment; + + // Diagnostics + public int ProbeTimeoutSeconds { get; set; } = 15; + + // Preserved read-only collections (round-tripped unchanged from original record) + internal IReadOnlyList _endpointUrls = []; + internal IReadOnlyDictionary _unsMappingTable = new System.Collections.Generic.Dictionary(); + + public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new() + { + EndpointUrl = r.EndpointUrl, + BrowseRoot = r.BrowseRoot, + ApplicationUri = r.ApplicationUri, + SessionName = r.SessionName, + AutoAcceptCertificates = r.AutoAcceptCertificates, + PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds, + TimeoutSeconds = (int)r.Timeout.TotalSeconds, + SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds, + KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds, + ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds, + MaxDiscoveredNodes = r.MaxDiscoveredNodes, + MaxBrowseDepth = r.MaxBrowseDepth, + SecurityMode = r.SecurityMode, + SecurityPolicy = r.SecurityPolicy, + AuthType = r.AuthType, + Username = r.Username, + Password = r.Password, + UserCertificatePath = r.UserCertificatePath, + UserCertificatePassword = r.UserCertificatePassword, + TargetNamespaceKind = r.TargetNamespaceKind, + ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + _endpointUrls = r.EndpointUrls, + _unsMappingTable = r.UnsMappingTable, + }; + + public OpcUaClientDriverOptions ToRecord() => new() + { + EndpointUrl = EndpointUrl, + EndpointUrls = _endpointUrls, + BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot, + ApplicationUri = ApplicationUri, + SessionName = SessionName, + AutoAcceptCertificates = AutoAcceptCertificates, + PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds), + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds), + KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds), + ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds), + MaxDiscoveredNodes = MaxDiscoveredNodes, + MaxBrowseDepth = MaxBrowseDepth, + SecurityMode = SecurityMode, + SecurityPolicy = SecurityPolicy, + AuthType = AuthType, + Username = string.IsNullOrWhiteSpace(Username) ? null : Username, + Password = string.IsNullOrWhiteSpace(Password) ? null : Password, + UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath, + UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword, + TargetNamespaceKind = TargetNamespaceKind, + UnsMappingTable = _unsMappingTable, + ProbeTimeoutSeconds = ProbeTimeoutSeconds, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..2e325812 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class OpcUaClientDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://plc.internal:4840", + ApplicationUri = "urn:plc:OtOpcUa:GatewayClient", + SessionName = "MySession", + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + AuthType = OpcUaAuthType.Username, + Username = "operator", + Password = "s3cr3t", + PerEndpointConnectTimeout = TimeSpan.FromSeconds(5), + Timeout = TimeSpan.FromSeconds(20), + SessionTimeout = TimeSpan.FromSeconds(180), + KeepAliveInterval = TimeSpan.FromSeconds(10), + ReconnectPeriod = TimeSpan.FromSeconds(15), + AutoAcceptCertificates = true, + BrowseRoot = "i=85", + MaxDiscoveredNodes = 5000, + MaxBrowseDepth = 6, + TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, + ProbeTimeoutSeconds = 20, + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.EndpointUrl.ShouldBe("opc.tcp://plc.internal:4840"); + back.ApplicationUri.ShouldBe("urn:plc:OtOpcUa:GatewayClient"); + back.SessionName.ShouldBe("MySession"); + back.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt); + back.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256); + back.AuthType.ShouldBe(OpcUaAuthType.Username); + back.Username.ShouldBe("operator"); + back.Password.ShouldBe("s3cr3t"); + back.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(5)); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(20)); + back.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(180)); + back.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(10)); + back.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(15)); + back.AutoAcceptCertificates.ShouldBeTrue(); + back.BrowseRoot.ShouldBe("i=85"); + back.MaxDiscoveredNodes.ShouldBe(5000); + back.MaxBrowseDepth.ShouldBe(6); + back.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform); + back.ProbeTimeoutSeconds.ShouldBe(20); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":20}"""; + + var optsWithSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(20); + } +}