feat(adminui): editable OpcUaClient endpoint URL list via CollectionEditor
This commit is contained in:
+55
-15
@@ -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<string></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,
|
||||
|
||||
+94
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user