365 lines
16 KiB
Plaintext
365 lines
16 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
|
|
@inject IEndpointVerificationService VerificationService
|
|
|
|
<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>
|
|
}
|
|
|
|
<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
|
|
{
|
|
<span class="text-danger small ms-2" data-test="verify-failure">
|
|
@result.FailureKind: @result.Error
|
|
</span>
|
|
}
|
|
|
|
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
|
|
&& result.Cert is { } cert)
|
|
{
|
|
<div class="border rounded bg-light p-2 mt-2 small" data-test="verify-cert-panel">
|
|
<div class="text-muted mb-1">Untrusted server certificate</div>
|
|
<dl class="row mb-1 small">
|
|
<dt class="col-sm-3">Subject</dt>
|
|
<dd class="col-sm-9"><code>@cert.Subject</code></dd>
|
|
<dt class="col-sm-3">Issuer</dt>
|
|
<dd class="col-sm-9"><code>@cert.Issuer</code></dd>
|
|
<dt class="col-sm-3">Thumbprint</dt>
|
|
<dd class="col-sm-9"><code>@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>
|
|
<div class="text-muted fst-italic">
|
|
Use cert management to trust this certificate.
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<div class="text-muted small mt-2 mb-1">Authentication</div>
|
|
@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>
|
|
}
|
|
|
|
<div class="text-muted small mt-2 mb-1">Timing</div>
|
|
<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>
|
|
|
|
<div class="text-muted small mt-2 mb-1">Subscription</div>
|
|
<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>
|
|
|
|
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
|
|
<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>
|
|
|
|
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
|
|
@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>
|
|
}
|
|
|
|
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
|
|
@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>
|
|
}
|
|
</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 T17): 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 async Task VerifyEndpoint()
|
|
{
|
|
_verifying = true;
|
|
_verifyResult = 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;
|
|
}
|
|
}
|
|
|
|
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>;
|
|
}
|
|
}
|