25 Commits

Author SHA1 Message Date
Joseph Doherty 560b327ee1 refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts
nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the
pre-restructure MxGateway.* namespaces across the runtime Galaxy driver,
Galaxy.Browser, and their tests.

Key changes:
- nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it
  from the *.nupkg rule so the packages are tracked
- Directory.Packages.props pins both packages at 0.1.0
- 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/>
- 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*`
- libs/ removed (vendored DLLs + README.md)

GalaxyBrowseSession rewritten around the new lazy API:
- RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes)
  and caches them by TagName instead of bulk-fetching the whole hierarchy
- ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync,
  giving true one-wire-call-per-click instead of in-memory parent/child scan
- _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint)
- AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0)

Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66.
Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
2026-05-29 07:14:18 -04:00
Joseph Doherty d1b6cff085 docs: link driver-browsers design from CLAUDE.md 2026-05-28 16:23:28 -04:00
Joseph Doherty ef17d2e595 fix(adminui): picker DisposeAsync is fire-and-forget per design 2026-05-28 16:21:24 -04:00
Joseph Doherty e439100937 fix(adminui): DriverBrowseTree uses local field, not parameter mutation 2026-05-28 16:18:58 -04:00
Joseph Doherty 7c9621040e feat(adminui): wire Galaxy picker to live browser + attribute side-panel 2026-05-28 16:17:34 -04:00
Joseph Doherty 1b0baf7025 feat(adminui): wire OpcUaClient picker to live browser 2026-05-28 16:16:37 -04:00
Joseph Doherty f31af0093f test(opcuaclient.browser): opc-plc integration round-trip 2026-05-28 16:13:43 -04:00
Joseph Doherty 6e365ef1a9 feat(adminui): shared lazy DriverBrowseTree component with per-node filter 2026-05-28 16:13:03 -04:00
Joseph Doherty 1dbd3b2a6d feat(adminui): register browse services in AddAdminUI 2026-05-28 16:11:13 -04:00
Joseph Doherty 48c3c56073 test(galaxy.browser): unit + fake-transport session coverage 2026-05-28 16:07:13 -04:00
Joseph Doherty 5475ab2aa3 test(opcuaclient.browser): unit + opc-plc live coverage 2026-05-28 16:04:25 -04:00
Joseph Doherty 1a143beeb9 feat(galaxy.browser): add transient gateway-connection factory
GalaxyDriverBrowser opens an ad-hoc GalaxyRepositoryClient from the
AdminUI's persisted Galaxy options and hands it to a GalaxyBrowseSession
for the address picker. Mirrors GalaxyDriver.BuildClientOptions field-
for-field so the gateway sees an identical option shape, with API-key
resolution inlined (env:/file:/dev: prefixes) so the Browser project
needn't take a hard reference on Driver.Galaxy.

Connect phase runs under a 30s budget linked to the caller's CT and
includes a TestConnectionAsync call so auth/TLS/DNS failures surface
inside the budget instead of waiting for the first DiscoverHierarchy
round-trip. On any post-Create exception the client is disposed before
the throw propagates.

Refactored GalaxyBrowseSession to take only GalaxyRepositoryClient —
browse never needs MxGatewaySession (that's only for live subscribe/
write paths), and constructing one outside the runtime driver isn't
straightforward. The session now disposes _client in DisposeAsync; the
_session field/parameter is gone.
2026-05-28 15:59:57 -04:00
Joseph Doherty 641b2ecbcf fix(opcuaclient.browser): volatile _disposed for cross-thread visibility 2026-05-28 15:54:33 -04:00
Joseph Doherty 09d1bbac00 feat(opcuaclient.browser): add transient-session factory 2026-05-28 15:53:17 -04:00
Joseph Doherty b869af2b3d fix(galaxy.browser): volatile _disposed, RootAsync gate, O(1) child hint 2026-05-28 15:51:31 -04:00
Joseph Doherty 56be42913c feat(opcuaclient.browser): add lazy browse session impl 2026-05-28 15:48:56 -04:00
Joseph Doherty dc8a2dd52c test(adminui): browse session registry, reaper, service 2026-05-28 15:44:20 -04:00
Joseph Doherty d605d0b20d feat(galaxy.browser): add lazy browse session with attribute fetch 2026-05-28 15:42:19 -04:00
Joseph Doherty 85676db3a5 feat(opcuaclient.browser): scaffold project + slnx entry 2026-05-28 15:39:14 -04:00
Joseph Doherty bec2988309 feat(adminui): in-process browse session registry + TTL reaper + service 2026-05-28 15:36:19 -04:00
Joseph Doherty 7cd5cde315 refactor(opcuaclient): move NamespaceMap to Contracts, make public
Browser project (Phase 3) needs to share namespace-stable address encoding
with the runtime driver. Move keeps the same namespace, so existing usages
in OpcUaClientDriver compile unchanged.
2026-05-28 15:35:21 -04:00
Joseph Doherty 7c92297d0e feat(galaxy.browser): scaffold project + slnx entry 2026-05-28 15:35:14 -04:00
Joseph Doherty 81f09a7054 feat(commons): add IDriverBrowser/IBrowseSession/BrowseNode abstractions 2026-05-28 15:32:01 -04:00
Joseph Doherty c962b86bde docs: implementation plan for driver browsers (OpcUaClient + Galaxy)
18-task plan following Section 9 of the approved design. Phases 3 & 4
parallelizable. Each task carries Classification + Estimated implement
time + Parallelizable-with metadata to drive subagent dispatch.
2026-05-28 15:29:40 -04:00
Joseph Doherty fcd0b9b355 docs: design for live address browsers (OpcUaClient + Galaxy)
Approved design for the deferred follow-up from PR #f9fc7dd's driver-pages
work. Lazy tree browse via per-driver IDriverBrowser registered in AdminUI
DI, sessions held in-process with TTL reaper. Detailed sequencing for the
writing-plans handoff is in section 9.
2026-05-28 15:19:52 -04:00
83 changed files with 4813 additions and 207 deletions
+2
View File
@@ -21,6 +21,8 @@ desktop.ini
# NuGet
packages/
*.nupkg
# … but DO track repo-local feed for mxaccessgw client (not yet on public nuget.org).
!nuget-packages/*.nupkg
# Certificates
*.pfx
+2
View File
@@ -150,3 +150,5 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tc
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode"
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500
```
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
+2
View File
@@ -97,5 +97,7 @@
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="local-mxgw" value="./nuget-packages" />
</packageSources>
</configuration>
+5
View File
@@ -21,6 +21,7 @@
</Folder>
<Folder Name="/src/Drivers/">
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
@@ -40,6 +41,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
</Folder>
<Folder Name="/src/Drivers/Driver CLIs/">
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
@@ -80,6 +82,7 @@
</Folder>
<Folder Name="/tests/Drivers/">
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
@@ -97,6 +100,8 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
</Folder>
<Folder Name="/tests/Drivers/Driver CLIs/">
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
@@ -0,0 +1,313 @@
# Live address browsers for OpcUaClient + Galaxy drivers — design
> **Status:** approved 2026-05-28. Implementation plan to follow via `writing-plans`.
> **Builds on:** PR that shipped driver-specific AdminUI pages (commit `0d3ec46`).
> Both `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` were
> intentionally shipped as static stubs ("enter the string manually") with live
> browse deferred to this follow-up.
**Goal:** Add lazy, ad-hoc browse trees to the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server's (or galaxy's) hierarchy and pick an address rather than typing it.
**Architecture:** A new `IDriverBrowser` abstraction registered per driver type (parallel to the runtime's `IDriverProbe`), with implementations housed in sibling `*.Browser` projects under `src/Drivers/`. AdminUI owns the live browse sessions in-process via a `BrowseSessionRegistry` singleton with a 2-minute idle TTL and an `IHostedService` reaper. Razor picker bodies talk to a scoped `IBrowserSessionService`; no actor messages on the hot path.
**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (sibling repo, lazy-browse API already shipped).
---
## 1. Architecture
### Abstraction
```csharp
// Commons (shared)
public interface IDriverBrowser {
string DriverType { get; } // "OpcUaClient", "Galaxy", ...
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct);
}
public interface IBrowseSession : IAsyncDisposable {
Guid Token { get; }
DateTime LastUsedUtc { get; }
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct);
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct);
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct); // empty for OPC UA
}
public sealed record BrowseNode(
string NodeId, // address persisted on commit
string DisplayName,
BrowseNodeKind Kind, // Folder | Leaf
bool HasChildrenHint);
public sealed record AttributeInfo(
string Name, // e.g. "DownloadPath"
string DriverDataType,
bool IsArray,
string SecurityClass); // FreeAccess | Operate | Tune | Configure | ViewOnly
public enum BrowseNodeKind { Folder, Leaf }
```
### Session lifecycle
1. Razor picker body calls `BrowserSessionService.OpenAsync(driverType, formJson)`
2. Service resolves `IDriverBrowser` from DI by driver type, calls `OpenAsync(json)`
3. Returns `IBrowseSession`; service registers it in `BrowseSessionRegistry` under a new `Guid` token
4. Razor stores token, calls `RootAsync(token)` to populate the initial tree
5. Each subsequent expand-click calls `ExpandAsync(token, nodeId)`
6. Picker body's `IAsyncDisposable.DisposeAsync` fires `CloseAsync(token)` on tear-down
7. `BrowseSessionReaper` (`IHostedService`) ticks every 30s, evicts any session where `(UtcNow - LastUsedUtc) > 2 min`, awaits `DisposeAsync`
The session genuinely has no value to other cluster nodes — it's tied to one circuit. Hosting it in-process avoids cross-cluster Ask latency on every folder click.
---
## 2. Components
### New projects
| Path | Purpose |
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | OPC UA browser impl + session |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` | Galaxy browser impl + session |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Unit tests (use opc-plc fixture) |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/` | Unit tests (fake transport) |
Driver-specific browsers live in **sibling** projects so AdminUI doesn't drag the runtime `Driver.*` projects (and their full SDK chains) through a transitive reference.
### New abstractions
| Path | Purpose |
|---|---|
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs` | Per-driver factory |
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs` | Session contract |
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs` | + `BrowseNodeKind` enum + `AttributeInfo` |
### AdminUI plumbing
| Path | Purpose |
|---|---|
| `src/Server/.../AdminUI/Browsing/BrowseSessionRegistry.cs` | Singleton, `ConcurrentDictionary<Guid, IBrowseSession>` |
| `src/Server/.../AdminUI/Browsing/BrowseSessionReaper.cs` | `IHostedService`, 30s tick, 2 min idle TTL |
| `src/Server/.../AdminUI/Browsing/IBrowserSessionService.cs` | Scoped DI service for Razor |
| `src/Server/.../AdminUI/Browsing/BrowserSessionService.cs` | Impl: resolve driver, register session, enforce per-call timeouts |
| `src/Server/.../AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor` | Shared lazy tree component with per-node text filter |
### Modified files
| Path | Change |
|---|---|
| `src/Server/.../Pickers/OpcUaClientAddressPickerBody.razor` | Add Browse button + DriverBrowseTree; keep manual entry |
| `src/Server/.../Pickers/GalaxyAddressPickerBody.razor` | Same shape + side-panel for attribute pick |
| `src/Server/.../AdminUI/Program.cs` | Register `IDriverBrowser` services + registry + reaper |
| `src/Drivers/.../OpcUaClient.Contracts/NamespaceMap.cs` | Extract from runtime `Driver.OpcUaClient` for shared use |
| `ZB.MOM.WW.OtOpcUa.slnx` | Add the four new projects |
---
## 3. Data flow
**Open → tree → pick** (OpcUaClient as worked example; Galaxy identical except attribute side-panel before commit):
```
Razor picker body BrowserSessionService IDriverBrowser Remote
| | | |
click Browse ────────► OpenAsync(driverType, json) ─► OpenAsync(json) ────────► connect + activate session
| ◄──────────────── token (Guid) ◄───── ISession |
| | | |
render tree ─────────► RootAsync(token) ─────────────► session.RootAsync ─────► BrowseAsync(ObjectsFolder)
| ◄──────────────── BrowseNode[] ◄───── refs |
| | | |
click folder ────────► ExpandAsync(token, nodeId) ──► session.ExpandAsync ───► BrowseAsync(nodeId)
| ◄──────────────── BrowseNode[] ◄───── refs |
| | | |
click leaf + commit ─► CloseAsync(token) ─────────► session.DisposeAsync ───► CloseSession
| | | |
```
**Galaxy two-stage attribute pick:** after the user selects an object (Folder) in the tree, the picker body calls `AttributesAsync(token, tagName)` and renders the result as a side-panel. The user picks an attribute; the committed address is `tag_name.AttributeName`.
**Stable address format:**
- OpcUaClient: `nsu=<uri>;<localid>` via `NamespaceMap.ToStableReference` — survives remote namespace-table reorder across restarts
- Galaxy: `tag_name` (the globally unique system name) — already stable by definition
**Per-node text filter:** purely client-side over the already-loaded `node.Children`. No round-trip on filter input.
---
## 4. OpcUaClient browser specifics
### Connection
- Reuses `OpcUaClientDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
- Builds a **separate** `ApplicationConfiguration` from the runtime driver — PKI root at `%LocalAppData%/OtOpcUa/adminui-browse-pki/` (separate cert store)
- `ApplicationName = "OtOpcUa AdminUI Browse"`, `ApplicationUri = "urn:OtOpcUa:AdminUI:Browse"`
- Endpoint selection: same `DiscoveryClient.GetEndpointsAsync` → filter `(policy, mode)` as the runtime driver
- One endpoint only (no failover) — interactive use; user retries with different URL on failure
- Bounded by `OpcUaClientDriverOptions.PerEndpointConnectTimeout` (clamped [5, 30]s)
### Namespace map
- `NamespaceMap` class extracted to `OpcUaClient.Contracts` so both runtime and Browser projects share one impl
- Browser builds the map from the live session on open; uses `ToStableReference` for outbound NodeIds; uses `TryResolve` for inbound
### Lazy browse
- One level per click using `Session.BrowseAsync` + `BrowseNextAsync` continuation-point loop
- `BrowseDescriptionCollection` filters to `NodeClass.Object | NodeClass.Variable`, `ResultMask = BrowseName | DisplayName | NodeClass`
- `BrowseNode.HasChildrenHint = (Kind == Folder)` — heuristic; saves a per-node round-trip
- Inside-session calls guarded by `SemaphoreSlim _gate` (same pattern as runtime driver — OPC UA `Session.BrowseAsync` not thread-safe)
### Cert handling
- `AutoAcceptCertificates = true` honored with parity to runtime + log warning + per-session unwire on dispose
- `AutoAcceptCertificates = false` + untrusted cert → `OpenAsync` fails with SDK error message in the UI
### Reconnect handling
- None. Browse sessions are short-lived (2 min idle TTL). Keep-alive failure → UI surfaces error chip → user re-clicks Browse.
---
## 5. Galaxy browser specifics
### Connection
- Reuses `GalaxyDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
- Opens `MxGatewaySession` with `ClientName = "OtOpcUa-AdminUI-Browse"` — distinct from runtime driver's name so the gateway can attribute load
- Per-call gateway client built via `session.GalaxyRepository(opts.GalaxyName)`
### Lazy browse
- Root: `client.BrowseAsync(new BrowseChildrenOptions(), ct)``IReadOnlyList<LazyBrowseNode>`
- Expand: cached `LazyBrowseNode` lookup by `tag_name`, then `node.ExpandAsync(ct)` (gateway client handles paging internally)
- No internal gate — `LazyBrowseNode.ExpandAsync` already has its own lock; gateway client is thread-safe across distinct calls
### Two-stage attribute pick
- Galaxy `BrowseNode.Kind` is always `Folder` — leaves don't exist at tree level
- When the user clicks an object node, picker body calls `AttributesAsync(token, tagName)` and shows the result as a side-panel listing `(Name, DriverDataType, IsArray, SecurityClass)`
- On attribute click, committed address is `$"{tagName}.{attrName}"`
- Backing call: either `BrowseChildrenOptions { IncludeAttributes = true }` filtered to the GobjectId, or a dedicated `GetAttributesAsync(GobjectId, ct)` — to be confirmed during plan write against the gateway client surface
### Filters in v1
- Per-node text filter (client-side) for tree navigation
- Server-side filters (`TagNameGlob`, `AlarmBearingOnly`, `HistorizedOnly`) deferred to a follow-up — easy to add later without breaking the wire (the session is constructed today with `new BrowseChildrenOptions()`)
---
## 6. Error handling, timeouts, TTL
### Failures
- `OpenAsync` → catches `Exception`, logs Info, returns typed `BrowseOpenResult(Ok: false, Message, Token: Empty)`. UI shows red chip with truncated SDK message
- `ExpandAsync` / `AttributesAsync` → same shape per-call. Failed branch shows error chip; rest of tree intact; session stays alive
- `BrowseSessionNotFoundException` when token unknown (session reaped or never existed)
### Timeouts
- Per-call expand/attributes: **20 s** via `CTS.CreateLinkedTokenSource(callerCt)` in `BrowserSessionService`
- Session open: **30 s** ceiling; OPC UA reuses `PerEndpointConnectTimeout` (default 10 s), Galaxy hardcodes 30 s for `MxGatewaySession.OpenAsync`
### TTL & reaping
- `LastUsedUtc` set on every `RootAsync`/`ExpandAsync`/`AttributesAsync`
- Reaper: `IHostedService` with `PeriodicTimer(30s)`. On each tick: snapshot keys; for any session with `(UtcNow - LastUsedUtc) > 120s`: `TryRemove` then `await DisposeAsync` outside the dictionary
- Concurrent `ExpandAsync` racing eviction → caller catches closed-session error → service translates to `BrowseSessionNotFoundException`
- On AdminUI shutdown: `StopAsync` walks the registry once and disposes all sessions
### Concurrency
- `BrowseSessionRegistry` = `ConcurrentDictionary<Guid, IBrowseSession>` — no extra lock
- OpcUaClient session serializes browse on `SemaphoreSlim`; Galaxy session relies on its internal locks
### Component dispose
- Razor picker body implements `IAsyncDisposable`
- Fires `CloseAsync(token)` fire-and-forget (no await) so circuit teardown isn't blocked by a gRPC roundtrip
- Reaper is the safety net if dispose doesn't fire
### Logging
- Serilog. Info at open + close, Debug at close-with-reason (`user-close | idle-ttl | shutdown`), Info on failure
- No per-expand logging (noise)
### Audit trail
- None — browse is read-only and doesn't mutate config or driver state (matches probe pattern)
---
## 7. Security & auth
### Role gating
- Browse button gated by existing `DriverOperator` LDAP policy — same as Reconnect/Restart in `DriverStatusPanel`
- Picker bodies check policy in `OnInitializedAsync` via `IAuthorizationService` and `AuthenticationStateProvider`
- Manual entry stays available regardless of role
### Credentials in JSON
- Form JSON posted to `BrowserSessionService.OpenAsync` contains plaintext passwords / API keys — same as the existing `TestDriverConnect` probe
- JSON is deserialized into typed Options → used to build SDK config → both released; no `_lastConfigJson` cached field anywhere in the registry or session impls
- Browse session tokens are `Guid.NewGuid()` and only ever cross the authenticated Blazor circuit
### Cert handling
- `AutoAcceptCertificates = true` honored with log warning + per-session unwire on dispose
- Browse PKI store separate from runtime PKI — browse-time accept doesn't poison the runtime driver's trust store
### Rate limiting
- None. DriverOperator role gating + 2-minute TTL is the budget. A bad actor with DriverOperator already has Reconnect/Restart capability
### Multi-replica AdminUI
- Sticky cookies (already configured via Traefik) pin a user to one replica → `BrowseSessionRegistry` is always co-located with the circuit that created the token
- Failover → token invalid on new replica → UI re-opens gracefully
---
## 8. Testing
### Unit tests — per-driver browsers
- `tests/Drivers/.../OpcUaClient.Browser.Tests/`: against opc-plc at `opc.tcp://10.100.0.35:50000`. `OpcUaClientBrowseSessionTests`, `OpcUaClientDriverBrowserTests` (bad endpoint, auth rejected, bad JSON)
- `tests/Drivers/.../Galaxy.Browser.Tests/`: fake `IGalaxyRepositoryClientTransport` (precedent in gateway-client repo). `GalaxyBrowseSessionTests`, `GalaxyDriverBrowserTests`
### Unit tests — AdminUI plumbing (added to existing `tests/Server/AdminUI.Tests/`)
- `BrowseSessionRegistryTests`: register/get/remove, concurrent registration
- `BrowseSessionReaperTests`: virtual time, idle eviction, non-idle preservation, eviction-vs-in-flight-expand race
- `BrowserSessionServiceTests`: open→root→expand→close, unknown driver type, per-call timeout enforced
### Component tests
- `DriverBrowseTree` lazy-expand contract with fake `IBrowserSessionService`; per-node filter filters DOM but does not call ExpandAsync; click caching
- Picker bodies: Browse button hidden when `!_canOperate`; manual entry still works
### Integration tests (opt-in, fixture-gated)
- `tests/Drivers/.../OpcUaClient.Browser.IntegrationTests/`: end-to-end against opc-plc, 3-level expand + round-trip resolve. Skipped unless `OPCUA_SIM_ENDPOINT` set
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
### Specific regression tests
- Namespace-stable round-trip: open → browse → take returned NodeId string → `ExpandAsync(string)` → must resolve back to same NodeId
- TTL reaper racing live ExpandAsync: `TryRemove` while expand is in-flight → safe, translates to `BrowseSessionNotFoundException`
### Verification at PR time
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean
- `dotnet test tests/Server/.../AdminUI.Tests/` green (existing 51 + new ~12)
- `dotnet test tests/Drivers/.../OpcUaClient.Browser.Tests/` with `lmxopcua-fix up opcuaclient`
- `dotnet test tests/Drivers/.../Galaxy.Browser.Tests/` (no fixture)
- Manual smoke: run AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, pick a variable, verify the stored NodeId reads cleanly via Client CLI
---
## 9. Implementation sequencing (for plan-writing)
Suggested phase split — each phase shippable + reviewable independently:
1. **Phase 1 — Abstractions.** Add `IDriverBrowser`, `IBrowseSession`, `BrowseNode`, `AttributeInfo`, `BrowseNodeKind` to Commons. Empty build.
2. **Phase 2 — Extract NamespaceMap.** Move from runtime `Driver.OpcUaClient` to `Driver.OpcUaClient.Contracts`; update runtime ref.
3. **Phase 3 — OpcUaClient browser.** New `Driver.OpcUaClient.Browser` project; impl + unit tests against opc-plc.
4. **Phase 4 — Galaxy browser.** New `Driver.Galaxy.Browser` project; impl + unit tests with fake transport. Confirm attribute-fetch API surface on `GalaxyRepositoryClient`.
5. **Phase 5 — AdminUI plumbing.** `BrowseSessionRegistry`, `BrowseSessionReaper`, `BrowserSessionService`, DI wire-up in `Program.cs`. Unit tests.
6. **Phase 6 — Shared `DriverBrowseTree.razor`.** Lazy tree component with per-node filter. Component tests with fake service.
7. **Phase 7 — Wire pickers.** Update `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` to use `DriverBrowseTree` + DriverOperator gating + (Galaxy) attribute side-panel. Manual smoke test.
8. **Phase 8 — Integration test + docs.** Opt-in opc-plc integration suite, design doc cross-references in `docs/`, `CLAUDE.md` (or `docs/security.md`) updates if needed.
---
## Decisions table
| # | Decision | Rationale |
|---|---|---|
| 1 | Ad-hoc browse using form JSON | Mirrors `TestDriverConnect` probe; works for new drafts and existing drivers uniformly |
| 2 | Tree + lazy load both drivers | Galaxy gateway just shipped `LazyBrowseNode.ExpandAsync` — symmetric UX possible |
| 3 | AdminUI-hosted via `IDriverBrowser` factory | Browse is interactive (≥10 calls/session); cross-cluster Ask hop would multiply latency; session has no value to other nodes |
| 4 | Sibling `*.Browser` projects | Keep AdminUI from pulling runtime `Driver.*` projects' SDK chains |
| 5 | `NamespaceMap` to `OpcUaClient.Contracts` | Shared between runtime + browser, no new project needed |
| 6 | Separate browse PKI store | Browse-time cert accept must not poison runtime driver's trust store |
| 7 | Per-node client-side text filter (v1) | Quick UX win; server-side filters deferred |
| 8 | 2 min idle TTL, 30s reaper tick | Matches typical user cadence; bounds resource exposure |
| 9 | 20 s per-call / 30 s open timeouts | Interactive feel; longer hangs almost always mean broken remote |
| 10 | DriverOperator role gating | Live remote connection is operationally privileged; matches Reconnect/Restart precedent |
| 11 | No audit trail | Browse is read-only; matches probe pattern |
| 12 | Galaxy two-stage attribute side-panel | One modal, no extra clicks vs. two-modal flow |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
{
"planPath": "docs/plans/2026-05-28-driver-browsers-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: Phase 1 — Add IDriverBrowser/IBrowseSession/BrowseNode to Commons", "status": "pending"},
{"id": 2, "subject": "Task 2: Phase 2 — Extract NamespaceMap to OpcUaClient.Contracts", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Phase 3a — Scaffold Driver.OpcUaClient.Browser project", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Phase 3b — Implement OpcUaClientBrowseSession", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Phase 3c — Implement OpcUaClientDriverBrowser factory", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Phase 3d — OpcUaClient.Browser tests (opc-plc fixture)", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Phase 4a — Scaffold Driver.Galaxy.Browser project", "status": "pending", "blockedBy": [1]},
{"id": 8, "subject": "Task 8: Phase 4b — Implement GalaxyBrowseSession", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: Phase 4c — Implement GalaxyDriverBrowser factory", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 10: Phase 4d — Galaxy.Browser tests (fake transport)", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 11: Phase 5a — BrowseSessionRegistry + reaper + service", "status": "pending", "blockedBy": [1]},
{"id": 12, "subject": "Task 12: Phase 5b — Wire DI in AddAdminUI()", "status": "pending", "blockedBy": [5, 9, 11]},
{"id": 13, "subject": "Task 13: Phase 5c — Tests for registry, reaper, service", "status": "pending", "blockedBy": [11]},
{"id": 14, "subject": "Task 14: Phase 6 — Shared DriverBrowseTree.razor", "status": "pending", "blockedBy": [12]},
{"id": 15, "subject": "Task 15: Phase 7a — Wire OpcUaClient picker to browser", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 16: Phase 7b — Wire Galaxy picker + attribute side-panel", "status": "pending", "blockedBy": [14]},
{"id": 17, "subject": "Task 17: Phase 8a — opc-plc integration test", "status": "pending", "blockedBy": [6]},
{"id": 18, "subject": "Task 18: Phase 8b — Manual smoke + CLAUDE.md update", "status": "pending", "blockedBy": [13, 15, 16, 17]}
],
"lastUpdated": "2026-05-28T00:00:00Z"
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>One node in a driver-agnostic browse tree.</summary>
/// <param name="NodeId">Stable identifier passed back to the picker on commit. For OPC UA
/// this is the <c>nsu=...;...</c> form; for Galaxy this is the <c>tag_name</c>.</param>
/// <param name="DisplayName">Label shown in the tree.</param>
/// <param name="Kind">Whether this node terminates the address (Leaf) or has children
/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates.</param>
/// <param name="HasChildrenHint">When true, the UI renders an expand affordance before
/// the children have been fetched.</param>
public sealed record BrowseNode(
string NodeId,
string DisplayName,
BrowseNodeKind Kind,
bool HasChildrenHint);
/// <summary>Discriminates terminal vs. expandable nodes for UI rendering.</summary>
public enum BrowseNodeKind
{
/// <summary>Expandable — has (or may have) children. UI shows expand affordance.</summary>
Folder,
/// <summary>Terminal — commit on select.</summary>
Leaf,
}
/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
public sealed record AttributeInfo(
string Name,
string DriverDataType,
bool IsArray,
string SecurityClass);
@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// A live, one-level-at-a-time browse over a remote address space. Owned by the
/// AdminUI <c>BrowseSessionRegistry</c>; disposed by the registry's TTL reaper or
/// the picker body on close.
/// </summary>
public interface IBrowseSession : IAsyncDisposable
{
/// <summary>Opaque token identifying this session in the registry.</summary>
Guid Token { get; }
/// <summary>Wall-clock time of the most recent successful call. Refreshed on
/// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, and
/// <see cref="AttributesAsync"/>; used by the reaper for idle eviction.</summary>
DateTime LastUsedUtc { get; }
/// <summary>Returns the top-level browse nodes.</summary>
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
/// <summary>Returns the direct children of the node identified by
/// <paramref name="nodeId"/>.</summary>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
/// the attribute side-panel after the user selects an object.</summary>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// Per-driver factory that opens an ad-hoc browse session against the configuration
/// supplied as JSON. Parallels <c>IDriverProbe</c> in the runtime — one implementation
/// per driver type, registered in AdminUI DI and indexed by <see cref="DriverType"/>.
/// </summary>
public interface IDriverBrowser
{
/// <summary>Driver type key, matching the AdminUI's persisted DriverType string
/// (e.g. "OpcUaClient", "Galaxy").</summary>
string DriverType { get; }
/// <summary>Opens a browse session against the supplied configuration.</summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
@@ -0,0 +1,179 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// <summary>
/// Lazy Galaxy browse over <see cref="GalaxyRepositoryClient.BrowseAsync"/>.
/// <see cref="RootAsync"/> returns the top-level <see cref="LazyBrowseNode"/>s
/// directly from the gateway; <see cref="ExpandAsync"/> fetches the direct children
/// of a previously-handed-out node via <see cref="LazyBrowseNode.ExpandAsync"/>
/// (one wire call per click, paginated internally by the client). Attribute fetches
/// are per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
/// Owns the supplied <see cref="GalaxyRepositoryClient"/> and disposes it best-effort.
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary<string, LazyBrowseNode> _byTagName = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _rootGate = new(1, 1);
private volatile bool _disposed;
private IReadOnlyList<LazyBrowseNode>? _roots;
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>
/// Initializes a new session wrapping a connected repository client. The factory
/// in <c>GalaxyDriverBrowser</c> constructs the client via
/// <see cref="GalaxyRepositoryClient.Create"/> and hands it off here for the
/// session's lifetime.
/// </summary>
/// <param name="client">Galaxy repository client to query for browse and attributes.</param>
internal GalaxyBrowseSession(GalaxyRepositoryClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// Fetches the top-level <see cref="LazyBrowseNode"/>s from the gateway and
/// returns them as <see cref="BrowseNode"/>s. Result is cached; a second call
/// returns the cached roots without a re-fetch.
/// </summary>
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _rootGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_roots ??= await _client.BrowseAsync(new BrowseChildrenOptions(), cancellationToken)
.ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
return Project(_roots);
}
finally
{
_rootGate.Release();
}
}
/// <summary>
/// Fetches the direct children of the cached node identified by
/// <paramref name="nodeId"/> (the object's <c>TagName</c>) via
/// <see cref="LazyBrowseNode.ExpandAsync"/>. Throws <see cref="ArgumentException"/>
/// if the tag hasn't been handed out by a prior Root/Expand call.
/// </summary>
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_byTagName.TryGetValue(nodeId, out var node))
{
throw new ArgumentException(
$"Galaxy object '{nodeId}' is not in the current browse-session cache. " +
"Re-open the browser or expand its parent first.", nameof(nodeId));
}
await node.ExpandAsync(cancellationToken).ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
return Project(node.Children);
}
/// <summary>
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
/// Returns an empty list if the gateway has no matching object.
/// </summary>
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var rows = await _client.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootTagName = nodeId,
MaxDepth = 0,
IncludeAttributes = true,
}, cancellationToken).ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
var obj = rows.FirstOrDefault();
if (obj is null) return Array.Empty<AttributeInfo>();
var result = new List<AttributeInfo>(obj.Attributes.Count);
foreach (var attr in obj.Attributes)
{
var driverType = !string.IsNullOrEmpty(attr.DataTypeName)
? attr.DataTypeName
: attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture);
result.Add(new AttributeInfo(
Name: attr.AttributeName,
DriverDataType: driverType,
IsArray: attr.IsArray,
SecurityClass: MapSecurityClass(attr.SecurityClassification)));
}
return result;
}
/// <summary>
/// Projects <see cref="LazyBrowseNode"/>s to <see cref="BrowseNode"/>s, caching
/// each by <c>TagName</c> so a subsequent <see cref="ExpandAsync"/> can locate
/// it. Galaxy nodes are always <see cref="BrowseNodeKind.Folder"/> — leaves only
/// appear in the attribute side-panel.
/// </summary>
private IReadOnlyList<BrowseNode> Project(IReadOnlyList<LazyBrowseNode> nodes)
{
var result = new List<BrowseNode>(nodes.Count);
foreach (var n in nodes)
{
_byTagName[n.Object.TagName] = n;
var displayName = !string.IsNullOrEmpty(n.Object.ContainedName)
? n.Object.ContainedName
: n.Object.TagName;
result.Add(new BrowseNode(
NodeId: n.Object.TagName,
DisplayName: displayName,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: n.HasChildrenHint));
}
return result;
}
/// <summary>
/// Maps the Galaxy raw security-classification integer to a display string.
/// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly;
/// anything else surfaces as <c>Unknown(N)</c>.
/// </summary>
private static string MapSecurityClass(int raw) => raw switch
{
0 => "FreeAccess",
1 => "Operate",
2 => "Tune",
3 => "Configure",
4 => "ViewOnly",
_ => $"Unknown({raw})",
};
/// <summary>
/// Idempotently tears down the underlying repository client. Swallows exceptions
/// on shutdown — the registry's reaper may be racing a client-initiated close.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_rootGate.Dispose();
try
{
await _client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort: a gateway-side close that hits a torn-down channel is normal.
}
}
}
@@ -0,0 +1,192 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// <summary>
/// Opens transient gateway connections for the AdminUI address picker. Mirrors the
/// runtime <c>GalaxyDriver.BuildClientOptions</c> pattern so the gateway sees the
/// same option shape, but tweaks <see cref="MxGatewayClientOptions.ApiKey"/>
/// resolution + the gateway-side client identity so browse sessions are
/// distinguishable from the runtime driver's live MX session.
/// </summary>
public sealed class GalaxyDriverBrowser : IDriverBrowser
{
/// <summary>
/// Identifier used in gateway-side logs / metrics for AdminUI browse sessions.
/// Distinct from any runtime driver's <c>MxAccess.ClientName</c> so an operator
/// can tell the two apart when triaging.
/// </summary>
internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse";
/// <summary>Hard cap on the time we'll wait for the initial gateway handshake.</summary>
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
};
private readonly ILogger<GalaxyDriverBrowser> _logger;
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
}
/// <summary>Driver type key — matches the AdminUI's persisted "Galaxy" value.</summary>
public string DriverType => "Galaxy";
/// <summary>
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
/// <see cref="GalaxyRepositoryClient"/> against the configured gateway endpoint,
/// and returns a browse session over it. The session owns the client and disposes
/// it on <see cref="IBrowseSession.DisposeAsync"/>.
/// </summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the JSON deserialises to null, when <c>Gateway.Endpoint</c> is empty,
/// or when <c>MxAccess.ClientName</c> is empty.
/// </exception>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
?? throw new InvalidOperationException("Galaxy options deserialized to null.");
if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
// The form persists MXAccess identity as ClientName (there is no separate
// "galaxy name" knob on the driver — the gateway picks the galaxy via its
// own GalaxyRepository config). Refuse a blank ClientName so the gateway side
// doesn't see anonymous browse sessions during triage.
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
var clientOpts = BuildClientOptions(opts.Gateway);
// 30s wall-clock budget for the connect phase, linked to the caller's token so
// an AdminUI cancel still wins early.
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(ConnectBudget);
GalaxyRepositoryClient? client = null;
try
{
client = GalaxyRepositoryClient.Create(clientOpts);
// TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS
// failures synchronously inside the connect budget rather than waiting for
// the first DiscoverHierarchyAsync call to fail. The client's own
// ConnectTimeout already bounds the underlying gRPC handshake; the linked
// CTS layered on top guarantees the AdminUI never blocks past 30s.
await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false);
_logger.LogInformation(
"AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})",
opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName);
var session = new GalaxyBrowseSession(client);
client = null; // Ownership transferred — keep finally from disposing.
return session;
}
catch
{
if (client is not null)
{
try
{
await client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort cleanup; the original exception is more useful.
}
}
throw;
}
}
/// <summary>
/// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
/// Browser project doesn't reference Driver.Galaxy.
/// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null,
};
/// <summary>
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
/// anything else is treated as a literal cleartext key with a startup warning.
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
/// in a sibling project the Browser intentionally doesn't reference.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
private string ResolveApiKey(string secretRef)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it when they open the address picker.
_logger.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -528,7 +528,7 @@ public sealed class GalaxyDriver
// If discovery hasn't run yet, build the client here so the watcher has a target.
// Driver.Galaxy-009 fix: guard with ??= so if BuildDefaultHierarchySource later runs
// it reuses this client rather than overwriting the field and leaking the first instance.
_ownedRepositoryClient ??= MxGateway.Client.GalaxyRepositoryClient.Create(
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
BuildClientOptions(_options.Gateway));
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,7 +1,7 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,8 +1,8 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -62,7 +62,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
/// applied value and skip redundant calls.
/// </summary>
private async Task EnsureSessionIntervalAsync(
MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken)
ZB.MOM.WW.MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken)
{
lock (_intervalLock)
{
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using System.Runtime.CompilerServices;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -19,34 +19,8 @@
</ItemGroup>
<ItemGroup>
<!-- Vendored mxaccessgw .NET client. Originally consumed via path-based
ProjectReference to the sibling repo, but the sibling repo restructured
and the MxGateway.Client.csproj path no longer exists. The DLLs in
libs/ are the last known-good build (May 2026); they reference proto
types from MxGateway.Contracts.dll using the pre-restructure namespace
(MxGateway.Contracts.Proto). See libs/README.md for the unwinding plan
once the sibling repo restores a client library or we migrate to the
new ZB.MOM.WW.MxGateway.Contracts.Proto namespace. -->
<Reference Include="MxGateway.Client">
<HintPath>libs\MxGateway.Client.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="MxGateway.Contracts">
<HintPath>libs\MxGateway.Contracts.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Transitive deps the vendored MxGateway.Client.dll was actually built
against (verified by reflecting GetReferencedAssemblies on the DLL —
see libs/README.md). Versions align with the sibling mxaccessgw repo's
current Server / Worker projects so binary-compat stays close to what
the team uses elsewhere. Pre-Driver.Galaxy-016 the csproj declared
`Polly` (the v7 API) instead of `Polly.Core` (the v8 API the DLL was
built against) — a package-name mistake, not just a version skew —
which would surface as a runtime MissingMethodException the first
time the client's retry pipeline ran. -->
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Core.Api" />
<PackageReference Include="Grpc.Net.Client" />
@@ -1,101 +0,0 @@
# Vendored MxGateway client DLLs
This directory holds binary copies of `MxGateway.Client.dll` and
`MxGateway.Contracts.dll` from the sibling `mxaccessgw` repo's last known-good
build (May 2026). The DLLs are referenced from the driver's csproj as
`<Reference HintPath="…" />` items rather than `ProjectReference`.
## Provenance
Both DLLs are built from this team's own `mxaccessgw` source tree — they are
not third-party binaries. The build commit + checksums below are recorded so
future readers can verify the artefacts match the expected source without
needing to ask the original author.
| File | Source commit | SHA-256 |
|---|---|---|
| `MxGateway.Client.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `3507f770adc8c1b27b2fc4645079c6e4e02d5c65b9545c12d637cd2a080a00bd` |
| `MxGateway.Contracts.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `437dc6cb6994c7c4d858c82f69af890732c7ffbfa0463fbd8a63ce7930d251b4` |
The build commit is the same for both DLLs and is embedded as
`AssemblyInformationalVersion` inside each binary — re-verify by running:
`ilspycmd <dll> | grep AssemblyInformationalVersion`.
To re-verify the checksums (e.g. after a clone):
```bash
sha256sum libs/MxGateway.Client.dll libs/MxGateway.Contracts.dll
```
If either SHA-256 or the embedded source commit no longer matches what's
listed above, the artefact has been replaced — verify before trusting.
## Why vendored
The sibling `mxaccessgw` repo restructured: the `clients/dotnet/MxGateway.Client`
project the driver previously referenced via path-based `ProjectReference` no
longer exists, and the proto contracts moved from the `MxGateway.Contracts.Proto`
namespace to `ZB.MOM.WW.MxGateway.Contracts.Proto`. The driver's source still
expects the pre-restructure namespace, so re-pointing at the new contracts would
require a global namespace rename across ~19 driver files PLUS reimplementing
the `MxGatewayClient` / `MxGatewaySession` / `GalaxyRepositoryClient` types the
old client library provided (the sibling repo dropped the client library
entirely, keeping only the contracts).
Vendoring the binaries unblocked the build in minutes instead of hours, freezes
the gateway contract surface at a known-good version, and preserves the option
to migrate properly later without an emergency rewrite.
## What's vendored
| File | Built against |
|---|---|
| `MxGateway.Client.dll` | net10.0, references `MxGateway.Contracts.dll` |
| `MxGateway.Contracts.dll` | net10.0, proto namespace `MxGateway.Contracts.Proto[.Galaxy]` |
The NuGet packages the vendored DLLs reference (verified by reflecting
`Assembly.GetReferencedAssemblies()` against `MxGateway.Client.dll`) are
declared as direct `PackageReference` in the driver csproj — when the dropped
`ProjectReference` was in place those packages were transitively provided;
with binary references the consumer must declare them explicitly:
| Package | Reason |
|---|---|
| `Google.Protobuf` 3.34.1 | Proto message types in `MxGateway.Contracts.dll` |
| `Grpc.Core.Api` 2.76.0 | Base gRPC client types in `MxGateway.Client.dll` |
| `Grpc.Net.Client` 2.76.0 | HTTP/2 transport used by `MxGatewayClient` |
| `Microsoft.Extensions.Logging.Abstractions` 10.0.7 | `ILogger` used by the client |
| `Polly.Core` 8.6.6 | Retry pipeline used by `MxGatewayClient` |
Versions match the sibling mxaccessgw repo's current Server / Worker
projects (`ZB.MOM.WW.MxGateway.Server.csproj`,
`ZB.MOM.WW.MxGateway.Worker.csproj`) so the runtime versions stay close to
what the gateway team uses. The pre-Driver.Galaxy-016 declarations were
incorrect — most visibly `Polly 8.5.2` was declared where the DLL actually
needs `Polly.Core` (a different package: `Polly` v7 is the older fluent API;
`Polly.Core` v8 is the modern resilience-pipeline API the gateway client was
built against). A `Polly` reference would have failed at runtime with
`MissingMethodException` the first time a retry pipeline ran.
## Decompiled-source archive
The vendored DLLs are byte-for-byte the build output. The full source can be
recovered with `ilspycmd MxGateway.Client.dll > MxGateway.Client.cs` if a code
review or audit needs it.
## How to unwind
Either path closes the vendored-binary debt:
1. **Sibling repo restores `MxGateway.Client.csproj`** (or publishes a NuGet
package). Switch the csproj back to a `ProjectReference` / `PackageReference`,
delete this directory.
2. **Driver migrates to the new `ZB.MOM.WW.MxGateway.Contracts.Proto`
namespace.** Global namespace rename across the ~19 consuming source files,
plus re-implementing `MxGatewayClient` / `MxGatewaySession` /
`GalaxyRepositoryClient` (≈2,200 LoC of behavioural client code) either
inlined into this driver or as a fresh sibling library. Delete this
directory.
Either way: when unwinding, also drop the five `PackageReference` lines added
to the csproj alongside the `<Reference>` items — the new ProjectReference /
PackageReference will provide them transitively again.
@@ -0,0 +1,178 @@
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
/// <summary>
/// Live one-level-per-call browse session over a remote OPC UA server. Created by
/// <c>OpcUaClientBrowser</c> on picker open and owned by the AdminUI's
/// <c>BrowseSessionRegistry</c>; the registry's TTL reaper disposes idle sessions.
/// </summary>
internal sealed class OpcUaClientBrowseSession : IBrowseSession
{
private readonly ISession _session;
private readonly NamespaceMap _nsMap;
private readonly NodeId _rootNodeId;
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// <summary>
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
/// </summary>
/// <param name="session">The OPC UA client session to browse against.</param>
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
{
_session = session;
_nsMap = nsMap;
_rootNodeId = rootNodeId;
}
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Wall-clock time of the most recent successful browse call; the reaper uses
/// this for idle eviction.</summary>
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>Browse one level under the configured root node.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
=> BrowseOneLevelAsync(_rootNodeId, cancellationToken);
/// <summary>Browse one level under the node identified by <paramref name="nodeId"/>,
/// which must be a stable reference produced by <see cref="NamespaceMap.ToStableReference"/>
/// (or a plain <c>ns=N;…</c> form).</summary>
/// <param name="nodeId">Stable reference string for the parent node.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="nodeId"/> cannot be
/// resolved against the live session's namespace table.</exception>
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
throw new ArgumentException(
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
return BrowseOneLevelAsync(resolved, cancellationToken);
}
/// <summary>The OPC UA picker treats variables as terminal leaves and does not surface
/// a per-attribute side-panel, so this always returns empty.</summary>
/// <param name="nodeId">Ignored.</param>
/// <param name="cancellationToken">Ignored.</param>
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
/// given parent node. <see cref="Session.BrowseAsync"/> is not thread-safe, so all calls
/// serialize through <see cref="_gate"/>.</summary>
private async Task<IReadOnlyList<BrowseNode>> BrowseOneLevelAsync(NodeId parent, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var descriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = parent,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass),
},
};
var resp = await _session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: descriptions,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count == 0)
{
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<BrowseNode>();
}
var result = resp.Results[0];
var refs = result.References;
// Follow browse continuation points so folders larger than the server's per-call
// cap aren't silently truncated (same pattern as runtime
// Driver.OpcUaClient-003).
var cp = result.ContinuationPoint;
while (cp is { Length: > 0 })
{
var next = await _session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoints: false,
continuationPoints: [cp],
ct: ct).ConfigureAwait(false);
if (next.Results.Count == 0) break;
var nextResult = next.Results[0];
if (nextResult.References is { Count: > 0 })
refs.AddRange(nextResult.References);
cp = nextResult.ContinuationPoint;
}
LastUsedUtc = DateTime.UtcNow;
var nodes = new List<BrowseNode>(refs.Count);
foreach (var rf in refs)
nodes.Add(ToBrowseNode(rf));
return nodes;
}
finally
{
_gate.Release();
}
}
/// <summary>Project a single <see cref="ReferenceDescription"/> into the driver-agnostic
/// <see cref="BrowseNode"/> shape, encoding the outbound NodeId in the stable
/// <c>nsu=…</c> form so the picker survives a remote namespace-table reorder.</summary>
private BrowseNode ToBrowseNode(ReferenceDescription rf)
{
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
var isObject = rf.NodeClass == NodeClass.Object;
var displayName = rf.DisplayName?.Text
?? rf.BrowseName?.Name
?? childId.ToString()
?? "(unnamed)";
return new BrowseNode(
NodeId: _nsMap.ToStableReference(childId),
DisplayName: displayName,
Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
HasChildrenHint: isObject);
}
/// <summary>Idempotent best-effort dispose: closes the underlying session if it's a
/// concrete <see cref="Session"/>, disposes it, and disposes the gate. Close errors are
/// swallowed because the registry reaper may be racing a remote disconnect.</summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_session is Session s)
{
try { await s.CloseAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
}
try { _session.Dispose(); }
catch { /* best-effort */ }
try { _gate.Dispose(); }
catch { /* best-effort */ }
}
}
@@ -0,0 +1,234 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
/// <summary>
/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
/// trust decisions cannot poison the runtime driver's cert store.
/// </summary>
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
};
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
}
/// <summary>Driver type key — matches the AdminUI's persisted "OpcUaClient" value.</summary>
public string DriverType => "OpcUaClient";
/// <summary>Opens a transient OPC UA session and returns a browse session over it.</summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
if (opts.AuthType == OpcUaAuthType.Certificate)
throw new InvalidOperationException(
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
if (opts.AutoAcceptCertificates)
_logger.LogWarning(
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
"driver's PKI store instead.",
endpoint);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var identity = BuildBrowseUserIdentity(opts);
var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(perEndpointBudget);
var endpointDesc = await SelectEndpointAsync(
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
var endpointCfg = EndpointConfiguration.Create(appConfig);
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
preferredLocales: null,
connectCts.Token).ConfigureAwait(false);
try
{
var nsMap = NamespaceMap.FromSession(session);
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
? ObjectIds.ObjectsFolder
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
_logger.LogInformation(
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
endpoint, opts.SecurityPolicy, opts.SecurityMode);
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
}
catch
{
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
try { session.Dispose(); } catch { /* best-effort */ }
throw;
}
}
/// <summary>
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
/// trust decisions don't leak into the deployed driver's cert store.
/// </summary>
/// <param name="ct">Cancellation token for the configuration build.</param>
private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
{
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
// locally rather than plumbing a telemetry context all the way through — the browser
// emits no per-request telemetry of its own and the SDK's internal fallback is fine
// for a transient picker session (mirrors the runtime driver's same suppression).
#pragma warning disable CS0618
var app = new ApplicationInstance
{
ApplicationName = "OtOpcUa AdminUI Browse",
ApplicationType = ApplicationType.Client,
};
#pragma warning restore CS0618
var pkiRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "adminui-browse-pki");
var config = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa AdminUI Browse",
ApplicationType = ApplicationType.Client,
ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = "CN=OtOpcUa AdminUI Browse",
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted"),
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuers"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = false,
},
TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
DisableHiResClock = true,
};
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
app.ApplicationConfiguration = config;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
.ConfigureAwait(false);
return config;
}
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param>
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
opts.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
/// <param name="appConfig">Application configuration used by the discovery client.</param>
/// <param name="url">Remote endpoint URL to query.</param>
/// <param name="policy">Required security policy.</param>
/// <param name="mode">Required message security mode.</param>
/// <param name="ct">Cancellation token for the discovery call.</param>
private static async Task<EndpointDescription> SelectEndpointAsync(
ApplicationConfiguration appConfig, string url,
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
{
using var client = await DiscoveryClient.CreateAsync(
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicy = MapPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
if (match is null)
{
var advertised = string.Join(", ", all.Select(e =>
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
throw new InvalidOperationException(
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
$"Server advertises: {advertised}");
}
return match;
}
/// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
/// <param name="p">The driver security policy to map.</param>
private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
{
OpcUaSecurityPolicy.None => SecurityPolicies.None,
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
_ => throw new ArgumentOutOfRangeException(nameof(p)),
};
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
/// <param name="uri">Full policy URI to shorten.</param>
private static string ShortName(string uri) =>
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// direct parse against the current session.
/// </para>
/// </remarks>
internal sealed class NamespaceMap
public sealed class NamespaceMap
{
// index -> URI and URI -> index, as the upstream server published them at connect time.
private readonly string[] _uris;
@@ -5,5 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Background service that periodically evicts idle browse sessions from the
/// <see cref="BrowseSessionRegistry"/>. Each tick takes a snapshot of the registry,
/// removes any session that has been idle longer than <see cref="IdleTtl"/>, then
/// disposes the evicted instance OUTSIDE the dictionary — so concurrent expand
/// calls racing eviction fail cleanly via <see cref="BrowseSessionNotFoundException"/>.
/// </summary>
public sealed class BrowseSessionReaper(
BrowseSessionRegistry registry,
ILogger<BrowseSessionReaper> logger) : BackgroundService
{
/// <summary>How long a session may be untouched before it becomes eligible for eviction.</summary>
public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2);
/// <summary>How often the reaper checks the registry.</summary>
public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try { await ReapOnceAsync(stoppingToken).ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogWarning(ex, "Browse-session reaper iteration failed; will retry next tick.");
}
}
await DrainAllAsync().ConfigureAwait(false);
}
/// <summary>Evicts every session whose <see cref="Commons.Browsing.IBrowseSession.LastUsedUtc"/>
/// is older than <see cref="IdleTtl"/>. Internal so tests can drive a tick directly.</summary>
internal async Task ReapOnceAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
foreach (var (token, session) in registry.Snapshot())
{
if (now - session.LastUsedUtc < IdleTtl) continue;
if (!registry.TryRemove(token, out var taken)) continue;
try { await taken.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogDebug(ex,
"Best-effort dispose of idle-evicted browse session {Token} failed.", token);
}
logger.LogDebug("Browse session {Token} closed reason=idle-ttl", token);
}
}
private async Task DrainAllAsync()
{
foreach (var (token, session) in registry.Snapshot())
{
if (!registry.TryRemove(token, out var taken)) continue;
try { await taken.DisposeAsync().ConfigureAwait(false); } catch { }
logger.LogDebug("Browse session {Token} closed reason=shutdown", token);
}
}
}
@@ -0,0 +1,30 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Singleton in-process directory of live <see cref="IBrowseSession"/> instances,
/// keyed by <see cref="IBrowseSession.Token"/>. Concurrency is provided by the
/// underlying <see cref="ConcurrentDictionary{TKey, TValue}"/> alone — there are no
/// additional locks; callers must dispose evicted sessions outside the dictionary.
/// </summary>
public sealed class BrowseSessionRegistry
{
private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();
/// <summary>Adds (or replaces) a session in the registry keyed by its token.</summary>
public void Register(IBrowseSession session) => _sessions[session.Token] = session;
/// <summary>Looks up a session by token without removing it.</summary>
public bool TryGet(Guid token, out IBrowseSession session) =>
_sessions.TryGetValue(token, out session!);
/// <summary>Atomically removes a session from the registry, returning it for disposal.</summary>
public bool TryRemove(Guid token, out IBrowseSession session) =>
_sessions.TryRemove(token, out session!);
/// <summary>Returns a point-in-time snapshot of all currently registered sessions.</summary>
public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
_sessions.Select(kv => (kv.Key, kv.Value)).ToList();
}
@@ -0,0 +1,72 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Default <see cref="IBrowserSessionService"/> implementation. Indexes injected
/// <see cref="IDriverBrowser"/>s by <see cref="IDriverBrowser.DriverType"/>
/// (case-insensitive) at construction, registers opened sessions, and wraps each
/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot
/// stall the UI indefinitely.
/// </summary>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
if (!_browsersByType.TryGetValue(driverType, out var browser))
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
try
{
var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
registry.Register(session);
return new(true, null, session.Token);
}
catch (Exception ex)
{
logger.LogInformation(ex,
"Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
return new(false, ex.Message, Guid.Empty);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
public async Task CloseAsync(Guid token)
{
if (!registry.TryRemove(token, out var session)) return;
try { await session.DisposeAsync().ConfigureAwait(false); } catch { }
logger.LogDebug("Browse session {Token} closed reason=user-close", token);
}
private async Task<T> InvokeAsync<T>(
Guid token, CancellationToken callerCt, Func<IBrowseSession, CancellationToken, Task<T>> op)
{
if (!registry.TryGet(token, out var session))
throw new BrowseSessionNotFoundException(token);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt);
cts.CancelAfter(PerCallTimeout);
return await op(session, cts.Token).ConfigureAwait(false);
}
}
@@ -0,0 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Outcome of <see cref="IBrowserSessionService.OpenAsync"/>. On success
/// <paramref name="Ok"/> is <see langword="true"/> and <paramref name="Token"/> is the
/// registry handle; on failure <paramref name="Message"/> carries a human-readable
/// diagnostic for the UI's error chip.
/// </summary>
/// <param name="Ok">True iff the browse session was opened and registered.</param>
/// <param name="Message">Failure diagnostic, or <see langword="null"/> on success.</param>
/// <param name="Token">Registry handle on success; <see cref="Guid.Empty"/> on failure.</param>
public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);
/// <summary>
/// Scoped Razor-page facade over the in-process browse-session machinery. Owns
/// driver-type dispatch on open and per-call timeout enforcement on expand/attributes.
/// </summary>
public interface IBrowserSessionService
{
/// <summary>Opens a session against the named driver type using the given JSON config.
/// Never throws — all errors are surfaced via <see cref="BrowseOpenResult"/>.</summary>
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
/// <summary>Returns the root nodes of an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
/// <summary>Returns the direct children of <paramref name="nodeId"/> in an open session.
/// Throws <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Returns the attributes of <paramref name="nodeId"/> in an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
Task CloseAsync(Guid token);
}
/// <summary>
/// Raised by the service layer when a caller references a token that is not
/// (or no longer) in the registry — typically because the reaper evicted it
/// between calls.
/// </summary>
public sealed class BrowseSessionNotFoundException(Guid token)
: InvalidOperationException($"Browse session {token} not found (may have been reaped).");
@@ -58,7 +58,8 @@ else
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* mxaccessgw connection *@
@@ -58,7 +58,8 @@ else
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Endpoint *@
@@ -0,0 +1,160 @@
@* Lazy tree component with per-node text filter. Driver-agnostic — consumes
IBrowserSessionService for root/expand. Selected node is bound back to parent
via OnNodeSelected EventCallback. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_loading)
{
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;</div>
}
else if (_error is not null)
{
<div class="text-danger small">@_error</div>
}
else if (_roots is null || _roots.Count == 0)
{
<div class="text-muted small">No nodes.</div>
}
else
{
@foreach (var n in _roots) { @RenderNode(n, 0) }
}
</div>
@code {
/// <summary>The browse-session token returned by IBrowserSessionService.OpenAsync.</summary>
[Parameter, EditorRequired] public Guid SessionToken { get; set; }
/// <summary>The currently-selected node's NodeId, for visual selection highlighting.</summary>
[Parameter] public string SelectedNodeId { get; set; } = "";
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
private bool _loading = true;
private string? _error;
private List<TreeItem>? _roots;
private string _selectedNodeIdLocal = "";
protected override void OnParametersSet()
{
_selectedNodeIdLocal = SelectedNodeId ?? "";
}
protected override async Task OnInitializedAsync()
{
await LoadRootAsync();
}
private async Task LoadRootAsync()
{
try
{
var roots = await BrowserService.RootAsync(SessionToken, default);
_roots = roots.Select(n => new TreeItem(n)).ToList();
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
}
private async Task ToggleAsync(TreeItem item)
{
item.Expanded = !item.Expanded;
if (item.Expanded && !item.Loaded) await ExpandAsync(item);
}
private async Task ExpandAsync(TreeItem item)
{
if (item.Loaded || item.Loading) return;
item.Loading = true; StateHasChanged();
try
{
var kids = await BrowserService.ExpandAsync(SessionToken, item.Node.NodeId, default);
item.Children = kids.Select(k => new TreeItem(k)).ToList();
item.Loaded = true;
}
catch (Exception ex) { item.Error = ex.Message; }
finally { item.Loading = false; StateHasChanged(); }
}
private async Task SelectAsync(TreeItem item)
{
_selectedNodeIdLocal = item.Node.NodeId;
await OnNodeSelected.InvokeAsync(item.Node);
}
private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var selectedCls = _selectedNodeIdLocal == item.Node.NodeId ? "bg-primary-subtle" : "";
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
{
<button type="button" class="btn btn-sm btn-link p-0"
@onclick="@(() => ToggleAsync(item))" style="width:18px">
@(item.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
}
</div>
@if (item.Expanded && item.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (item.Expanded && item.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@item.Error
</div>
}
else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 })
{
<input type="text" class="form-control form-control-sm mt-1"
placeholder="filter children..."
style="@($"width:calc(100% - {(depth + 2) * 18}px); margin-left:{(depth + 2) * 18}px")"
@bind="item.Filter" @bind:event="oninput" />
@foreach (var c in FilterChildren(item))
{
@RenderNode(c, depth + 1)
}
}
};
private static IEnumerable<TreeItem> FilterChildren(TreeItem item)
{
if (item.Children is null) yield break;
var f = item.Filter?.Trim();
foreach (var c in item.Children)
{
if (string.IsNullOrEmpty(f)) { yield return c; continue; }
if (c.Node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase) ||
c.Node.NodeId.Contains(f, StringComparison.OrdinalIgnoreCase))
yield return c;
}
}
private sealed class TreeItem(BrowseNode node)
{
public BrowseNode Node { get; } = node;
public bool Expanded { get; set; }
public bool Loaded { get; set; }
public bool Loading { get; set; }
public string? Error { get; set; }
public List<TreeItem>? Children { get; set; }
public string? Filter { get; set; }
}
}
@@ -1,10 +1,15 @@
@* Static Galaxy address builder: tag_name.AttributeName free text → verbatim.
Live Galaxy browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live Galaxy browse is deferred to a follow-up phase.
Enter the tag and attribute name manually below.
</div>
@* Galaxy address picker:
1. Manual tag/attribute entry (always available).
2. (DriverOperator-gated) Live browse: object tree on the left,
attribute side-panel on the right. Clicking an attribute commits
tag_name.AttributeName into the result. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-5">
@@ -21,6 +26,70 @@
</div>
</div>
@if (_canOperate)
{
<div class="mt-3 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
@onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse galaxy
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
</div>
@if (_token != Guid.Empty)
{
<div class="row g-3 mt-1">
<div class="col-md-7">
<label class="form-label small">Objects</label>
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnObjectSelectAsync"
SelectedNodeId="_tagName" />
</div>
<div class="col-md-5">
<label class="form-label small">Attributes of @(string.IsNullOrEmpty(_tagName) ? "—" : _tagName)</label>
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_attrsLoading)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else if (_attrsError is not null)
{
<span class="text-danger small">@_attrsError</span>
}
else if (_attrs is null)
{
<span class="text-muted small">Pick an object.</span>
}
else if (_attrs.Count == 0)
{
<span class="text-muted small">No attributes.</span>
}
else
{
@foreach (var a in _attrs)
{
var sel = _attributeName == a.Name ? "bg-primary-subtle" : "";
<div class="d-flex justify-content-between align-items-center py-1 @sel"
style="cursor:pointer" @onclick="@(() => SelectAttributeAsync(a))">
<span class="mono small">@a.Name</span>
<span class="text-muted small">@a.DriverDataType@(a.IsArray ? "[]" : "") · @a.SecurityClass</span>
</div>
}
}
</div>
</div>
</div>
}
}
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
@@ -29,15 +98,77 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
private string _tagName = "";
private string _attributeName = "";
private string _built = "";
private Guid _token = Guid.Empty;
private bool _opening;
private bool _attrsLoading;
private bool _canOperate;
private string? _openError;
private string? _attrsError;
private IReadOnlyList<AttributeInfo>? _attrs;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
_canOperate = authResult.Succeeded;
_built = Build();
_ = CurrentAddressChanged.InvokeAsync(_built);
await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OpenBrowseAsync()
{
_opening = true; _openError = null; StateHasChanged();
try
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync("Galaxy", json, default);
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
finally { _opening = false; StateHasChanged(); }
}
private async Task CloseBrowseAsync()
{
var t = _token;
_token = Guid.Empty;
_attrs = null;
StateHasChanged();
if (t != Guid.Empty) await BrowserService.CloseAsync(t);
}
private async Task OnObjectSelectAsync(BrowseNode node)
{
_tagName = node.NodeId;
_attributeName = "";
_attrs = null;
_attrsLoading = true;
_attrsError = null;
StateHasChanged();
try
{
_attrs = await BrowserService.AttributesAsync(_token, _tagName, default);
}
catch (Exception ex)
{
_attrsError = ex.Message;
}
finally
{
_attrsLoading = false;
await OnChangedAsync();
}
}
private async Task SelectAttributeAsync(AttributeInfo a)
{
_attributeName = a.Name;
await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -54,4 +185,17 @@
return _tagName;
return $"{_tagName}.{_attributeName}";
}
private string TruncatedError() =>
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
}
@@ -1,22 +1,57 @@
@* Static OPC UA Client address builder: NodeId free text → verbatim.
Live browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live OPC UA node browse is deferred to a follow-up phase.
Enter the NodeId string manually below.
</div>
@* OPC UA Client address picker:
1. Manual NodeId entry (always available)
2. (DriverOperator-gated) Browse remote server with live tree, lazy expand. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-10">
<label class="form-label">NodeId</label>
<input type="text" class="form-control form-control-sm mono" placeholder="ns=2;s=Channel.Device.Tag"
<input type="text" class="form-control form-control-sm mono"
placeholder="ns=2;s=Channel.Device.Tag"
@bind="_nodeId" @bind:after="OnChangedAsync" />
<div class="form-text">
OPC UA NodeId string, e.g. <code>ns=2;s=Channel.Device.Tag</code> or <code>i=1001</code>.
OPC UA NodeId string, e.g. <code>ns=2;s=Channel.Device.Tag</code> or
<code>i=1001</code>. Use Browse to navigate the remote server.
</div>
</div>
</div>
@if (_canOperate)
{
<div class="mt-3 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm"
disabled="@_opening" @onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse remote server
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">
Close
</button>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
</div>
@if (_token != Guid.Empty)
{
<div class="mt-2">
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
SelectedNodeId="_nodeId" />
</div>
}
}
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
@@ -25,14 +60,47 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
private string _nodeId = "";
private string _built = "";
private Guid _token = Guid.Empty;
private bool _opening;
private bool _canOperate;
private string? _openError;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
_canOperate = authResult.Succeeded;
_built = _nodeId;
_ = CurrentAddressChanged.InvokeAsync(_built);
await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OpenBrowseAsync()
{
_opening = true; _openError = null; StateHasChanged();
try
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync("OpcUaClient", json, default);
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
finally { _opening = false; StateHasChanged(); }
}
private async Task CloseBrowseAsync()
{
var t = _token; _token = Guid.Empty; StateHasChanged();
if (t != Guid.Empty) await BrowserService.CloseAsync(t);
}
private async Task OnTreeSelectAsync(BrowseNode node)
{
_nodeId = node.NodeId;
await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -40,4 +108,17 @@
_built = _nodeId;
await CurrentAddressChanged.InvokeAsync(_built);
}
private string TruncatedError() =>
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
}
@@ -4,6 +4,9 @@ using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -39,6 +42,14 @@ public static class EndpointRouteBuilderExtensions
{
services.AddRazorComponents().AddInteractiveServerComponents();
services.AddOtOpcUaDriverStatusServices();
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
return services;
}
}
@@ -11,6 +11,10 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
@@ -30,6 +34,8 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,115 @@
using ZB.MOM.WW.MxGateway.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
/// <summary>
/// Pure-construction + lifecycle coverage of <see cref="GalaxyBrowseSession"/>.
/// The session is <c>internal</c>; visibility comes via <c>InternalsVisibleTo</c>
/// on the production project.
/// <para>
/// <b>Blocker:</b> the hierarchy/expand/attribute paths all call into
/// <see cref="GalaxyRepositoryClient"/>, which only ships an <c>internal</c>
/// transport seam (<c>IGalaxyRepositoryClientTransport</c>) and an <c>internal</c>
/// constructor — both keyed via <c>InternalsVisibleTo</c> on the vendored
/// <c>ZB.MOM.WW.MxGateway.Client</c> assembly, and only granted to that repo's own
/// <c>ZB.MOM.WW.MxGateway.Client.Tests</c>. We can't substitute a fake transport from
/// here without changing the upstream repo, and the public <c>Create</c>
/// factory always opens a real gRPC channel. So in-memory traversal coverage
/// (RootAsync / ExpandAsync / AttributesAsync, including the SecurityClass
/// mapping) is deferred to the integration suite (Task 17) and the manual
/// smoke pass (Task 18) — both of which run the gateway for real.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyBrowseSessionTests
{
/// <summary>Builds a <see cref="GalaxyRepositoryClient"/> bound to an
/// unreachable endpoint. No connection is opened — <c>Create</c> just builds the
/// gRPC channel object — so this is safe to call without a fixture.</summary>
private static GalaxyRepositoryClient NewClient() =>
GalaxyRepositoryClient.Create(new MxGatewayClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1"),
ApiKey = "test-key",
UseTls = false,
ConnectTimeout = TimeSpan.FromSeconds(1),
DefaultCallTimeout = TimeSpan.FromSeconds(1),
});
/// <summary>The internal ctor must reject a null client — the production caller
/// (the factory in <c>GalaxyDriverBrowser.OpenAsync</c>) hands off ownership of a
/// real client and never passes null, but defence-in-depth catches a future caller
/// who skips that handoff.</summary>
[Fact]
public void Constructor_with_null_client_throws_ArgumentNullException()
{
Should.Throw<ArgumentNullException>(() => new GalaxyBrowseSession(null!));
}
/// <summary>Each session must publish a distinct <see cref="GalaxyBrowseSession.Token"/>
/// so the AdminUI registry can disambiguate concurrent browse sessions against the
/// same driver config.</summary>
[Fact]
public async Task Token_is_unique_per_session()
{
await using var a = new GalaxyBrowseSession(NewClient());
await using var b = new GalaxyBrowseSession(NewClient());
a.Token.ShouldNotBe(b.Token);
a.Token.ShouldNotBe(Guid.Empty);
}
/// <summary><see cref="GalaxyBrowseSession.LastUsedUtc"/> is primed to the
/// construction time so the registry reaper has a sensible baseline before the
/// first Root/Expand/Attributes call lands.</summary>
[Fact]
public async Task LastUsedUtc_is_initialized_at_construction()
{
var before = DateTime.UtcNow;
await using var session = new GalaxyBrowseSession(NewClient());
var after = DateTime.UtcNow;
// Allow generous slop — the field is set inside the ctor body, both bookends
// are wall-clock UtcNow, and we only care that it isn't default(DateTime).
session.LastUsedUtc.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
session.LastUsedUtc.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
}
/// <summary><see cref="GalaxyBrowseSession.DisposeAsync"/> is idempotent — the
/// registry's reaper may race a client-initiated close, so the second call must
/// no-op rather than throw <see cref="ObjectDisposedException"/> or hit the
/// already-disposed gRPC channel.</summary>
[Fact]
public async Task DisposeAsync_is_idempotent()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
// Second call should silently no-op.
await Should.NotThrowAsync(async () => await session.DisposeAsync());
}
/// <summary>After disposal, any <see cref="GalaxyBrowseSession.ExpandAsync"/> call
/// must surface <see cref="ObjectDisposedException"/> — not a downstream channel
/// fault — so the AdminUI sees a clean "session closed" signal.</summary>
[Fact]
public async Task ExpandAsync_after_dispose_throws_ObjectDisposedException()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync("anything", TestContext.Current.CancellationToken));
}
/// <summary><see cref="GalaxyBrowseSession.ExpandAsync"/> must reject a tag that
/// hasn't been seen by a prior Root/Expand call — the cache is the source of
/// truth, and silently returning [] would mask AdminUI bugs that browse with a
/// stale path.</summary>
[Fact]
public async Task ExpandAsync_unknown_tag_throws_ArgumentException()
{
await using var session = new GalaxyBrowseSession(NewClient());
// No RootAsync call ⇒ cache is empty ⇒ any tag is unknown.
await Should.ThrowAsync<ArgumentException>(
() => session.ExpandAsync("Galaxy.Unknown", TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
/// <summary>
/// Unit-only coverage of <see cref="GalaxyDriverBrowser"/>'s pre-connect validation.
/// These tests do not require a live mxaccessgw endpoint and are safe to run without
/// the gateway fixture — they exercise the JSON deserialization + validation paths
/// that run before <c>GalaxyRepositoryClient.Create</c> + <c>TestConnectionAsync</c>.
/// The factory's transport-construction path is covered by the integration suite
/// (Task 17) and manual smoke (Task 18) since both require a real gateway.
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyDriverBrowserTests
{
private readonly GalaxyDriverBrowser _sut = new();
/// <summary>The DriverType key must match the AdminUI's persisted "Galaxy" value
/// so the factory wire-up picks the right browser implementation.</summary>
[Fact]
public void DriverType_is_Galaxy() => _sut.DriverType.ShouldBe("Galaxy");
/// <summary>An empty Gateway.Endpoint must fail fast with a clear, endpoint-mentioning
/// message rather than surfacing a downstream gRPC URI parse error.</summary>
[Fact]
public async Task OpenAsync_with_empty_endpoint_throws_InvalidOperationException()
{
var json = """{"Gateway":{"Endpoint":"","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":"X"},"Repository":{},"Reconnect":{}}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Endpoint");
}
/// <summary>An empty MxAccess.ClientName must fail fast — refused so the gateway
/// side doesn't see anonymous browse sessions during triage.</summary>
[Fact]
public async Task OpenAsync_with_empty_clientName_throws_InvalidOperationException()
{
var json = """{"Gateway":{"Endpoint":"http://127.0.0.1:1","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":""},"Repository":{},"Reconnect":{}}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ClientName");
}
/// <summary>A JSON literal that deserializes to null must fail fast with a
/// "deserialized to null" message — never a downstream NRE.</summary>
[Fact]
public async Task OpenAsync_with_null_json_throws_InvalidOperationException()
{
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync("null", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("null");
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,7 +1,7 @@
using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,5 +1,5 @@
using System.Diagnostics;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -183,9 +183,9 @@ public sealed class GalaxyTelemetryTests
private sealed class FakeHierarchy : IGalaxyHierarchySource
{
/// <inheritdoc />
public Task<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>> GetHierarchyAsync(
public Task<IReadOnlyList<ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyObject>> GetHierarchyAsync(
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>>(
=> Task.FromResult<IReadOnlyList<ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyObject>>(
[new(), new()]);
}
}
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -24,16 +24,7 @@
</ItemGroup>
<ItemGroup>
<!-- Vendored mxaccessgw contracts DLL. The driver under test holds the same
binary reference; this explicit duplicate lets tests construct
GalaxyObject / GalaxyAttribute / MxCommand / MxEvent fixtures directly
rather than only via the driver's public surface. See
..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\libs\README.md for
the unwinding plan. -->
<Reference Include="MxGateway.Contracts">
<HintPath>..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\libs\MxGateway.Contracts.dll</HintPath>
<Private>true</Private>
</Reference>
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;
/// <summary>
/// End-to-end test against opc-plc Docker fixture. Validates that NodeId strings returned
/// by RootAsync/ExpandAsync round-trip cleanly through ExpandAsync — the regression test
/// for namespace-stable address encoding.
/// </summary>
[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")]
public class BrowseRoundTripTests
{
[Fact]
public async Task Three_level_expand_round_trips_resolve_back()
{
var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT")
?? "opc.tcp://10.100.0.35:50000";
// Numeric ordinals because the production browser uses default System.Text.Json
// without a JsonStringEnumConverter (SecurityPolicy.None=0, SecurityMode.None=0,
// AuthType.Anonymous=0).
var json = $$"""
{
"EndpointUrl":"{{endpoint}}",
"SecurityPolicy":0,"SecurityMode":0,"AuthType":0,
"SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10"
}
""";
var browser = new OpcUaClientDriverBrowser();
await using var session = await browser.OpenAsync(json, TestContext.Current.CancellationToken);
var roots = await session.RootAsync(TestContext.Current.CancellationToken);
var current = roots.FirstOrDefault(n => n.HasChildrenHint);
current.ShouldNotBeNull("expected at least one Folder under ObjectsFolder on opc-plc");
// Drill down up to 2 more levels by alternately expanding the first Folder
for (var depth = 0; depth < 2; depth++)
{
var next = await session.ExpandAsync(current!.NodeId, TestContext.Current.CancellationToken);
var step = next.FirstOrDefault(n => n.HasChildrenHint);
if (step is null) break;
current = step;
}
// Round-trip the leaf-most reachable folder string — must resolve back through the
// namespace map, prove the nsu= encoding survives.
var children = await session.ExpandAsync(current!.NodeId, TestContext.Current.CancellationToken);
children.ShouldNotBeNull();
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
/// <summary>
/// Live-server tests against the opc-plc Docker fixture. Bring up with
/// `lmxopcua-fix up opcuaclient` from PowerShell before running. These tests
/// drive the internal <c>OpcUaClientBrowseSession</c> through its public factory
/// <see cref="OpcUaClientDriverBrowser"/> so no InternalsVisibleTo is needed.
/// Filter out with <c>--filter "Category!=RequiresOpcPlc"</c> when the fixture
/// is unreachable.
/// </summary>
[Trait("Category", "RequiresOpcPlc")]
public sealed class OpcUaClientBrowseSessionTests
{
private static string Endpoint =>
Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000";
// Enum values use their numeric ordinal because the browser uses default
// System.Text.Json with no JsonStringEnumConverter. SecurityPolicy.None,
// SecurityMode.None, OpcUaAuthType.Anonymous are all index 0, so omitting them
// also works — keeping them explicit for documentation value.
private static string ConfigJson => $$"""
{
"EndpointUrl":"{{Endpoint}}",
"SecurityPolicy":0,
"SecurityMode":0,
"AuthType":0,
"SessionTimeout":"00:01:00",
"Timeout":"00:00:10",
"PerEndpointConnectTimeout":"00:00:10"
}
""";
/// <summary>RootAsync should surface at least one node under ObjectsFolder
/// (opc-plc exposes a non-empty top level).</summary>
[Fact]
public async Task RootAsync_returns_at_least_one_node()
{
var ct = TestContext.Current.CancellationToken;
var browser = new OpcUaClientDriverBrowser();
await using var session = await browser.OpenAsync(ConfigJson, ct);
var roots = await session.RootAsync(ct);
roots.Count.ShouldBeGreaterThan(0);
}
/// <summary>ExpandAsync should accept a stable NodeId returned by RootAsync
/// and round-trip through the live namespace table.</summary>
[Fact]
public async Task ExpandAsync_round_trips_stable_NodeId()
{
var ct = TestContext.Current.CancellationToken;
var browser = new OpcUaClientDriverBrowser();
await using var session = await browser.OpenAsync(ConfigJson, ct);
var roots = await session.RootAsync(ct);
var folder = roots.FirstOrDefault(n => n.Kind == BrowseNodeKind.Folder);
folder.ShouldNotBeNull("expected at least one Folder under ObjectsFolder");
var children = await session.ExpandAsync(folder!.NodeId, ct);
children.ShouldNotBeNull();
}
/// <summary>The OPC UA picker treats variables as terminal leaves, so
/// AttributesAsync must always be empty for this driver.</summary>
[Fact]
public async Task AttributesAsync_is_empty_for_opcuaclient()
{
var ct = TestContext.Current.CancellationToken;
var browser = new OpcUaClientDriverBrowser();
await using var session = await browser.OpenAsync(ConfigJson, ct);
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
attrs.ShouldBeEmpty();
}
}
@@ -0,0 +1,52 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
/// <summary>
/// Unit-only coverage of <see cref="OpcUaClientDriverBrowser"/>'s pre-connect
/// validation. These tests do not require a live OPC UA endpoint and are safe to
/// run without the opc-plc Docker fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDriverBrowserTests
{
private readonly OpcUaClientDriverBrowser _sut = new();
/// <summary>The DriverType key must match the AdminUI's persisted value.</summary>
[Fact]
public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient");
/// <summary>An empty endpoint must fail fast with a clear EndpointUrl-mentioning message.</summary>
[Fact]
public async Task OpenAsync_with_empty_endpoint_throws()
{
var json = """{"EndpointUrl":"","EndpointUrls":[]}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("EndpointUrl");
}
/// <summary>A JSON literal that deserializes to null must fail fast.</summary>
[Fact]
public async Task OpenAsync_with_null_json_throws()
{
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync("null", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("null");
}
/// <summary>Certificate auth is not supported by the browser; the failure message
/// must say so explicitly rather than surfacing a downstream COM/SDK error.
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the
/// browser's default System.Text.Json options (no string-enum converter).</summary>
[Fact]
public async Task OpenAsync_with_certificate_auth_throws_clear_message()
{
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Certificate");
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>
@@ -0,0 +1,86 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowseSessionReaper"/>. We drive the reaper
/// directly through the internal <c>ReapOnceAsync</c> entry point and skew
/// <see cref="FakeBrowseSession.LastUsedUtc"/> to simulate the passage of time —
/// no real wall-clock waits.</summary>
public sealed class BrowseSessionReaperTests
{
private static BrowseSessionReaper NewReaper(BrowseSessionRegistry registry) =>
new(registry, NullLogger<BrowseSessionReaper>.Instance);
[Fact]
public async Task ReapOnceAsync_evicts_idle_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession
{
LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3),
};
registry.Register(session);
var reaper = NewReaper(registry);
await reaper.ReapOnceAsync(CancellationToken.None);
registry.TryGet(session.Token, out _).ShouldBeFalse();
session.Disposed.ShouldBeTrue();
}
[Fact]
public async Task ReapOnceAsync_preserves_recent_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession { LastUsedUtc = DateTime.UtcNow };
registry.Register(session);
var reaper = NewReaper(registry);
await reaper.ReapOnceAsync(CancellationToken.None);
registry.TryGet(session.Token, out _).ShouldBeTrue();
session.Disposed.ShouldBeFalse();
}
[Fact]
public async Task ReapOnceAsync_handles_already_removed_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession
{
LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3),
};
registry.Register(session);
// Race the reaper: pull the entry out before the loop calls TryRemove.
registry.TryRemove(session.Token, out _).ShouldBeTrue();
var reaper = NewReaper(registry);
// No exception, no double dispose. Because we removed the session ourselves
// without disposing it, Disposed should still be false.
await reaper.ReapOnceAsync(CancellationToken.None);
session.Disposed.ShouldBeFalse();
}
[Fact]
public async Task ReapOnceAsync_continues_when_one_session_dispose_throws()
{
var registry = new BrowseSessionRegistry();
var staleAt = DateTime.UtcNow - TimeSpan.FromMinutes(3);
var bad = new FakeBrowseSession { LastUsedUtc = staleAt, ThrowOnDispose = true };
var good = new FakeBrowseSession { LastUsedUtc = staleAt };
registry.Register(bad);
registry.Register(good);
var reaper = NewReaper(registry);
// The dispose exception is swallowed; the second session must still be reaped + disposed.
await Should.NotThrowAsync(() => reaper.ReapOnceAsync(CancellationToken.None));
registry.TryGet(bad.Token, out _).ShouldBeFalse();
registry.TryGet(good.Token, out _).ShouldBeFalse();
good.Disposed.ShouldBeTrue();
}
}
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowseSessionRegistry"/>'s lookup, removal and
/// concurrent-registration behaviour.</summary>
public sealed class BrowseSessionRegistryTests
{
[Fact]
public void Register_then_TryGet_returns_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
registry.Register(session);
var found = registry.TryGet(session.Token, out var got);
found.ShouldBeTrue();
got.ShouldBeSameAs((IBrowseSession)session);
}
[Fact]
public void TryGet_unknown_returns_false()
{
var registry = new BrowseSessionRegistry();
registry.TryGet(Guid.NewGuid(), out var got).ShouldBeFalse();
got.ShouldBeNull();
}
[Fact]
public void TryRemove_then_TryGet_returns_false()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
registry.Register(session);
registry.TryRemove(session.Token, out var removed).ShouldBeTrue();
removed.ShouldBeSameAs((IBrowseSession)session);
registry.TryGet(session.Token, out _).ShouldBeFalse();
}
[Fact]
public async Task Concurrent_Register_from_many_tasks_all_visible_in_Snapshot()
{
var registry = new BrowseSessionRegistry();
const int count = 50;
var sessions = Enumerable.Range(0, count).Select(_ => new FakeBrowseSession()).ToArray();
var tasks = sessions.Select(s => Task.Run(() => registry.Register(s))).ToArray();
await Task.WhenAll(tasks);
var snapshot = registry.Snapshot();
snapshot.Count.ShouldBe(count);
var snapshotTokens = snapshot.Select(x => x.Token).OrderBy(g => g).ToArray();
var expectedTokens = sessions.Select(s => s.Token).OrderBy(g => g).ToArray();
snapshotTokens.ShouldBe(expectedTokens);
}
}
@@ -0,0 +1,142 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
/// timeout enforcement.</summary>
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance);
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry, new FakeDriverBrowser("Known"));
var result = await service.OpenAsync("Unknown", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("Unknown");
}
[Fact]
public async Task OpenAsync_happy_path_returns_token_and_registers()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
};
var service = NewService(registry, browser);
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Message.ShouldBeNull();
result.Token.ShouldNotBe(Guid.Empty);
result.Token.ShouldBe(session.Token);
registry.TryGet(result.Token, out var registered).ShouldBeTrue();
registered.ShouldBeSameAs((IBrowseSession)session);
}
[Fact]
public async Task OpenAsync_swallows_driver_throws_returns_Ok_false()
{
var registry = new BrowseSessionRegistry();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => throw new InvalidOperationException("boom"),
};
var service = NewService(registry, browser);
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("boom");
}
[Fact]
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry);
await Should.ThrowAsync<BrowseSessionNotFoundException>(
() => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
}
[Fact]
public async Task RootAsync_invokes_session_Root()
{
var registry = new BrowseSessionRegistry();
IReadOnlyList<BrowseNode> expected = new[]
{
new BrowseNode("ns=2;s=A", "A", BrowseNodeKind.Folder, true),
new BrowseNode("ns=2;s=B", "B", BrowseNodeKind.Leaf, false),
};
var session = new FakeBrowseSession { RootHandler = _ => Task.FromResult(expected) };
registry.Register(session);
var service = NewService(registry);
var actual = await service.RootAsync(session.Token, CancellationToken.None);
actual.ShouldBe(expected);
}
[Fact]
public async Task RootAsync_enforces_PerCallTimeout()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession
{
// Awaits the linked CTS — the service must cancel it via PerCallTimeout (20s).
RootHandler = ct => Task.Delay(TimeSpan.FromSeconds(40), ct)
.ContinueWith<IReadOnlyList<BrowseNode>>(_ => Array.Empty<BrowseNode>(), ct),
};
registry.Register(session);
var service = NewService(registry);
var sw = System.Diagnostics.Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(
() => service.RootAsync(session.Token, CancellationToken.None));
sw.Stop();
// Real timeout is 20s; allow generous slack but cap well below the 40s task delay.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35));
}
[Fact]
public async Task CloseAsync_removes_and_disposes_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
};
var service = NewService(registry, browser);
var opened = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
opened.Ok.ShouldBeTrue();
await service.CloseAsync(opened.Token);
registry.TryGet(opened.Token, out _).ShouldBeFalse();
session.Disposed.ShouldBeTrue();
// Unknown token is a no-op.
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Test double for <see cref="IBrowseSession"/> used by the registry, reaper,
/// and service tests. All three operations delegate to caller-supplied handlers so each
/// test can shape behaviour; <see cref="DisposeAsync"/> records that it ran and can be
/// instructed to throw via <see cref="ThrowOnDispose"/>.</summary>
internal sealed class FakeBrowseSession : IBrowseSession
{
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Mutable so tests can rewind the timestamp into the reaper's eviction window.</summary>
public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow;
/// <summary>True once <see cref="DisposeAsync"/> has run to completion.</summary>
public bool Disposed { get; private set; }
/// <summary>When true, <see cref="DisposeAsync"/> throws to exercise the reaper's
/// best-effort dispose path.</summary>
public bool ThrowOnDispose { get; set; }
// Suppress CS0649: handlers are test seams — some tests leave them null intentionally.
#pragma warning disable CS0649
public Func<CancellationToken, Task<IReadOnlyList<BrowseNode>>>? RootHandler;
public Func<string, CancellationToken, Task<IReadOnlyList<BrowseNode>>>? ExpandHandler;
public Func<string, CancellationToken, Task<IReadOnlyList<AttributeInfo>>>? AttributesHandler;
#pragma warning restore CS0649
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
=> RootHandler?.Invoke(ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
=> ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
=> AttributesHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (ThrowOnDispose) throw new InvalidOperationException("dispose-failed");
Disposed = true;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,22 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Test double for <see cref="IDriverBrowser"/>. The constructor sets
/// <see cref="DriverType"/>; <see cref="OpenAsync"/> delegates to the caller-supplied
/// <see cref="OpenHandler"/> or returns a fresh <see cref="FakeBrowseSession"/>.</summary>
internal sealed class FakeDriverBrowser(string driverType) : IDriverBrowser
{
/// <inheritdoc />
public string DriverType { get; } = driverType;
/// <summary>Override for <see cref="OpenAsync"/>; if null, a fresh
/// <see cref="FakeBrowseSession"/> is returned.</summary>
#pragma warning disable CS0649
public Func<string, CancellationToken, Task<IBrowseSession>>? OpenHandler;
#pragma warning restore CS0649
/// <inheritdoc />
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
=> OpenHandler?.Invoke(configJson, ct) ?? Task.FromResult<IBrowseSession>(new FakeBrowseSession());
}