feat(centralui): protocol selector + MxGateway editor in DataConnectionForm

Adds an OPC UA | MxGateway protocol dropdown (create-time; locked read-only on
edit), branches the primary/backup endpoint editors, serializer, and validator
by protocol, and persists DataConnection.Protocol accordingly. Updates form
tests: protocol dropdown present on create + MxGateway save round-trips typed
JSON with Protocol=MxGateway.
This commit is contained in:
Joseph Doherty
2026-05-29 08:02:44 -04:00
parent 648d00692f
commit be32e4a7ff
2 changed files with 162 additions and 35 deletions
@@ -49,17 +49,45 @@
</select> </select>
} }
</div> </div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
@if (_protocolLocked)
{
<input type="text"
class="form-control form-control-plaintext form-control-sm"
readonly
value="@_protocol" />
<div class="form-text">Protocol is locked after creation.</div>
}
else
{
<select class="form-select form-select-sm" @bind="_protocol">
<option value="OpcUa">OPC UA</option>
<option value="MxGateway">MxGateway</option>
</select>
}
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small">Name</label> <label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" /> <input type="text" class="form-control form-control-sm" @bind="_formName" />
</div> </div>
<h6 class="text-muted mt-3">Primary endpoint</h6> <h6 class="text-muted mt-3">Primary endpoint</h6>
<OpcUaEndpointEditor Title="Primary Endpoint" @if (_protocol == "MxGateway")
IdPrefix="primary" {
Config="_primaryConfig" <MxGatewayEndpointEditor Title="Primary Endpoint"
IsLegacy="_primaryIsLegacy" IdPrefix="primary"
Errors="_primaryErrors" /> Config="_primaryMx"
Errors="_primaryErrors" />
}
else
{
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
}
<h6 class="text-muted mt-3"> <h6 class="text-muted mt-3">
Backup endpoint Backup endpoint
@@ -77,11 +105,21 @@
} }
else else
{ {
<OpcUaEndpointEditor Title="Backup Endpoint" @if (_protocol == "MxGateway")
IdPrefix="backup" {
Config="_backupConfig" <MxGatewayEndpointEditor Title="Backup Endpoint"
IsLegacy="_backupIsLegacy" IdPrefix="backup"
Errors="_backupErrors" /> Config="_backupMx"
Errors="_backupErrors" />
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
}
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Failover Retry Count</label> <label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;" <input type="number" class="form-control form-control-sm" style="max-width: 120px;"
@@ -118,8 +156,12 @@
private string _siteName = string.Empty; private string _siteName = string.Empty;
private bool _siteLocked; private bool _siteLocked;
private string _formName = string.Empty; private string _formName = string.Empty;
private string _protocol = "OpcUa";
private bool _protocolLocked;
private OpcUaEndpointConfig _primaryConfig = new(); private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new(); private OpcUaEndpointConfig _backupConfig = new();
private MxGatewayEndpointConfig _primaryMx = new();
private MxGatewayEndpointConfig _backupMx = new();
private bool _primaryIsLegacy; private bool _primaryIsLegacy;
private bool _backupIsLegacy; private bool _backupIsLegacy;
private bool _showBackup; private bool _showBackup;
@@ -143,17 +185,10 @@
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}"; _siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true; _siteLocked = true;
_formName = _editingConnection.Name; _formName = _editingConnection.Name;
_protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol;
_protocolLocked = true;
(_primaryConfig, _primaryIsLegacy) = LoadConfig(_editingConnection);
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
}
} }
} }
else if (SiteId.HasValue) else if (SiteId.HasValue)
@@ -177,32 +212,80 @@
} }
} }
private void LoadConfig(DataConnection conn)
{
if (_protocol == "MxGateway")
{
_primaryMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
_backupMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
else
{
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
}
private async Task SaveConnection() private async Task SaveConnection()
{ {
_formError = null; _formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; } if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary."); string primaryJson;
_backupErrors = _showBackup string? backupJson;
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false })) if (_protocol == "MxGateway")
{ {
_formError = "Fix the errors below before saving."; _primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary.");
return; _backupErrors = _showBackup
} ? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.")
: null;
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig); if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null; {
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = MxGatewayEndpointConfigSerializer.Serialize(_primaryMx);
backupJson = _showBackup ? MxGatewayEndpointConfigSerializer.Serialize(_backupMx) : null;
}
else
{
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
}
try try
{ {
if (_editingConnection != null) if (_editingConnection != null)
{ {
_editingConnection.Name = _formName.Trim(); _editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = "OpcUa"; _editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson; _editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson; _editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3; _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
@@ -210,7 +293,7 @@
} }
else else
{ {
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId) var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{ {
PrimaryConfiguration = primaryJson, PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson, BackupConfiguration = backupJson,
@@ -233,6 +316,7 @@
{ {
_showBackup = false; _showBackup = false;
_backupConfig = new OpcUaEndpointConfig(); _backupConfig = new OpcUaEndpointConfig();
_backupMx = new MxGatewayEndpointConfig();
_backupIsLegacy = false; _backupIsLegacy = false;
_formFailoverRetryCount = 3; _formFailoverRetryCount = 3;
} }
@@ -44,12 +44,55 @@ public class DataConnectionFormTests : BunitContext
} }
[Fact] [Fact]
public void NoProtocolDropdown_IsRendered() public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols()
{ {
var cut = RenderForCreateSite(1); var cut = RenderForCreateSite(1);
Assert.DoesNotContain("Custom", cut.Markup);
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList(); var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
Assert.DoesNotContain(labels, l => l == "Protocol"); Assert.Contains(labels, l => l == "Protocol");
// The protocol select offers OPC UA and MxGateway.
var optionTexts = cut.FindAll("option").Select(o => o.TextContent.Trim()).ToList();
Assert.Contains("OPC UA", optionTexts);
Assert.Contains("MxGateway", optionTexts);
}
[Fact]
public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway()
{
DataConnection? captured = null;
await _siteRepo.AddDataConnectionAsync(
Arg.Do<DataConnection>(d => captured = d));
var cut = RenderForCreateSite(1);
// Switch protocol to MxGateway — re-renders with the MxGateway editor.
cut.FindAll("select")
.First(s => s.QuerySelectorAll("option").Any(o => o.TextContent.Trim() == "MxGateway"))
.Change("MxGateway");
// Name (skip readonly Site plaintext input; MxGateway editor inputs carry placeholders).
cut.FindAll("input[type='text']")
.First(i => !i.HasAttribute("readonly") && i.GetAttribute("placeholder") is null)
.Change("MX-1");
// Gateway endpoint
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("http://") == true)
.Change("http://gw:5000");
// API key (password input)
cut.FindAll("input[type='password']")
.First(i => i.GetAttribute("placeholder")?.Contains("API key") == true)
.Change("secret");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
Assert.NotNull(captured);
Assert.Equal("MxGateway", captured!.Protocol);
Assert.NotNull(captured.PrimaryConfiguration);
using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
Assert.Equal("http://gw:5000",
doc.RootElement.GetProperty("endpoint").GetString());
} }
[Fact] [Fact]