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:
@@ -989,7 +989,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
///
|
||||
/// Failure mapping:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BrowseFailureKind.NotBrowsable"/> — adapter is not <see cref="IBrowsableDataConnection"/>.</item>
|
||||
/// <item><see cref="BrowseFailureKind.NotBrowsable"/> — adapter is not <see cref="IBrowsableDataConnection"/>, or it threw <see cref="NotSupportedException"/> (browsable adapter, but the server/protocol cannot browse — e.g. a gateway build predating the browse RPC); message carried verbatim in the latter case.</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>
|
||||
@@ -1015,13 +1015,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Debug("[{0}] Browsing OPC UA children of {1}", _connectionName, command.ParentNodeId ?? "(root)");
|
||||
_log.Debug("[{0}] Browsing children of {1}", _connectionName, command.ParentNodeId ?? "(root)");
|
||||
|
||||
browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
return new BrowseNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
|
||||
// Bound the reply to stay under Akka's remote frame size before it
|
||||
// crosses the site→central boundary (see CapBrowseChildren).
|
||||
var (children, truncated) = CapBrowseChildren(t.Result.Children, t.Result.Truncated);
|
||||
return new BrowseNodeResult(children, truncated, Failure: null);
|
||||
}
|
||||
|
||||
var baseEx = t.Exception?.GetBaseException();
|
||||
@@ -1035,6 +1038,13 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
|
||||
// Adapter reachable but the protocol/server cannot browse (e.g. an
|
||||
// MxGateway build that predates the BrowseChildren RPC). Carry the
|
||||
// adapter's explanatory message through as NotBrowsable.
|
||||
NotSupportedException notSupported => new BrowseNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.NotBrowsable, notSupported.Message)),
|
||||
_ => new BrowseNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
@@ -1045,6 +1055,47 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
}).PipeTo(sender);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimated-byte ceiling for a single <see cref="BrowseNodeResult"/>, kept
|
||||
/// comfortably below Akka's default 128 KB remote frame size. A browse reply
|
||||
/// crosses the site→central frame on a temp Ask actor; an oversized reply is
|
||||
/// silently discarded by remoting (the picker then hangs on "loading…"). The
|
||||
/// limit is a byte budget rather than a child count because the only thing
|
||||
/// that actually consumes frame space is serialized size — OPC UA NodeIds and
|
||||
/// MxGateway tag references vary widely in length, so a fixed count is not a
|
||||
/// safe proxy.
|
||||
/// </summary>
|
||||
private const int BrowseResultByteBudget = 100 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Truncates a browse child list to <see cref="BrowseResultByteBudget"/> using
|
||||
/// a conservative per-node size estimate (JSON structural overhead plus the two
|
||||
/// variable-length strings — ASCII NodeId/DisplayName ≈ 1 byte/char). Returns
|
||||
/// the kept prefix and a <c>Truncated</c> flag OR-ed with the adapter's own
|
||||
/// truncation signal, so the picker shows its "use manual entry" hint when the
|
||||
/// level is clipped. Protocol-agnostic: every adapter's reply funnels through
|
||||
/// here regardless of how it paginates upstream.
|
||||
/// </summary>
|
||||
private static (IReadOnlyList<BrowseNode> Children, bool Truncated) CapBrowseChildren(
|
||||
IReadOnlyList<BrowseNode> children, bool truncated)
|
||||
{
|
||||
var budget = 0;
|
||||
var kept = new List<BrowseNode>(children.Count);
|
||||
foreach (var node in children)
|
||||
{
|
||||
budget += 64 + (node.NodeId?.Length ?? 0) + (node.DisplayName?.Length ?? 0);
|
||||
if (budget > BrowseResultByteBudget)
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
kept.Add(node);
|
||||
}
|
||||
|
||||
return (kept, truncated);
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -138,39 +138,83 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
|
||||
{
|
||||
var request = new BrowseChildrenRequest { IncludeAttributes = true };
|
||||
// Navigation browse returns child OBJECTS only (IncludeAttributes = false).
|
||||
// An object's own attributes are loaded lazily — via DiscoverHierarchy below,
|
||||
// scoped to that single object — only when the object is expanded. This keeps
|
||||
// each browse level's payload to a few KB. Inlining every child's full
|
||||
// attribute set (the previous behaviour) produced replies that exceeded
|
||||
// Akka's remote frame size (e.g. ~152 KB for one attribute-heavy area), and
|
||||
// remoting silently discarded the oversized reply, hanging the picker.
|
||||
var request = new BrowseChildrenRequest { IncludeAttributes = false };
|
||||
|
||||
// Object NodeIds are the Galaxy gobject id (encoded as a string); attribute
|
||||
// NodeIds are FullTagReference leaves and never arrive here as a parent.
|
||||
if (!string.IsNullOrEmpty(parentNodeId)
|
||||
&& int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var gobjectId))
|
||||
var parentGobjectId = 0;
|
||||
var haveParentObject = !string.IsNullOrEmpty(parentNodeId)
|
||||
&& int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out parentGobjectId);
|
||||
if (haveParentObject)
|
||||
{
|
||||
request.ParentGobjectId = gobjectId;
|
||||
request.ParentGobjectId = parentGobjectId;
|
||||
}
|
||||
|
||||
BrowseChildrenReply reply;
|
||||
GalaxyObject? parentObject = null;
|
||||
try
|
||||
{
|
||||
reply = await _galaxy!.BrowseChildrenRawAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
// When expanding a concrete object, fetch just that object (MaxDepth = 0)
|
||||
// with its attributes so they can be listed as selectable leaves. The root
|
||||
// level has no parent object, hence no attributes of its own.
|
||||
if (haveParentObject)
|
||||
{
|
||||
var attrRequest = new DiscoverHierarchyRequest
|
||||
{
|
||||
RootGobjectId = parentGobjectId,
|
||||
MaxDepth = 0,
|
||||
IncludeAttributes = true,
|
||||
};
|
||||
var attrReply = await _galaxy!.DiscoverHierarchyRawAsync(attrRequest, ct).ConfigureAwait(false);
|
||||
parentObject = attrReply.Objects.Count > 0 ? attrReply.Objects[0] : null;
|
||||
}
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
|
||||
{
|
||||
throw new ConnectionNotConnectedException($"MxGateway repository unavailable: {ex.Status.Detail}");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unimplemented)
|
||||
{
|
||||
// The data pipe (read/subscribe/write) works against every gateway
|
||||
// build, but Galaxy hierarchy browsing (the BrowseChildren RPC) was
|
||||
// added later. An older gateway answers Unimplemented. Surface a
|
||||
// clear, actionable reason instead of a raw gRPC fault — the actor
|
||||
// maps NotSupportedException to BrowseFailureKind.NotBrowsable and
|
||||
// carries this message through to the picker.
|
||||
throw new NotSupportedException(
|
||||
"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 children = new List<MxBrowseChild>();
|
||||
for (var i = 0; i < reply.Children.Count; i++)
|
||||
|
||||
// Navigable child objects, keyed by gobject id. Always marked expandable —
|
||||
// every Galaxy object carries attributes (and may host sub-objects), both
|
||||
// resolved on demand when the node is expanded.
|
||||
foreach (var obj in reply.Children)
|
||||
{
|
||||
var obj = reply.Children[i];
|
||||
var hasChildren = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||
// Navigable container node, keyed by gobject id.
|
||||
children.Add(new MxBrowseChild(
|
||||
obj.GobjectId.ToString(CultureInfo.InvariantCulture),
|
||||
string.IsNullOrEmpty(obj.TagName) ? obj.ContainedName : obj.TagName,
|
||||
BrowseNodeClass.Object,
|
||||
hasChildren || obj.Attributes.Count > 0));
|
||||
true));
|
||||
}
|
||||
|
||||
// Selectable attribute leaves, keyed by their full tag reference.
|
||||
foreach (var attr in obj.Attributes)
|
||||
// The expanded object's own attributes, as selectable leaves keyed by their
|
||||
// full tag reference.
|
||||
if (parentObject is not null)
|
||||
{
|
||||
foreach (var attr in parentObject.Attributes)
|
||||
{
|
||||
children.Add(new MxBrowseChild(
|
||||
attr.FullTagReference,
|
||||
|
||||
@@ -353,11 +353,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// Variables (selectable), Methods (display-only).
|
||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
||||
// Truncated, prompting manual entry rather than auto-paging.
|
||||
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeToBrowse,
|
||||
1000u,
|
||||
500u,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
|
||||
Reference in New Issue
Block a user