diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs index a5903127..536b4043 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs @@ -87,4 +87,59 @@ public sealed class BrowseService : IBrowseService new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); } } + + /// + public async Task SearchAsync( + string siteId, + string connectionName, + string query, + int maxDepth, + int maxResults, + CancellationToken cancellationToken = default) + { + // Same CentralUI-side role guard as BrowseChildrenAsync — sites don't + // enforce envelope-level roles, so the Designer check must happen here + // before any cross-cluster traffic. + var state = await _auth.GetAuthenticationStateAsync(); + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer)) + { + return new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized.")); + } + + try + { + return await _communication.SearchAddressSpaceAsync( + siteId, + new SearchAddressSpaceCommand(connectionName, query, maxDepth, maxResults), + cancellationToken); + } + catch (TimeoutException ex) + { + // 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 picker can render an inline banner. + return new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.Timeout, ex.Message)); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel — propagate so Blazor can drop the response + // cleanly. Distinct from Timeout (which the picker renders inline). + throw; + } + catch (Exception ex) + { + // Any other transport / serialization failure: keep the picker alive + // and let the user fall back to manual browse / node-id paste. + return new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); + } + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs index 8597fc49..c860a634 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs @@ -37,4 +37,31 @@ public interface IBrowseService string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default); + + /// + /// Runs a bounded recursive search of the address space on the live server + /// backing at , + /// returning the nodes whose DisplayName or root-relative path contains + /// . The address-space analogue of + /// : it forwards a + /// to the owning site via + /// and + /// enforces the same Design-role trust boundary at central. The depth + /// and result caps are supplied by the caller (the picker's search box) and + /// chosen to stay well under Akka's remote frame size. + /// + /// The target site identifier. + /// Name of the site-local data connection to search against. + /// Case-insensitive substring matched against each node's DisplayName and root-relative path. + /// Maximum number of levels below the root to descend. + /// Maximum number of matches to return; when reached the walk stops early and the result's CapReached flag is set. + /// Cancellation token. + /// A task that resolves to a containing the matches or a on error. + Task SearchAsync( + string siteId, + string connectionName, + string query, + int maxDepth, + int maxResults, + 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 999585fc..58abe95c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -40,6 +40,39 @@ public record BrowseNodeResult( BrowseFailure? Failure, string? ContinuationToken = null); +/// +/// Sent from CentralUI to a specific site to run a bounded recursive search of +/// the address space on the live server backing the given data connection. +/// +/// +/// The address-space analogue of : where browse +/// walks one level at a time on user demand, search walks the tree itself +/// (bounded by depth + result caps) and returns the nodes whose DisplayName or +/// root-relative path contains . Keyed by +/// for the same reason as browse — the site-side +/// DataConnectionManagerActor indexes its children by connection name and +/// the central DataConnections id is intentionally not exposed at the +/// site. Routed over the same cross-cluster path as browse and resolved by the +/// owning connection's IAddressSpaceSearchable adapter. +/// +/// Name of the site-local data connection to search against. +/// Case-insensitive substring matched against each node's DisplayName and root-relative path. +/// Maximum number of levels below the root to descend. Must be non-negative. +/// Maximum number of matches to return; when reached the walk stops early and is set. +public record SearchAddressSpaceCommand( + string ConnectionName, + string Query, + int MaxDepth, + int MaxResults); + +/// The matched address-space nodes, in breadth-first discovery order (capped at ). +/// True when a bound (result cap or the adapter's node-visit ceiling) cut the walk short, so more matches may exist than were returned. +/// Structured failure, or null on success. Reuses the browse kinds (the search path mirrors browse exactly). +public record SearchAddressSpaceResult( + IReadOnlyList Matches, + bool CapReached, + BrowseFailure? Failure); + public record BrowseFailure( BrowseFailureKind Kind, string Message); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 3b866e35..b80f627e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -371,6 +371,28 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + /// + /// Asks a site to run a bounded recursive search of the address space on the + /// live server backing the given data connection. The address-space analogue + /// of — used by the CentralUI OPC UA tag picker's + /// "find a tag" box. The Ask is bounded by , + /// the same latency budget as browse and the other interactive design-time + /// queries. + /// + /// The target site identifier. + /// The address-space search command (connection name + query + depth/result caps). + /// Cancellation token. + /// The search result (matches + cap-reached flag + structured failure). + public Task SearchAddressSpaceAsync( + string siteId, + SearchAddressSpaceCommand command, + CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, command); + return GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + // ── Test Bindings (one-shot live read of bound tags) ── /// diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 4b706960..a34c99d0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -269,6 +269,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // an inline banner. HandleBrowse(browse); break; + case SearchAddressSpaceCommand search: + // Search is the address-space analogue of browse — same rule: + // never stash; the adapter has no session yet here, so + // HandleSearch short-circuits to ConnectionNotConnected. + HandleSearch(search); + break; case ReadTagValuesCommand read: // Same rule as browse — never stash; adapter is not yet // connected, so HandleReadTagValues short-circuits to @@ -353,6 +359,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers case BrowseNodeCommand browse: HandleBrowse(browse); break; + case SearchAddressSpaceCommand search: + HandleSearch(search); + break; case ReadTagValuesCommand read: HandleReadTagValues(read); break; @@ -497,6 +506,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // throw ConnectionNotConnectedException — mapped by HandleBrowse. HandleBrowse(browse); break; + case SearchAddressSpaceCommand search: + // Same rule as browse — never stashed; while reconnecting the + // adapter is not Connected so the adapter call throws + // ConnectionNotConnectedException, mapped by HandleSearch. + HandleSearch(search); + break; case ReadTagValuesCommand read: // Same rule as browse — never stashed; while reconnecting the // adapter is not Connected so HandleReadTagValues short-circuits @@ -1203,6 +1218,83 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers }).PipeTo(sender); } + /// + /// Handles a forwarded by the + /// — the address-space analogue of + /// . The capability check (does this adapter support + /// search?) and all failure mapping live here because the adapter is held by + /// this actor, not the manager. The search path reuses the browse + /// kinds rather than inventing a parallel set. + /// + /// Failure mapping: + /// + /// — adapter is not , or it threw (searchable adapter, but the server/protocol cannot search); message carried verbatim in the latter case. + /// — adapter threw . + /// — adapter threw . + /// — any other exception, message carried verbatim. + /// + /// + /// The adapter already caps the match list at MaxResults, so unlike + /// there is no frame-budget clip here — the bound is + /// supplied by the caller (B6) and chosen to stay well under Akka's remote + /// frame size. The reply is sent via PipeTo(sender), the same pattern + /// used by . + /// + private void HandleSearch(SearchAddressSpaceCommand command) + { + var sender = Sender; + + if (_adapter is not IAddressSpaceSearchable searchable) + { + _log.Debug("[{0}] Search requested but adapter does not implement IAddressSpaceSearchable", _connectionName); + sender.Tell(new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure( + BrowseFailureKind.NotBrowsable, + $"Connection '{_connectionName}' does not support search."))); + return; + } + + _log.Debug("[{0}] Searching address space for '{1}' (maxDepth={2}, maxResults={3})", + _connectionName, command.Query, command.MaxDepth, command.MaxResults); + + searchable.SearchAddressSpaceAsync(command.Query, command.MaxDepth, command.MaxResults).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + // The adapter already bounded the walk by MaxResults — pass the + // matches and the cap flag straight through. + return new SearchAddressSpaceResult(t.Result.Matches, t.Result.CapReached, Failure: null); + } + + var baseEx = t.Exception?.GetBaseException(); + return baseEx switch + { + ConnectionNotConnectedException notConnected => new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)), + OperationCanceledException => new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.Timeout, "Search cancelled.")), + // Adapter reachable but the protocol/server cannot search. Carry + // the adapter's explanatory message through as NotBrowsable. + NotSupportedException notSupported => new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure(BrowseFailureKind.NotBrowsable, notSupported.Message)), + _ => new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure( + BrowseFailureKind.ServerError, + baseEx?.Message ?? "Unknown search error.")), + }; + }).PipeTo(sender); + } + /// /// Estimated-byte ceiling for a single , kept /// comfortably below Akka's default 128 KB remote frame size. A browse reply diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs index a46b020e..2dec25b3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -50,6 +50,7 @@ public class DataConnectionManagerActor : ReceiveActor Receive(HandleRemoveConnection); Receive(HandleGetAllHealthReports); Receive(HandleBrowse); + Receive(HandleSearch); Receive(HandleReadTagValues); } @@ -189,6 +190,32 @@ public class DataConnectionManagerActor : ReceiveActor } } + /// + /// Routes a from the central UI's OPC + /// UA tag picker to the child that owns the + /// named connection — the address-space analogue of . + /// Same split: the manager owns + /// (only it knows the per-site connection set); the capability check and every + /// other failure live inside the child where the adapter is held. + /// + private void HandleSearch(SearchAddressSpaceCommand command) + { + if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) + { + actor.Forward(command); + } + else + { + _log.Warning("No connection actor for {0} during search", command.ConnectionName); + Sender.Tell(new SearchAddressSpaceResult( + Array.Empty(), + CapReached: false, + new BrowseFailure( + BrowseFailureKind.ConnectionNotFound, + $"No data connection named '{command.ConnectionName}' at this site."))); + } + } + /// /// Routes a from the CentralUI's Test /// Bindings dialog to the child that diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs new file mode 100644 index 00000000..8ac30f3a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs @@ -0,0 +1,212 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +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.DataConnectionLayer.Actors; +using ZB.MOM.WW.ScadaBridge.HealthMonitoring; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; + +/// +/// Task B5 (T15): the site-side + child +/// together resolve a +/// against the live adapter and surface +/// every search outcome as a typed (the search path +/// reuses the browse failure kinds). The split mirrors browse exactly: the +/// manager owns (only it knows +/// the per-site connection set); the child owns the capability check +/// ( when the adapter is not +/// ) and the adapter-call failures +/// ( / +/// / ). +/// +public class DataConnectionManagerSearchHandlerTests : TestKit +{ + private readonly IDataConnectionFactory _factory; + private readonly ISiteHealthCollector _healthCollector; + private readonly DataConnectionOptions _options; + + public DataConnectionManagerSearchHandlerTests() + : base(@"akka.loglevel = WARNING") + { + _factory = Substitute.For(); + _healthCollector = Substitute.For(); + _options = new DataConnectionOptions + { + ReconnectInterval = TimeSpan.FromSeconds(30), + TagResolutionRetryInterval = TimeSpan.FromSeconds(30), + }; + } + + [Fact] + public void Unknown_connection_name_returns_ConnectionNotFound() + { + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + + // No CreateConnectionCommand sent — the manager has zero children, so a + // search against any name must be rejected with ConnectionNotFound (the + // manager is the only actor with site-level visibility). + manager.Tell(new SearchAddressSpaceCommand("unknown-connection", "Tag", 5, 100)); + + var reply = ExpectMsg(); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); + Assert.Empty(reply.Matches); + Assert.False(reply.CapReached); + } + + [Fact] + public void Non_searchable_adapter_returns_NotBrowsable() + { + // Bare IDataConnection — no IAddressSpaceSearchable. The child actor's + // capability check must surface this as NotBrowsable. + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + adapter.Status.Returns(ConnectionHealth.Connected); + _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-bare", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new SearchAddressSpaceCommand("conn-bare", "Tag", 5, 100)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); + Assert.Empty(reply.Matches); + } + + [Fact] + public void Success_path_returns_mapped_matches() + { + // Adapter implementing both IDataConnection (so DataConnectionActor can run + // its lifecycle) AND IAddressSpaceSearchable (so the search handler takes + // the success path). + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + + var matches = new[] + { + new AddressSpaceMatch( + new BrowseNode("ns=2;s=A", "TagA", BrowseNodeClass.Variable, HasChildren: false), + "/Folder1/TagA"), + new AddressSpaceMatch( + new BrowseNode("ns=2;s=B", "TagB", BrowseNodeClass.Variable, HasChildren: false), + "/Folder2/TagB"), + }; + ((IAddressSpaceSearchable)adapter) + .SearchAddressSpaceAsync("Tag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AddressSpaceSearchResult(matches, CapReached: true)); + + _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-ok", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new SearchAddressSpaceCommand("conn-ok", "Tag", 5, 100)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Null(reply.Failure); + Assert.Equal(2, reply.Matches.Count); + Assert.Equal("ns=2;s=A", reply.Matches[0].Node.NodeId); + Assert.Equal("/Folder1/TagA", reply.Matches[0].Path); + Assert.Equal("ns=2;s=B", reply.Matches[1].Node.NodeId); + Assert.True(reply.CapReached); + } + + [Fact] + public void Query_depth_and_results_thread_through_to_the_adapter() + { + // The command's Query/MaxDepth/MaxResults must reach the adapter verbatim. + string? receivedQuery = "NOT-CALLED"; + int receivedDepth = -1; + int receivedResults = -1; + + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + ((IAddressSpaceSearchable)adapter) + .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + receivedQuery = ci.ArgAt(0); + receivedDepth = ci.ArgAt(1); + receivedResults = ci.ArgAt(2); + return new AddressSpaceSearchResult(Array.Empty(), CapReached: false); + }); + + _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-args", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new SearchAddressSpaceCommand("conn-args", "Boiler", 7, 250)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Null(reply.Failure); + Assert.Equal("Boiler", receivedQuery); + Assert.Equal(7, receivedDepth); + Assert.Equal(250, receivedResults); + Assert.Empty(reply.Matches); + } + + [Fact] + public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() + { + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + ((IAddressSpaceSearchable)adapter) + .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException( + new ConnectionNotConnectedException("OPC UA session is not connected."))); + + _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-down", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new SearchAddressSpaceCommand("conn-down", "Tag", 5, 100)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); + Assert.Empty(reply.Matches); + } +}