feat(adminui): editable OpcUaClient endpoint URL list via CollectionEditor

This commit is contained in:
Joseph Doherty
2026-05-29 09:41:09 -04:00
parent 244949caa3
commit 7570df76d3
2 changed files with 149 additions and 19 deletions
@@ -131,11 +131,25 @@ else
<div class="form-text">Default 10.</div>
</div>
</div>
@* Endpoint URLs list — read-only JSON view (full list-editor is a follow-up) *@
<div class="row g-3 mt-1">
<div class="col-12">
<label class="form-label">Endpoint URLs (failover list — read-only; edit via raw JSON import or use Endpoint URL above)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_endpointUrlsJson</pre>
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
</div>
@@ -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<EndpointUrlRow> _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; } = [];
}
/// <summary>
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
/// <c>List&lt;string&gt;</c> (a failover list) so the row is a thin wrapper the
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
/// </summary>
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<EndpointUrlRow> 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;
}
}
/// <summary>
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> 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 <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
/// survives round-trip via the original deserialized record, re-serialized unchanged.
/// </summary>
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<string> _endpointUrls = [];
// Preserved read-only collection (round-tripped unchanged from original record)
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
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<string> endpointUrls) => new()
{
EndpointUrl = EndpointUrl,
EndpointUrls = _endpointUrls,
EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri,
SessionName = SessionName,
@@ -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<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
var unsMappingTable = new Dictionary<string, string>
{
@@ -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<OpcUaClientDriverPage.EndpointUrlRow>();
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<OpcUaClientDriverPage.EndpointUrlRow>();
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<OpcUaClientDriverPage.EndpointUrlRow>
{
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<OpcUaClientDriverPage.EndpointUrlRow>
{
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<EndpointUrlRow>; loading from EndpointUrls and
// converting back must preserve order (the failover list is ordered, primary first).
var endpointUrls = new List<string> { "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");
}
}