fix(dcl+centralui): MxGateway tag browse — lazy attributes, frame-size cap, wider scrollable picker

Expanding a Galaxy object in the tag picker hung on "loading…": the browse
reply inlined every child's full attribute set (~152 KB), exceeding Akka's
128 KB remote frame, and remoting silently discarded the oversized reply.

Browse path (DataConnectionLayer):
- RealMxGatewayClient: navigation now uses BrowseChildren(include_attributes=
  false) — child objects only — and an object's own attributes load lazily via
  DiscoverHierarchy(root, max_depth=0) when it's expanded. Payload drops from
  ~152 KB/level to a few KB. Seam contract unchanged.
- DataConnectionActor.CapBrowseChildren: protocol-agnostic byte-budget cap
  (~100 KB) on every BrowseNodeResult before it crosses the site→central
  frame, OR-ing the adapter's own Truncated flag. Byte budget, not a count —
  the only bound that holds regardless of NodeId/attribute-name length.
- RealOpcUaClient: requestedMaxReferencesPerNode 1000 → 500 to narrow the
  window before the byte budget applies.
- Graceful gRPC Unimplemented handling → NotSupportedException →
  BrowseFailureKind.NotBrowsable with an actionable message (older gateway
  builds lacking BrowseChildren).

Picker UI (CentralUI):
- NodeBrowserDialog: modal-lg → modal-xl; new scoped .razor.css caps the tree
  at 55vh with its own scrollbar so manual entry + Select/Cancel stay visible.
- Protocol-agnostic failure messages (was hardcoded "OPC UA …"); renamed the
  leftover opcua-browser-tree class to node-browser-tree.

Tests: new frame-budget cap test + NotSupported=>NotBrowsable mapping test;
DCL suite 88/88. Doc: Component-DataConnectionLayer.md records the lazy
attribute-light browse and the frame-size guard.
This commit is contained in:
Joseph Doherty
2026-05-29 09:53:19 -04:00
parent 0434fcee00
commit 4b6ff49822
7 changed files with 236 additions and 25 deletions
@@ -162,4 +162,95 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
Assert.Empty(reply.Children);
}
[Fact]
public void NotSupportedException_maps_to_NotBrowsable_carrying_message()
{
// A browsable adapter that is connected but whose server/protocol cannot
// browse (e.g. an MxGateway build predating the BrowseChildren RPC, which
// answers gRPC Unimplemented) throws NotSupportedException. The actor must
// surface this as NotBrowsable with the adapter's actionable message
// carried verbatim — distinct from the capability-check NotBrowsable
// (non-browsable adapter), which has no message.
const string reason =
"The connected MxGateway build does not support hierarchy browsing. "
+ "Update the gateway to a build that implements BrowseChildren, "
+ "or enter the tag reference manually.";
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));
_factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
.Returns((IDataConnection)adapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
manager.Tell(new CreateConnectionCommand(
"conn-old-gw", "MxGateway", new Dictionary<string, string>(), null, 3));
AwaitCondition(
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
manager.Tell(new BrowseNodeCommand("conn-old-gw", ParentNodeId: null));
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
Assert.Equal(reason, reply.Failure.Message);
Assert.Empty(reply.Children);
}
[Fact]
public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated()
{
// A level large enough to exceed Akka's 128 KB remote frame must be clipped
// by the actor BEFORE it crosses the site→central boundary — otherwise the
// reply is silently discarded and the picker hangs. This guards every
// protocol's reply, regardless of how the adapter paginates upstream. The
// adapter here reports Truncated=false; the actor must still truncate purely
// on serialized size.
const int byteBudget = 100 * 1024; // mirrors DataConnectionActor.BrowseResultByteBudget
var bigList = Enumerable.Range(0, 3000)
.Select(i => new BrowseNode($"ns=2;s=Item{i:D5}", $"Item{i:D5}", BrowseNodeClass.Variable, HasChildren: false))
.ToArray();
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
.Returns(new BrowseChildrenResult(bigList, Truncated: false));
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
.Returns((IDataConnection)adapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
manager.Tell(new CreateConnectionCommand(
"conn-big", "OpcUa", new Dictionary<string, string>(), null, 3));
AwaitCondition(
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
manager.Tell(new BrowseNodeCommand("conn-big", ParentNodeId: null));
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.Null(reply.Failure);
Assert.True(reply.Truncated, "an oversized level must be reported as truncated");
Assert.True(reply.Children.Count > 0, "the cap must still return a usable prefix");
Assert.True(reply.Children.Count < bigList.Length, "the level must actually be clipped");
// The kept prefix's estimated serialized size must respect the budget.
var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length);
Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}");
}
}