diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 9b8fdcb7..6cfebd3f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -50,6 +50,11 @@ public static class ServiceCollectionExtensions // Backs the Audit Log page's Export button via GET /api/centralui/audit/export. services.AddScoped(); + // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync + // that enforces the CentralUI-side Design-role trust boundary and translates + // transport failures into typed BrowseFailure results for the dialog. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs new file mode 100644 index 00000000..37fedeee --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +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 +/// , which forwards a +/// to the owning site via +/// . +/// +/// +/// The service is the trust boundary for the browse capability: it enforces the +/// Design role at central before any cross-cluster traffic is generated, +/// because site-side actors do not unwrap the central trust envelope. Transport +/// failures (timeouts, unreachable sites) are translated into a typed +/// so the dialog can render an inline error and +/// remain usable (manual node-id paste still works). +/// +public interface IOpcUaBrowseService +{ + /// + /// Enumerates the immediate children of an OPC UA node on the live server + /// backing at . + /// Pass null for to browse from the + /// server root (ObjectsFolder). + /// + /// The target site identifier. + /// Id of the site-local data connection to browse against. + /// Node to browse, or null to browse from the server root. + /// Cancellation token. + Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs new file mode 100644 index 00000000..2b50d9b3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Components.Authorization; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Communication; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default implementation — a thin facade over +/// that enforces the +/// CentralUI-side Design-role trust boundary and translates transport +/// exceptions into a typed result. +/// +/// +/// Site-side actors (SiteCommunicationActor + DeploymentManagerActor) +/// do not unwrap the central trust envelope, so the role check MUST run here — +/// never on the site. Transport failures collapse into Timeout or +/// ServerError so the dialog can show an inline banner while leaving the +/// manual node-id paste field usable. +/// +public sealed class OpcUaBrowseService : IOpcUaBrowseService +{ + private readonly CommunicationService _communication; + private readonly AuthenticationStateProvider _auth; + + /// + /// Initializes a new instance of the . + /// + /// Central-side cluster communication service. + /// Authentication state provider used for the Design-role guard. + public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + } + + /// + public async Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default) + { + // CentralUI-side role guard — sites don't enforce envelope-level roles, + // so the Design check must happen here before any cross-cluster traffic. + var state = await _auth.GetAuthenticationStateAsync(); + if (!state.User.IsInRole("Design")) + { + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized.")); + } + + try + { + return await _communication.BrowseOpcUaNodeAsync( + siteId, + new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId), + 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 dialog can render an inline banner. + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: 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 dialog renders inline). + throw; + } + catch (Exception ex) + { + // Any other transport / serialization failure: keep the dialog + // alive and let the user fall back to manual node-id paste. + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 68bc6723..f91ddfe2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Communication.Actors; @@ -346,6 +347,29 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + // ── OPC UA Tag Browser (interactive design-time query) ── + + /// + /// Asks a site to enumerate the immediate children of an OPC UA node on the + /// live server backing the given data connection. Used by the CentralUI OPC UA + /// Tag Browser dialog. The Ask is bounded by + /// — interactive browse expansions are short, one-shot queries that share the + /// same latency budget as other remote queries (event logs, parked messages). + /// + /// The target site identifier. + /// The OPC UA browse command. + /// Cancellation token. + /// The browse result (children + truncation flag + structured failure). + public Task BrowseOpcUaNodeAsync( + string siteId, + BrowseOpcUaNodeCommand command, + CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, command); + return GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + // ── Pattern 8: Heartbeat (site→central, Tell) ── // Heartbeats are received by central, not sent. No method needed here.