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,