Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor
T
Joseph Doherty 9e243493fb ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
2026-08-11 05:50:12 -04:00

371 lines
15 KiB
Plaintext

@page "/design/connections/create"
@page "/design/connections/{Id:int}/edit"
@page "/design/data-connections/create"
@page "/design/data-connections/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@using ZB.MOM.WW.ScadaBridge.Commons.Serialization
@using ZB.MOM.WW.ScadaBridge.Commons.Validators
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Connection</span>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Site</label>
@if (_siteLocked)
{
<input type="text"
class="form-control form-control-plaintext form-control-sm"
readonly
value="@_siteName" />
<div class="form-text">Site is locked after creation.</div>
}
else
{
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
}
</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">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (Id.HasValue && _protocol == "OpcUa")
{
<div class="mb-2">
<a class="btn btn-outline-secondary btn-sm"
data-test="manage-certificates-link"
href="@($"/design/connections/{Id}/certificates")">
Manage certificates
</a>
<div class="form-text">
View, trust, and remove OPC UA server certificates in the
site's node-wide trusted-peer store.
</div>
</div>
}
</div>
</div>
@* Each endpoint editor is a heavyweight sub-form, so it gets its own card
rather than sharing one body separated only by a muted <h6>. *@
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Primary endpoint</span>
</div>
<div class="card-body">
@if (_protocol == "MxGateway")
{
<MxGatewayEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryMx"
Errors="_primaryErrors" />
}
else
{
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
SiteIdentifier="@_formSiteIdentifier"
ConnectionName="@_formName"
Protocol="@_protocol"
Errors="_primaryErrors" />
}
</div>
</div>
<div class="card mb-3">
<div class="card-header d-flex align-items-center">
<span class="fw-semibold">Backup endpoint</span>
@if (!_showBackup)
{
<span class="badge bg-body-secondary text-body-secondary border ms-2">Optional</span>
}
</div>
<div class="card-body">
@if (!_showBackup)
{
<div class="mb-0">
<button type="button" class="btn btn-outline-secondary btn-sm"
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
}
else
{
@if (_protocol == "MxGateway")
{
<MxGatewayEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupMx"
Errors="_backupErrors" />
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
SiteIdentifier="@_formSiteIdentifier"
ConnectionName="@_formName"
Protocol="@_protocol"
Errors="_backupErrors" />
}
<div class="mb-2">
<label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries before failing over to backup endpoint.</div>
</div>
<div class="mb-0">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mb-3">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private List<Site> _sites = new();
private int _formSiteId;
private string _siteName = string.Empty;
private bool _siteLocked;
private string _formName = string.Empty;
private string _protocol = "OpcUa";
private bool _protocolLocked;
private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new();
private MxGatewayEndpointConfig _primaryMx = new();
private MxGatewayEndpointConfig _backupMx = new();
private bool _primaryIsLegacy;
private bool _backupIsLegacy;
private bool _showBackup;
private int _formFailoverRetryCount = 3;
private ValidationResult? _primaryErrors;
private ValidationResult? _backupErrors;
private string? _formError;
// The machine-readable site identifier (used in Akka addresses) for the currently
// selected site — resolved from _formSiteId (the numeric primary key) so the
// endpoint editor's Verify probe can target the owning site. Blank until a site is
// chosen, which disables verification in the editor.
private string _formSiteIdentifier =>
_sites.FirstOrDefault(s => s.Id == _formSiteId)?.SiteIdentifier ?? string.Empty;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue)
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formSiteId = _editingConnection.SiteId;
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
_protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol;
_protocolLocked = true;
LoadConfig(_editingConnection);
}
}
else if (SiteId.HasValue)
{
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
if (site != null)
{
_formSiteId = site.Id;
_siteName = site.Name;
_siteLocked = true;
}
}
}
catch (Exception ex)
{
_formError = $"Failed to load: {ex.Message}";
}
finally
{
_loading = false;
}
}
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()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
string primaryJson;
string? backupJson;
if (_protocol == "MxGateway")
{
_primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary.");
_backupErrors = _showBackup
? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_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
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void EnableBackup() => _showBackup = true;
private void RemoveBackup()
{
_showBackup = false;
_backupConfig = new OpcUaEndpointConfig();
_backupMx = new MxGatewayEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
}