9e243493fb
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.
481 lines
22 KiB
Plaintext
481 lines
22 KiB
Plaintext
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@inject IEndpointVerificationService VerificationService
|
|
@inject ICertManagementService CertManagementService
|
|
|
|
<div class="opcua-endpoint-editor">
|
|
<h6 class="text-muted border-bottom pb-1">@Title</h6>
|
|
|
|
@if (IsLegacy)
|
|
{
|
|
<div class="alert alert-warning py-1 small mb-2">
|
|
This connection was migrated from a legacy format.
|
|
Review the settings and Save to update.
|
|
</div>
|
|
}
|
|
|
|
@* Each settings group gets a real boundary (fieldset + legend) rather than an
|
|
unstyled muted caption. Fieldsets — not nested cards — because this editor is
|
|
embedded inside DataConnectionForm's card; same idiom as TransportExport. *@
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Connection</legend>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-7">
|
|
<label class="form-label small">Endpoint URL</label>
|
|
<input type="text" class="form-control form-control-sm"
|
|
@bind="Config.EndpointUrl"
|
|
placeholder="opc.tcp://host:4840" />
|
|
@RenderFieldError("EndpointUrl")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Security Mode</label>
|
|
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
|
|
<option value="@OpcUaSecurityMode.None">None</option>
|
|
<option value="@OpcUaSecurityMode.Sign">Sign</option>
|
|
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign & Encrypt</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="@($"{IdPrefix}-autoaccept")"
|
|
@bind="Config.AutoAcceptUntrustedCerts" />
|
|
<label class="form-check-label small"
|
|
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
|
data-test="verify-endpoint-btn"
|
|
disabled="@_verifying"
|
|
@onclick="VerifyEndpoint">
|
|
@if (_verifying)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
|
<span>Verifying…</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Verify endpoint</span>
|
|
}
|
|
</button>
|
|
|
|
@if (_verifyResult is { } result)
|
|
{
|
|
@if (result.Success)
|
|
{
|
|
<span class="text-success small ms-2" data-test="verify-success">
|
|
✓ Endpoint reachable
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
@* The failure text is a raw driver exception message. Give it its own
|
|
block below the button (rather than inline beside it) and clamp it,
|
|
so a long message cannot reflow the whole button row. Full text
|
|
stays reachable via the title. *@
|
|
<div class="mt-1">
|
|
<span class="text-danger small cell-clamp-2"
|
|
data-test="verify-failure"
|
|
title="@result.FailureKind: @result.Error">@result.FailureKind: @result.Error</span>
|
|
</div>
|
|
}
|
|
|
|
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
|
|
&& result.Cert is { } cert)
|
|
{
|
|
<div class="border rounded bg-body-secondary p-2 mt-2 small" data-test="verify-cert-panel">
|
|
<div class="text-muted mb-1">Untrusted server certificate</div>
|
|
@* Server-supplied X.509 DNs / thumbprints: clip so a long DN cannot
|
|
stretch the panel; the full value stays in the title. *@
|
|
<dl class="row mb-1 small">
|
|
<dt class="col-sm-3">Subject</dt>
|
|
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Subject">@cert.Subject</code></dd>
|
|
<dt class="col-sm-3">Issuer</dt>
|
|
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Issuer">@cert.Issuer</code></dd>
|
|
<dt class="col-sm-3">Thumbprint</dt>
|
|
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Thumbprint">@cert.Thumbprint</code></dd>
|
|
<dt class="col-sm-3">Not before</dt>
|
|
<dd class="col-sm-9">@cert.NotBeforeUtc.ToString("u")</dd>
|
|
<dt class="col-sm-3">Not after</dt>
|
|
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
|
|
</dl>
|
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
|
<Authorized>
|
|
<button type="button" class="btn btn-outline-warning btn-sm mt-1"
|
|
data-test="trust-cert-btn"
|
|
disabled="@_trusting"
|
|
@onclick="() => TrustCert(cert)">
|
|
@if (_trusting)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
|
<span>Trusting…</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Trust certificate</span>
|
|
}
|
|
</button>
|
|
<div class="text-muted fst-italic mt-1">
|
|
Trusting adds this certificate to every node of the site's
|
|
trusted-peer store (node-wide), then re-runs Verify.
|
|
</div>
|
|
</Authorized>
|
|
<NotAuthorized>
|
|
<div class="text-muted fst-italic">
|
|
An Administrator must trust this certificate (cert management).
|
|
</div>
|
|
</NotAuthorized>
|
|
</AuthorizeView>
|
|
|
|
@if (_trustError is { } trustError)
|
|
{
|
|
@* Raw exception text from the trust call — clamped, full text in title. *@
|
|
<div class="text-danger small mt-1 cell-clamp-2"
|
|
data-test="trust-cert-error"
|
|
title="@trustError">@trustError</div>
|
|
}
|
|
@if (_trustSucceeded)
|
|
{
|
|
<div class="text-success small mt-1" data-test="trust-cert-success">
|
|
✓ Certificate trusted.
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Authentication</legend>
|
|
@if (Config.UserIdentity is null)
|
|
{
|
|
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
|
@onclick="EnableAuthentication">Enable Authentication</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Token type</label>
|
|
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
|
|
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
|
|
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
|
|
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
|
|
</select>
|
|
</div>
|
|
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
|
|
{
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Username</label>
|
|
<input type="text" class="form-control form-control-sm"
|
|
@bind="Config.UserIdentity.Username" />
|
|
@RenderFieldError("UserIdentity.Username")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Password</label>
|
|
<input type="password" class="form-control form-control-sm"
|
|
@bind="Config.UserIdentity.Password" />
|
|
</div>
|
|
}
|
|
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
|
|
{
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Certificate path</label>
|
|
<input type="text" class="form-control form-control-sm"
|
|
@bind="Config.UserIdentity.CertificatePath"
|
|
placeholder="/etc/scadabridge/pki/client.pfx" />
|
|
@RenderFieldError("UserIdentity.CertificatePath")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Certificate password</label>
|
|
<input type="password" class="form-control form-control-sm"
|
|
@bind="Config.UserIdentity.CertificatePassword" />
|
|
</div>
|
|
}
|
|
<div class="col-md-3 d-flex align-items-end">
|
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
|
@onclick="() => Config.UserIdentity = null">
|
|
Remove Authentication
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Timing</legend>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Session timeout (ms)</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.SessionTimeoutMs" min="1" />
|
|
@RenderFieldError("SessionTimeoutMs")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Operation timeout (ms)</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.OperationTimeoutMs" min="1" />
|
|
@RenderFieldError("OperationTimeoutMs")
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Subscription</legend>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Publishing interval (ms)</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.PublishingIntervalMs" min="1" />
|
|
@RenderFieldError("PublishingIntervalMs")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Sampling interval (ms)</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.SamplingIntervalMs" min="1" />
|
|
@RenderFieldError("SamplingIntervalMs")
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Queue size</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.QueueSize" min="1" />
|
|
@RenderFieldError("QueueSize")
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Keep-alive count</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.KeepAliveCount" min="1" />
|
|
@RenderFieldError("KeepAliveCount")
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Lifetime count</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.LifetimeCount" min="1" />
|
|
@RenderFieldError("LifetimeCount")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Max notifications / publish</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.MaxNotificationsPerPublish" min="1" />
|
|
@RenderFieldError("MaxNotificationsPerPublish")
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Advanced subscription</legend>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Subscription display name</label>
|
|
<input type="text" class="form-control form-control-sm"
|
|
@bind="Config.SubscriptionDisplayName" />
|
|
@RenderFieldError("SubscriptionDisplayName")
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Subscription priority</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.SubscriptionPriority" min="0" max="255" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Timestamps to return</label>
|
|
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
|
|
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
|
|
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
|
|
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="@($"{IdPrefix}-discardoldest")"
|
|
@bind="Config.DiscardOldest" />
|
|
<label class="form-check-label small"
|
|
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-3">
|
|
<legend class="h6">Deadband filter</legend>
|
|
@if (Config.Deadband is null)
|
|
{
|
|
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
|
@onclick="EnableDeadband">Enable Deadband</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Type</label>
|
|
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
|
|
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
|
|
<option value="@OpcUaDeadbandType.Percent">Percent</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Value</label>
|
|
<input type="number" step="0.01" class="form-control form-control-sm"
|
|
@bind="Config.Deadband.Value" min="0" />
|
|
@RenderFieldError("Deadband.Value")
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-end">
|
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
|
@onclick="() => Config.Deadband = null">
|
|
Remove Deadband
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-0">
|
|
<legend class="h6">Heartbeat</legend>
|
|
@if (Config.Heartbeat is null)
|
|
{
|
|
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
|
@onclick="EnableHeartbeat">Enable Heartbeat</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-6">
|
|
<label class="form-label small">Tag path</label>
|
|
<input type="text" class="form-control form-control-sm"
|
|
@bind="Config.Heartbeat.TagPath"
|
|
placeholder="Sensors.Heartbeat" />
|
|
@RenderFieldError("Heartbeat.TagPath")
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Max silence (s)</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
|
|
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-end">
|
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
|
@onclick="() => Config.Heartbeat = null">
|
|
Remove Heartbeat
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</fieldset>
|
|
</div>
|
|
|
|
@code {
|
|
[Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!;
|
|
[Parameter] public string Title { get; set; } = "Endpoint";
|
|
[Parameter] public string IdPrefix { get; set; } = "endpoint";
|
|
[Parameter] public bool IsLegacy { get; set; }
|
|
[Parameter] public ValidationResult? Errors { get; set; }
|
|
|
|
// Verify-endpoint context (M7): the site + connection identity the verify
|
|
// probe targets. Supplied by DataConnectionForm (_formSiteId → SiteIdentifier,
|
|
// _formName, _protocol). When SiteIdentifier is blank the connection has not been
|
|
// assigned a site yet, so verification is unavailable.
|
|
[Parameter] public string SiteIdentifier { get; set; } = string.Empty;
|
|
[Parameter] public string ConnectionName { get; set; } = string.Empty;
|
|
[Parameter] public string Protocol { get; set; } = "OpcUa";
|
|
|
|
private bool _verifying;
|
|
private VerifyEndpointResult? _verifyResult;
|
|
private bool _trusting;
|
|
private bool _trustSucceeded;
|
|
private string? _trustError;
|
|
|
|
private Task VerifyEndpoint() => VerifyEndpoint(clearTrustNotes: true);
|
|
|
|
private async Task VerifyEndpoint(bool clearTrustNotes)
|
|
{
|
|
_verifying = true;
|
|
_verifyResult = null;
|
|
// A fresh (user-initiated) verify supersedes any prior trust outcome — clear
|
|
// the inline notes so a stale "trusted" / error banner doesn't linger across
|
|
// probes. The trust-triggered re-verify keeps the success note so the operator
|
|
// still sees confirmation even if the re-probe also surfaces a (different) cert.
|
|
if (clearTrustNotes)
|
|
{
|
|
_trustSucceeded = false;
|
|
_trustError = null;
|
|
}
|
|
try
|
|
{
|
|
_verifyResult = await VerificationService.VerifyAsync(
|
|
SiteIdentifier, ConnectionName, Protocol, Config);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_verifyResult = new VerifyEndpointResult(
|
|
false, VerifyFailureKind.ServerError, ex.Message, null);
|
|
}
|
|
finally
|
|
{
|
|
_verifying = false;
|
|
}
|
|
}
|
|
|
|
// M7: trust the captured untrusted server certificate at every node of the
|
|
// owning site, then re-run Verify (which should now succeed and clear the cert
|
|
// panel). Administrator-gated by the AuthorizeView wrapping the button; the
|
|
// CertManagementService enforces the same role server-side-of-the-trust-boundary.
|
|
private async Task TrustCert(ServerCertInfo cert)
|
|
{
|
|
_trusting = true;
|
|
_trustError = null;
|
|
_trustSucceeded = false;
|
|
try
|
|
{
|
|
var result = await CertManagementService.TrustAsync(
|
|
SiteIdentifier, ConnectionName, cert.DerBase64, cert.Thumbprint);
|
|
if (result.Success)
|
|
{
|
|
_trustSucceeded = true;
|
|
// Re-run Verify so the editor reflects the now-trusted state (and
|
|
// clears the cert panel on the expected success). Preserve the
|
|
// success note (clearTrustNotes:false) so confirmation is shown.
|
|
await VerifyEndpoint(clearTrustNotes: false);
|
|
}
|
|
else
|
|
{
|
|
_trustError = result.Error ?? "Failed to trust certificate.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_trustError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
_trusting = false;
|
|
}
|
|
}
|
|
|
|
private void EnableHeartbeat() =>
|
|
Config.Heartbeat = new OpcUaHeartbeatConfig();
|
|
|
|
private void EnableAuthentication() =>
|
|
Config.UserIdentity = new OpcUaUserIdentityConfig();
|
|
|
|
private void EnableDeadband() =>
|
|
Config.Deadband = new OpcUaDeadbandConfig();
|
|
|
|
private RenderFragment? RenderFieldError(string field)
|
|
{
|
|
var match = Errors?.Errors.FirstOrDefault(e =>
|
|
e.EntityName != null
|
|
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
|
|
return match is null
|
|
? null
|
|
: @<div class="text-danger small">@match.Message</div>;
|
|
}
|
|
}
|