feat(dcl+ui): rename BrowseOpcUaNode -> ConnectionName-keyed; implement site handler + dialog failure mapping

- BrowseOpcUaNodeCommand: int DataConnectionId -> string ConnectionName
  (site DataConnectionManagerActor indexes children by name; CentralUI
  already has the connection name in scope via the dropdown — no extra
  plumbing across the trust boundary).
- IOpcUaBrowseService / OpcUaBrowseService: parameter renamed accordingly.
- OpcUaBrowserDialog: collapse the duplicate ConnectionName parameters
  (display label and routing key are the same string).
- Task 10: DataConnectionManagerActor forwards BrowseOpcUaNodeCommand to
  its child by name (owns ConnectionNotFound); DataConnectionActor adds
  the receive across all three lifecycle states (Connecting / Connected
  / Reconnecting) and maps adapter outcomes to BrowseFailureKind
  (NotBrowsable / ConnectionNotConnected / Timeout / ServerError).
- Task 17: SetFailure in OpcUaBrowserDialog implements the full
  BrowseFailureKind switch with friendly UI messages.
- Tests: DataConnectionManagerBrowseHandlerTests covers ConnectionNotFound,
  NotBrowsable, success, and ConnectionNotConnectedException paths.
This commit is contained in:
Joseph Doherty
2026-05-28 12:09:43 -04:00
parent 6999aedc60
commit d285174597
7 changed files with 313 additions and 13 deletions
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -233,6 +234,13 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case BrowseOpcUaNodeCommand browse:
// Browse is an interactive design-time query; never stash. The
// adapter has no session yet in this state, so reply with a
// typed ConnectionNotConnected failure so the dialog can render
// an inline banner.
HandleBrowse(browse);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -293,6 +301,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case RetryTagResolution:
HandleRetryTagResolution();
break;
case BrowseOpcUaNodeCommand browse:
HandleBrowse(browse);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -412,6 +423,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case BrowseOpcUaNodeCommand browse:
// Browse is design-time and never stashed. While reconnecting
// the adapter has no live session, so the adapter call will
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
HandleBrowse(browse);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -947,6 +964,72 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}).PipeTo(sender);
}
// ── OPC UA Tag Browser (interactive design-time query) ──
/// <summary>
/// Handles a <see cref="BrowseOpcUaNodeCommand"/> forwarded by the
/// <see cref="DataConnectionManagerActor"/>. The capability check (does
/// this adapter support browsing?) and all browse-failure mapping live
/// here because the adapter is held by this actor, not the manager.
///
/// Failure mapping:
/// <list type="bullet">
/// <item><see cref="BrowseFailureKind.NotBrowsable"/> — adapter is not <see cref="IBrowsableDataConnection"/>.</item>
/// <item><see cref="BrowseFailureKind.ConnectionNotConnected"/> — adapter threw <see cref="ConnectionNotConnectedException"/>.</item>
/// <item><see cref="BrowseFailureKind.Timeout"/> — adapter threw <see cref="OperationCanceledException"/>.</item>
/// <item><see cref="BrowseFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
/// </list>
///
/// The reply is sent via <c>PipeTo(sender)</c> — the same pattern used by
/// <see cref="HandleWrite"/> — so the captured <see cref="Sender"/> is
/// safe to use from the continuation (which runs off the actor thread).
/// </summary>
private void HandleBrowse(BrowseOpcUaNodeCommand command)
{
var sender = Sender;
if (_adapter is not IBrowsableDataConnection browsable)
{
_log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName);
sender.Tell(new BrowseOpcUaNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
BrowseFailureKind.NotBrowsable,
$"Connection '{_connectionName}' does not support browsing.")));
return;
}
_log.Debug("[{0}] Browsing OPC UA children of {1}", _connectionName, command.ParentNodeId ?? "(root)");
browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
}
var baseEx = t.Exception?.GetBaseException();
return baseEx switch
{
ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
OperationCanceledException => new BrowseOpcUaNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
_ => new BrowseOpcUaNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
BrowseFailureKind.ServerError,
baseEx?.Message ?? "Unknown browse error.")),
};
}).PipeTo(sender);
}
// ── Tag Resolution Retry (WP-12) ──
private void HandleRetryTagResolution()
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -45,6 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<WriteTagRequest>(HandleRouteWrite);
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -111,6 +113,33 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
/// <summary>
/// Routes a <see cref="BrowseOpcUaNodeCommand"/> from the central UI's OPC UA
/// Tag Browser to the child <see cref="DataConnectionActor"/> that owns the
/// named connection. The manager is the only actor that knows whether a
/// connection exists at this site — so it owns the
/// <see cref="BrowseFailureKind.ConnectionNotFound"/> failure. Everything
/// else (capability check, session state, server errors) lives inside the
/// child where the adapter is held.
/// </summary>
private void HandleBrowse(BrowseOpcUaNodeCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
actor.Forward(command);
}
else
{
_log.Warning("No connection actor for {0} during browse", command.ConnectionName);
Sender.Tell(new BrowseOpcUaNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
BrowseFailureKind.ConnectionNotFound,
$"No data connection named '{command.ConnectionName}' at this site.")));
}
}
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))