refactor(browse): rename BrowseOpcUaNode* to protocol-agnostic BrowseNode*

Renames BrowseOpcUaNodeCommand/Result -> BrowseNodeCommand/Result and
CommunicationService.BrowseOpcUaNodeAsync -> BrowseNodeAsync across Commons,
Communication, SiteRuntime, DCL actors, and CentralUI. Wire manifest name
follows (BrowseOpcUaNode -> BrowseNode). Browse regression tests green.
This commit is contained in:
Joseph Doherty
2026-05-29 07:57:36 -04:00
parent 20c24ef260
commit 9b7916bb2e
14 changed files with 52 additions and 53 deletions
-1
View File
@@ -21,7 +21,6 @@ This document serves as the master index for the SCADA system design. The system
### Scale
- ~10 site clusters, each with 50500 machines, 2575 live tags per machine.
- Central cluster: 2-node active/standby behind a load balancer.
- Site clusters: 2-node active/standby, headless (no UI).
@@ -1,11 +1,11 @@
{
"planPath": "docs/plans/2026-05-28-mxgateway-data-connection.md",
"tasks": [
{"id": 6, "planTask": 1, "subject": "Task 1: Packaging foundation (Gitea feed + package refs)", "status": "pending"},
{"id": 7, "planTask": 2, "subject": "Task 2: MxGatewayEndpointConfig type", "status": "pending"},
{"id": 6, "planTask": 1, "subject": "Task 1: Packaging foundation (Gitea feed + package refs)", "status": "completed"},
{"id": 7, "planTask": 2, "subject": "Task 2: MxGatewayEndpointConfig type", "status": "completed"},
{"id": 8, "planTask": 3, "subject": "Task 3: MxGatewayEndpointConfigSerializer + tests", "status": "pending", "blockedBy": [7]},
{"id": 9, "planTask": 4, "subject": "Task 4: MxGatewayEndpointConfigValidator + tests", "status": "pending", "blockedBy": [7]},
{"id": 10, "planTask": 5, "subject": "Task 5: Client seam interfaces + MxGatewayGlobalOptions", "status": "pending"},
{"id": 10, "planTask": 5, "subject": "Task 5: Client seam interfaces + MxGatewayGlobalOptions", "status": "completed"},
{"id": 11, "planTask": 6, "subject": "Task 6: Adapter connect/disconnect/Disconnected + value mapping", "status": "pending", "blockedBy": [7, 10]},
{"id": 12, "planTask": 7, "subject": "Task 7: Adapter subscribe/unsubscribe + event routing", "status": "pending", "blockedBy": [11]},
{"id": 13, "planTask": 8, "subject": "Task 8: Adapter read/write batch + error classification", "status": "pending", "blockedBy": [11]},
@@ -50,7 +50,7 @@ public static class ServiceCollectionExtensions
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync
// that enforces the CentralUI-side Design-role trust boundary and translates
// transport failures into typed BrowseFailure results for the dialog.
services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>();
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// CentralUI facade over the central-to-site OPC UA browse command. Backs the
/// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls
/// <see cref="BrowseChildrenAsync"/>, which forwards a
/// <see cref="BrowseOpcUaNodeCommand"/> to the owning site via
/// <see cref="BrowseNodeCommand"/> to the owning site via
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>.
/// </summary>
/// <remarks>
@@ -29,7 +29,7 @@ public interface IOpcUaBrowseService
/// <param name="connectionName">Name of the site-local data connection to browse against — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param>
/// <param name="parentNodeId">Node to browse, or <c>null</c> to browse from the server root.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
Task<BrowseNodeResult> BrowseChildrenAsync(
string siteId,
string connectionName,
string? parentNodeId,
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IOpcUaBrowseService"/> implementation — a thin facade over
/// <see cref="CommunicationService.BrowseOpcUaNodeAsync"/> that enforces the
/// <see cref="CommunicationService.BrowseNodeAsync"/> that enforces the
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport
/// exceptions into a typed <see cref="BrowseFailure"/> result.
/// </summary>
@@ -36,7 +36,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
}
/// <inheritdoc/>
public async Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
public async Task<BrowseNodeResult> BrowseChildrenAsync(
string siteId,
string connectionName,
string? parentNodeId,
@@ -47,7 +47,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
{
return new BrowseOpcUaNodeResult(
return new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
@@ -55,9 +55,9 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
try
{
return await _communication.BrowseOpcUaNodeAsync(
return await _communication.BrowseNodeAsync(
siteId,
new BrowseOpcUaNodeCommand(connectionName, parentNodeId),
new BrowseNodeCommand(connectionName, parentNodeId),
cancellationToken);
}
catch (TimeoutException ex)
@@ -65,7 +65,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
// Akka Ask timed out — the site (or its OPC UA session) didn't answer
// within CommunicationOptions.QueryTimeout. Surface as a typed
// Timeout failure so the dialog can render an inline banner.
return new BrowseOpcUaNodeResult(
return new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
@@ -80,7 +80,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
{
// Any other transport / serialization failure: keep the dialog
// alive and let the user fall back to manual node-id paste.
return new BrowseOpcUaNodeResult(
return new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
@@ -15,11 +15,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// </remarks>
/// <param name="ConnectionName">Name of the site-local data connection to browse against.</param>
/// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param>
public record BrowseOpcUaNodeCommand(
public record BrowseNodeCommand(
string ConnectionName,
string? ParentNodeId);
public record BrowseOpcUaNodeResult(
public record BrowseNodeResult(
IReadOnlyList<BrowseNode> Children,
bool Truncated,
BrowseFailure? Failure);
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// </summary>
/// <remarks>
/// Keyed by <see cref="ConnectionName"/> (not id) for the same reason as
/// <see cref="BrowseOpcUaNodeCommand"/>: the site-side
/// <see cref="BrowseNodeCommand"/>: the site-side
/// <c>DataConnectionManagerActor</c> indexes its children by connection name,
/// and the central UI already has the connection name in scope from the
/// bindings table. The central <c>DataConnections</c> table's id is not
@@ -152,10 +152,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// DataConnectionActor children (which own the live OPC UA sessions)
// only exist on the singleton's node. The singleton then re-forwards
// to its own /user/dcl-manager, which DOES have the connection.
Receive<BrowseOpcUaNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
Receive<BrowseNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
// Test Bindings (interactive design-time read) — same routing rationale
// as BrowseOpcUaNodeCommand above: the singleton always lands on the
// as BrowseNodeCommand above: the singleton always lands on the
// active site node, which is the node that owns the DataConnectionActor
// children holding the live OPC UA sessions.
Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
@@ -360,13 +360,13 @@ public class CommunicationService
/// <param name="command">The OPC UA browse command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The browse result (children + truncation flag + structured failure).</returns>
public Task<BrowseOpcUaNodeResult> BrowseOpcUaNodeAsync(
public Task<BrowseNodeResult> BrowseNodeAsync(
string siteId,
BrowseOpcUaNodeCommand command,
BrowseNodeCommand command,
CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return GetActor().Ask<BrowseOpcUaNodeResult>(
return GetActor().Ask<BrowseNodeResult>(
envelope, _options.QueryTimeout, cancellationToken);
}
@@ -377,7 +377,7 @@ public class CommunicationService
/// server backing the given data connection. Used by the CentralUI "Test
/// Bindings" dialog on the Configure Instance page. The Ask is bounded by
/// <see cref="CommunicationOptions.QueryTimeout"/> — same latency budget
/// as <see cref="BrowseOpcUaNodeAsync"/> (both are interactive one-shot
/// as <see cref="BrowseNodeAsync"/> (both are interactive one-shot
/// design-time queries).
/// </summary>
/// <param name="siteId">The target site identifier.</param>
@@ -234,7 +234,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case BrowseOpcUaNodeCommand browse:
case BrowseNodeCommand 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
@@ -307,7 +307,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case RetryTagResolution:
HandleRetryTagResolution();
break;
case BrowseOpcUaNodeCommand browse:
case BrowseNodeCommand browse:
HandleBrowse(browse);
break;
case ReadTagValuesCommand read:
@@ -432,7 +432,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case BrowseOpcUaNodeCommand browse:
case BrowseNodeCommand 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.
@@ -982,7 +982,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ── OPC UA Tag Browser (interactive design-time query) ──
/// <summary>
/// Handles a <see cref="BrowseOpcUaNodeCommand"/> forwarded by the
/// Handles a <see cref="BrowseNodeCommand"/> 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.
@@ -999,14 +999,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
/// <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)
private void HandleBrowse(BrowseNodeCommand 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(
sender.Tell(new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
@@ -1021,21 +1021,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
if (t.IsCompletedSuccessfully)
{
return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
return new BrowseNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
}
var baseEx = t.Exception?.GetBaseException();
return baseEx switch
{
ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult(
ConnectionNotConnectedException notConnected => new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
OperationCanceledException => new BrowseOpcUaNodeResult(
OperationCanceledException => new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
_ => new BrowseOpcUaNodeResult(
_ => new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
@@ -46,7 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<WriteTagRequest>(HandleRouteWrite);
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
Receive<BrowseNodeCommand>(HandleBrowse);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
}
@@ -115,7 +115,7 @@ public class DataConnectionManagerActor : ReceiveActor
}
/// <summary>
/// Routes a <see cref="BrowseOpcUaNodeCommand"/> from the central UI's OPC UA
/// Routes a <see cref="BrowseNodeCommand"/> 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
@@ -123,7 +123,7 @@ public class DataConnectionManagerActor : ReceiveActor
/// else (capability check, session state, server errors) lives inside the
/// child where the adapter is held.
/// </summary>
private void HandleBrowse(BrowseOpcUaNodeCommand command)
private void HandleBrowse(BrowseNodeCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
@@ -132,7 +132,7 @@ public class DataConnectionManagerActor : ReceiveActor
else
{
_log.Warning("No connection actor for {0} during browse", command.ConnectionName);
Sender.Tell(new BrowseOpcUaNodeResult(
Sender.Tell(new BrowseNodeResult(
Array.Empty<BrowseNode>(),
Truncated: false,
new BrowseFailure(
@@ -149,12 +149,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
// OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager.
// BrowseOpcUaNodeCommand is routed to this singleton (active node) by
// BrowseNodeCommand is routed to this singleton (active node) by
// SiteCommunicationActor so the dcl-manager we forward to is guaranteed
// to be the one holding the live DataConnectionActor children. ActorSelection
// has no Forward() extension in this Akka.NET version, so we Tell with the
// original Sender preserved (semantically identical to Forward).
Receive<BrowseOpcUaNodeCommand>(msg =>
Receive<BrowseNodeCommand>(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// Test Bindings — same singleton-only re-forward as the browse handler
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Verifies that <see cref="BrowseOpcUaNodeCommand"/> is discovered by
/// Verifies that <see cref="BrowseNodeCommand"/> is discovered by
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
/// boundary as a known command (resolvable by wire name and round-trippable
/// through <c>GetCommandName</c> / <c>Resolve</c>).
@@ -11,13 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class BrowseCommandsRegistryTests
{
[Fact]
public void Registry_discovers_BrowseOpcUaNodeCommand()
public void Registry_discovers_BrowseNodeCommand()
{
// GetCommandName throws ArgumentException for any type the registry
// does not contain, so a successful call here is proof of discovery.
var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseOpcUaNodeCommand));
var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseNodeCommand));
Assert.Equal("BrowseOpcUaNode", name);
Assert.Equal(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name));
Assert.Equal("BrowseNode", name);
Assert.Equal(typeof(BrowseNodeCommand), ManagementCommandRegistry.Resolve(name));
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// Task 10 (opcua-tag-browser): the site-side
/// <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve
/// <see cref="BrowseOpcUaNodeCommand"/> against the live adapter and surface
/// <see cref="BrowseNodeCommand"/> against the live adapter and surface
/// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is:
/// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it
/// knows the per-site connection set); everything else lives in the child where
@@ -50,9 +50,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
// No CreateConnectionCommand sent — the manager has zero children, so a
// browse against any name must be rejected with ConnectionNotFound
// (the manager is the only actor with site-level visibility).
manager.Tell(new BrowseOpcUaNodeCommand("unknown-connection", ParentNodeId: null));
manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>();
var reply = ExpectMsg<BrowseNodeResult>();
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
Assert.Empty(reply.Children);
@@ -80,9 +80,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null));
manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
Assert.Empty(reply.Children);
@@ -120,9 +120,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null));
manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.Null(reply.Failure);
Assert.Equal(2, reply.Children.Count);
Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
@@ -155,9 +155,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null));
manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
Assert.Empty(reply.Children);