diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs index 968df7f6..a5903127 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs @@ -40,6 +40,7 @@ public sealed class BrowseService : IBrowseService string siteId, string connectionName, string? parentNodeId, + string? continuationToken = null, CancellationToken cancellationToken = default) { // CentralUI-side role guard — sites don't enforce envelope-level roles, @@ -57,7 +58,7 @@ public sealed class BrowseService : IBrowseService { return await _communication.BrowseNodeAsync( siteId, - new BrowseNodeCommand(connectionName, parentNodeId), + new BrowseNodeCommand(connectionName, parentNodeId, continuationToken), cancellationToken); } catch (TimeoutException ex) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs index 8e45feba..8597fc49 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs @@ -28,11 +28,13 @@ public interface IBrowseService /// The target site identifier. /// Name of the site-local data connection to browse against — the site's DataConnectionManagerActor indexes its children by name. /// Node to browse, or null to browse from the server root. + /// Opaque cursor from a prior to fetch the NEXT page under ; null for the first page. /// Cancellation token. /// A task that resolves to a containing child nodes or a on error. Task BrowseChildrenAsync( string siteId, string connectionName, string? parentNodeId, + string? continuationToken = null, CancellationToken cancellationToken = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs index c72c404f..999585fc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -15,14 +15,30 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// /// Name of the site-local data connection to browse against. /// Node to browse, or null to browse from the server root (ObjectsFolder). +/// +/// Opaque adapter cursor for fetching the NEXT page of children under +/// . Null on the first request; on a subsequent +/// request carry back the token from the prior . +/// Additive (appended last) so positional construction stays source-compatible. +/// public record BrowseNodeCommand( string ConnectionName, - string? ParentNodeId); + string? ParentNodeId, + string? ContinuationToken = null); +/// Immediate children resolved for the browsed node (this page only). +/// True when the result was clipped (frame-budget cap or adapter-reported truncation). +/// Structured failure, or null on success. +/// +/// Opaque adapter cursor for the NEXT page when more children remain, or null +/// when this is the final page. Surface it back in a follow-up +/// . Additive (appended last). +/// public record BrowseNodeResult( IReadOnlyList Children, bool Truncated, - BrowseFailure? Failure); + BrowseFailure? Failure, + string? ContinuationToken = null); public record BrowseFailure( BrowseFailureKind Kind, diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 60ad161a..4b706960 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -1163,14 +1163,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers _log.Debug("[{0}] Browsing children of {1}", _connectionName, command.ParentNodeId ?? "(root)"); - browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t => + browsable.BrowseChildrenAsync(command.ParentNodeId, command.ContinuationToken).ContinueWith(t => { if (t.IsCompletedSuccessfully) { // 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); + // Carry the adapter's continuation cursor through so the UI can ask + // for the next page (BrowseNext). Null when this is the final page. + return new BrowseNodeResult(children, truncated, Failure: null, t.Result.ContinuationToken); } var baseEx = t.Exception?.GetBaseException(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs index 34526b2d..af885f89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs @@ -253,4 +253,57 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length); Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}"); } + + [Fact] + public void ContinuationToken_round_trips_through_the_browse_handler() + { + // Task B3 (T15): the BrowseNext continuation token must thread through the + // actor plumbing in both directions — the inbound command's token must reach + // the adapter, and the adapter's returned token must be surfaced on the reply + // so the UI can request the next page. We capture the token the adapter is + // called with and return a fresh one, then assert both halves. + const string inboundToken = "page-1-cursor"; + const string outboundToken = "page-2-cursor"; + string? receivedToken = "NOT-CALLED"; + + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + + var children = new[] + { + new BrowseNode("ns=2;s=Next", "Next", BrowseNodeClass.Variable, HasChildren: false), + }; + ((IBrowsableDataConnection)adapter) + .BrowseChildrenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + receivedToken = ci.ArgAt(1); + return new BrowseChildrenResult(children, Truncated: true, ContinuationToken: outboundToken); + }); + + _factory.Create("OpcUa", Arg.Any>()) + .Returns((IDataConnection)adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-paged", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new BrowseNodeCommand("conn-paged", ParentNodeId: "ns=2;s=Folder", ContinuationToken: inboundToken)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Null(reply.Failure); + // Inbound token reached the adapter verbatim. + Assert.Equal(inboundToken, receivedToken); + // Outbound token from the adapter is surfaced on the reply for the next page. + Assert.Equal(outboundToken, reply.ContinuationToken); + Assert.True(reply.Truncated); + Assert.Single(reply.Children); + } }