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 a11e841e..aa04a0e3 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 @@ -131,11 +131,25 @@ else
Default 10.
- @* Endpoint URLs list — read-only JSON view (full list-editor is a follow-up) *@
- -
@_endpointUrlsJson
+ + + Endpoint URL (failover list — first reachable wins) + + + @e.Url + + + + +
When this list is non-empty, the single Endpoint URL above is ignored.
+
+
@@ -281,8 +295,10 @@ else private void OnAddressPicked(string address) => _pickedAddress = address; - // Read-only JSON snippets for collections that have no list editor yet. - private string _endpointUrlsJson = "[]"; + // Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal. + private List _endpoints = []; + + // Read-only JSON snippet for the UnsMappingTable, which has no list editor yet. private string _unsMappingTableJson = "{}"; protected override async Task OnInitializedAsync() @@ -322,7 +338,7 @@ else var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions(); _form = new FormModel(); _form.OpcUa = OpcUaClientFormModel.FromRecord(opts); - _endpointUrlsJson = System.Text.Json.JsonSerializer.Serialize(opts.EndpointUrls, _jsonOpts); + _endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList(); _unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; @@ -336,7 +352,7 @@ else _busy = true; _error = null; try { - var opts = _form.OpcUa.ToRecord(); + var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()); var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) @@ -407,7 +423,8 @@ else } private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.OpcUa.ToRecord(), _jsonOpts); + => System.Text.Json.JsonSerializer.Serialize( + _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts); private static OpcUaClientDriverOptions? TryDeserialize(string json) { @@ -422,11 +439,36 @@ else public byte[] RowVersion { get; set; } = []; } + /// + /// Mutable VM for a single endpoint URL row. EndpointUrls is a plain + /// List<string> (a failover list) so the row is a thin wrapper the + /// modal can bind to. + /// + public sealed class EndpointUrlRow + { + public string Url { get; set; } = ""; + public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone(); + public static EndpointUrlRow FromUrl(string u) => new() { Url = u }; + public string ToUrl() => Url.Trim(); + + public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required."; + if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase)) + return "Endpoint URL must start with opc.tcp://"; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase)) + return $"Duplicate endpoint '{row.Url}'."; + return null; + } + } + /// /// 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. + /// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and + /// threaded into ); UnsMappingTable is shown as read-only JSON and + /// survives round-trip via the original deserialized record, re-serialized unchanged. /// public sealed class OpcUaClientFormModel { @@ -461,8 +503,7 @@ else // Diagnostics public int ProbeTimeoutSeconds { get; set; } = 15; - // Preserved read-only collections (round-tripped unchanged from original record) - internal IReadOnlyList _endpointUrls = []; + // Preserved read-only collection (round-tripped unchanged from original record) internal IReadOnlyDictionary _unsMappingTable = new System.Collections.Generic.Dictionary(); public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new() @@ -488,14 +529,13 @@ else UserCertificatePassword = r.UserCertificatePassword, TargetNamespaceKind = r.TargetNamespaceKind, ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, - _endpointUrls = r.EndpointUrls, _unsMappingTable = r.UnsMappingTable, }; - public OpcUaClientDriverOptions ToRecord() => new() + public OpcUaClientDriverOptions ToRecord(IReadOnlyList endpointUrls) => new() { EndpointUrl = EndpointUrl, - EndpointUrls = _endpointUrls, + EndpointUrls = endpointUrls, BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot, ApplicationUri = ApplicationUri, SessionName = SessionName, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index 12f61c46..60920f4d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; @@ -85,9 +86,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void FormModel_RoundTrip_PreservesAllFields() { - // Construct options with non-default values for every editable property plus - // non-empty EndpointUrls and UnsMappingTable — both are "read-only" in the form - // but must survive the FormModel translation unchanged. + // Construct options with non-default values for every editable property plus a + // non-empty UnsMappingTable (read-only in the form, round-tripped via the original + // record). EndpointUrls is now edited via the CollectionEditor on the page and is + // threaded into ToRecord explicitly; see EndpointUrls_ListRoundTrip_PreservesOrder. var endpointUrls = new List { "opc.tcp://primary:4840", "opc.tcp://backup:4840" }; var unsMappingTable = new Dictionary { @@ -123,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests }; var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original); - var result = form.ToRecord(); + var result = form.ToRecord(endpointUrls); result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840"); result.EndpointUrls.Count.ShouldBe(2); @@ -153,4 +155,92 @@ public sealed class OpcUaClientDriverPageFormSerializationTests result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2"); result.ProbeTimeoutSeconds.ShouldBe(25); } + + [Fact] + public void EndpointUrlRow_FromUrl_ToUrl_Trims() + { + var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 "); + + row.Url.ShouldBe(" opc.tcp://plc:4840 "); + row.ToUrl().ShouldBe("opc.tcp://plc:4840"); + } + + [Fact] + public void EndpointUrlRow_ValidateRow_RejectsBlank() + { + var all = new List(); + var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " }; + + var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + + error.ShouldBe("URL is required."); + } + + [Fact] + public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme() + { + var all = new List(); + var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" }; + + var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + + error.ShouldBe("Endpoint URL must start with opc.tcp://"); + } + + [Fact] + public void EndpointUrlRow_ValidateRow_RejectsDuplicate() + { + var all = new List + { + new() { Url = "opc.tcp://primary:4840" }, + new() { Url = "opc.tcp://backup:4840" }, + }; + // Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive. + var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " }; + + var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + + error.ShouldNotBeNull(); + error.ShouldContain("Duplicate endpoint"); + } + + [Fact] + public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace() + { + var all = new List + { + new() { Url = "opc.tcp://primary:4840" }, + new() { Url = "opc.tcp://backup:4840" }, + }; + // Editing index 0 and keeping the same URL must not flag itself as a duplicate. + var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" }; + + var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0); + + error.ShouldBeNull(); + } + + [Fact] + public void EndpointUrls_ListRoundTrip_PreservesOrder() + { + // The page holds endpoints as a List; loading from EndpointUrls and + // converting back must preserve order (the failover list is ordered, primary first). + var endpointUrls = new List { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" }; + + var rows = endpointUrls + .Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl) + .ToList(); + var roundTripped = rows.Select(r => r.ToUrl()).ToList(); + + roundTripped.Count.ShouldBe(3); + roundTripped[0].ShouldBe("opc.tcp://primary:4840"); + roundTripped[1].ShouldBe("opc.tcp://secondary:4840"); + roundTripped[2].ShouldBe("opc.tcp://tertiary:4840"); + + var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()); + var result = form.ToRecord(roundTripped); + result.EndpointUrls.Count.ShouldBe(3); + result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840"); + result.EndpointUrls[2].ShouldBe("opc.tcp://tertiary:4840"); + } }