feat(centralui+dcl): Test Bindings popup — one-shot live read of bound tags

Adds a Test Bindings button to the Connection Bindings table on the Configure
Instance page that opens a modal showing the live current value of every bound
attribute. Reuses the routing path that the OPC UA tag browser landed on:

  Central:  TestBindingsDialog → IBindingTester → CommunicationService
            → ReadTagValuesCommand → SiteEnvelope (Ask)
  Site:     SiteCommunicationActor → DeploymentManagerActor singleton
            → DataConnectionManagerActor → child DataConnectionActor
            → _adapter.ReadBatchAsync

Split mirrors the browse handler:
  • Manager owns ConnectionNotFound (only it sees the per-site connection set).
  • Child owns ConnectionNotConnected (pre-call status check, never stash —
    read is interactive design-time), Timeout (OperationCanceledException),
    ServerError (any other exception). Per-tag failures from ReadBatchAsync
    become failure TagReadOutcomes without aborting the batch.

CentralUI:
  • IBindingTester / BindingTester — Design-role guard via HasClaim against
    JwtTokenService.RoleClaimType (not IsInRole — see c1e16cf), typed
    transport-failure translation.
  • TestBindingsDialog — ShowAsync(siteId, rows, instanceLabel) method-arg
    pattern (no Razor parameter race; see 2c138b6), groups rows by connection
    and issues one ReadAsync per connection in parallel, per-row error subline
    + per-connection banner, Refresh button re-issues the reads.
  • InstanceConfigure.razor — Test Bindings button next to Save Bindings,
    disabled when no testable rows. OPC UA only today (other protocols have
    no ReadTagValuesCommand wiring yet).

Tests:
  • Commons: ReadTagValuesCommand discovered by ManagementCommandRegistry.
  • DataConnectionLayer: unknown connection → ConnectionNotFound,
    not-connected adapter → ConnectionNotConnected (ReadBatchAsync NOT called),
    success-path mapping (Good/Bad + per-tag error), cancellation → Timeout.
  • CentralUI: register IBindingTester (and the previously-missing
    IOpcUaBrowseService) on the existing InstanceConfigureAuditDrillinTests
    Bunit container so the page renders cleanly with the new dialog.
This commit is contained in:
Joseph Doherty
2026-05-28 13:25:48 -04:00
parent f401a9ea0e
commit 2a7dee4afa
14 changed files with 909 additions and 1 deletions
@@ -241,6 +241,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// an inline banner.
HandleBrowse(browse);
break;
case ReadTagValuesCommand read:
// Same rule as browse — never stash; adapter is not yet
// connected, so HandleReadTagValues short-circuits to
// ConnectionNotConnected.
HandleReadTagValues(read);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -304,6 +310,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case BrowseOpcUaNodeCommand browse:
HandleBrowse(browse);
break;
case ReadTagValuesCommand read:
HandleReadTagValues(read);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -429,6 +438,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
HandleBrowse(browse);
break;
case ReadTagValuesCommand read:
// Same rule as browse — never stashed; while reconnecting the
// adapter is not Connected so HandleReadTagValues short-circuits
// to a ConnectionNotConnected failure.
HandleReadTagValues(read);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -1030,6 +1045,100 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}).PipeTo(sender);
}
// ── Test Bindings (one-shot live read of bound tags) ──
/// <summary>
/// Handles a <see cref="ReadTagValuesCommand"/> forwarded by the
/// <see cref="DataConnectionManagerActor"/>. Short-circuits to a
/// <see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> failure
/// when the adapter is not currently Connected (Connecting / Reconnecting
/// states) so the dialog can render an inline banner without waiting for
/// the adapter to fail per-tag with a generic "client is not connected"
/// message. Otherwise calls <c>_adapter.ReadBatchAsync</c> and maps the
/// resulting per-tag <see cref="ReadResult"/> map onto a list of
/// <see cref="TagReadOutcome"/> (preserving every requested tag — missing
/// adapter entries become failure outcomes).
///
/// Failure mapping mirrors <see cref="HandleBrowse"/>:
/// <list type="bullet">
/// <item><see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> — adapter status is not <see cref="ConnectionHealth.Connected"/>.</item>
/// <item><see cref="ReadTagValuesFailureKind.Timeout"/> — batch cancelled (<see cref="OperationCanceledException"/>).</item>
/// <item><see cref="ReadTagValuesFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
/// </list>
///
/// The reply is sent via <c>PipeTo(sender)</c> — same pattern as
/// <see cref="HandleWrite"/> and <see cref="HandleBrowse"/> — so the
/// captured <see cref="Sender"/> is safe to use from the continuation.
/// </summary>
private void HandleReadTagValues(ReadTagValuesCommand command)
{
var sender = Sender;
if (_adapter.Status != ConnectionHealth.Connected)
{
_log.Debug("[{0}] Test-bindings read requested but adapter status is {1}", _connectionName, _adapter.Status);
sender.Tell(new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
new ReadTagValuesFailure(
ReadTagValuesFailureKind.ConnectionNotConnected,
"Connection is not yet established.")));
return;
}
_log.Debug("[{0}] Test-bindings read of {1} tag(s)", _connectionName, command.TagPaths.Count);
var tagPaths = command.TagPaths.ToList();
_adapter.ReadBatchAsync(tagPaths).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
var nowUtc = DateTimeOffset.UtcNow;
var outcomes = new List<TagReadOutcome>(tagPaths.Count);
foreach (var tagPath in tagPaths)
{
if (t.Result.TryGetValue(tagPath, out var result) && result.Success && result.Value is not null)
{
outcomes.Add(new TagReadOutcome(
tagPath,
Success: true,
Value: result.Value.Value,
Quality: result.Value.Quality.ToString(),
Timestamp: result.Value.Timestamp,
ErrorMessage: null));
}
else
{
var errMsg = result?.ErrorMessage
?? (t.Result.ContainsKey(tagPath)
? "Read returned no value."
: "Tag missing from adapter result.");
outcomes.Add(new TagReadOutcome(
tagPath,
Success: false,
Value: null,
Quality: "Bad",
Timestamp: nowUtc,
ErrorMessage: errMsg));
}
}
return new ReadTagValuesResult(outcomes, Failure: null);
}
var baseEx = t.Exception?.GetBaseException();
return baseEx switch
{
OperationCanceledException => new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "Read cancelled.")),
_ => new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
new ReadTagValuesFailure(
ReadTagValuesFailureKind.ServerError,
baseEx?.Message ?? "Unknown read error.")),
};
}).PipeTo(sender);
}
// ── Tag Resolution Retry (WP-12) ──
private void HandleRetryTagResolution()
@@ -47,6 +47,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -140,6 +141,33 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
/// <summary>
/// Routes a <see cref="ReadTagValuesCommand"/> from the CentralUI's Test
/// Bindings dialog to the child <see cref="DataConnectionActor"/> that
/// owns the named connection. Same split as <see cref="HandleBrowse"/> —
/// the manager owns
/// <see cref="ReadTagValuesFailureKind.ConnectionNotFound"/> because it is
/// the only actor with site-level visibility; every other failure
/// (not connected, server error, timeout) is resolved by the child where
/// the adapter is held.
/// </summary>
private void HandleReadTagValues(ReadTagValuesCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
actor.Forward(command);
}
else
{
_log.Warning("No connection actor for {0} during test-bindings read", command.ConnectionName);
Sender.Tell(new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
new ReadTagValuesFailure(
ReadTagValuesFailureKind.ConnectionNotFound,
$"No data connection named '{command.ConnectionName}' at this site.")));
}
}
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))