- @* 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");
+ }
}