diff --git a/docs/plans/2026-05-28-driver-browsers-plan.md b/docs/plans/2026-05-28-driver-browsers-plan.md new file mode 100644 index 00000000..988f488b --- /dev/null +++ b/docs/plans/2026-05-28-driver-browsers-plan.md @@ -0,0 +1,2058 @@ +# Live address browsers (OpcUaClient + Galaxy) — implementation plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task. + +**Goal:** Ship lazy, ad-hoc browse trees for the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server / galaxy hierarchy and pick an address rather than typing it. + +**Architecture:** New `IDriverBrowser` abstraction in Commons, parallel to the runtime's `IDriverProbe`. One impl per driver type, housed in new sibling `*.Browser` projects under `src/Drivers/`. AdminUI holds browse sessions in-process via a `BrowseSessionRegistry` singleton with an `IHostedService` reaper enforcing a 2-minute idle TTL. Razor picker bodies call a scoped `IBrowserSessionService`. No new shared messages; no actor plumbing on the hot path. + +**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (lazy-browse API: `LazyBrowseNode.ExpandAsync`). + +**Design doc:** `docs/plans/2026-05-28-driver-browsers-design.md` (commit `fcd0b9b`). Each task below cites the design section it implements. + +--- + +## Sequencing & parallelism overview + +``` +Phase 1 (abstractions) ──► Phase 2 (NamespaceMap extract) ──► Phase 3 (OpcUa browser) ─┐ + └─► Phase 4 (Galaxy browser) ─┤ + │ + Phase 5 (AdminUI plumbing) ◄──┤ + │ + Phase 6 (DriverBrowseTree) ◄──┘ + │ + Phase 7 (picker bodies) ◄─────┘ + │ + Phase 8 (integration + docs) ◄┘ +``` + +Phases 3 & 4 dispatch in parallel (no file overlap). Phase 5 starts after Phase 1 (only needs the abstractions). Phase 6 starts after Phase 5. Phase 7 needs 3, 4, 5, 6. Phase 8 last. + +--- + +## Task 1 — Phase 1: Add browser abstractions to Commons + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (everything depends on this) + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs` +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs` +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs` + +**Step 1: Add the three abstraction files** + +`IDriverBrowser.cs`: +```csharp +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// Per-driver factory that opens an ad-hoc browse session against the configuration +/// supplied as JSON. Parallels IDriverProbe in the runtime — one implementation +/// per driver type, registered in AdminUI DI and indexed by . +/// +public interface IDriverBrowser +{ + /// Driver type key, matching the AdminUI's persisted DriverType string + /// (e.g. "OpcUaClient", "Galaxy"). + string DriverType { get; } + + /// Opens a browse session against the supplied configuration. + /// Driver options serialized as JSON; same shape the runtime + /// driver would consume. + /// Cancellation for the connect phase only. + Task OpenAsync(string configJson, CancellationToken cancellationToken); +} +``` + +`IBrowseSession.cs`: +```csharp +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// A live, one-level-at-a-time browse over a remote address space. Owned by the +/// AdminUI BrowseSessionRegistry; disposed by the registry's TTL reaper or +/// the picker body on close. +/// +public interface IBrowseSession : IAsyncDisposable +{ + /// Opaque token identifying this session in the registry. + Guid Token { get; } + + /// Wall-clock time of the most recent successful call. Refreshed on + /// , , and + /// ; used by the reaper for idle eviction. + DateTime LastUsedUtc { get; } + + /// Returns the top-level browse nodes. + Task> RootAsync(CancellationToken cancellationToken); + + /// Returns the direct children of the node identified by + /// . + Task> ExpandAsync(string nodeId, CancellationToken cancellationToken); + + /// Returns the attributes of the node identified by . + /// 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. + Task> AttributesAsync(string nodeId, CancellationToken cancellationToken); +} +``` + +`BrowseNode.cs`: +```csharp +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// One node in a driver-agnostic browse tree. +/// Stable identifier passed back to the picker on commit. For OPC UA +/// this is the nsu=...;... form; for Galaxy this is the tag_name. +/// Label shown in the tree. +/// Whether this node terminates the address (Leaf) or has children +/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates. +/// When true, the UI renders an expand affordance before +/// the children have been fetched. +public sealed record BrowseNode( + string NodeId, + string DisplayName, + BrowseNodeKind Kind, + bool HasChildrenHint); + +/// Discriminates terminal vs. expandable nodes for UI rendering. +public enum BrowseNodeKind +{ + /// Expandable — has (or may have) children. UI shows expand affordance. + Folder, + /// Terminal — commit on select. + Leaf, +} + +/// Metadata for an attribute of a Galaxy object (or the equivalent +/// per-driver concept). Surfaced in the picker's attribute side-panel. +public sealed record AttributeInfo( + string Name, + string DriverDataType, + bool IsArray, + string SecurityClass); +``` + +**Step 2: Build clean** +Run: `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj` +Expected: 0 errors, 0 warnings. + +**Step 3: Commit** +```bash +git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/ +git commit -m "feat(commons): add IDriverBrowser/IBrowseSession/BrowseNode abstractions" +``` + +--- + +## Task 2 — Phase 2: Extract NamespaceMap to OpcUaClient.Contracts + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (Phase 3 depends on this) + +**Files:** +- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NamespaceMap.cs` → `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj` — add `PackageReference` to `OPCFoundation.NetStandard.Opc.Ua.Client` (NamespaceMap needs `ISession`/`NamespaceTable`/`NodeId`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` — add `` (if not already present) + +**Step 1: Move the file** +```bash +git mv src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NamespaceMap.cs \ + src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs +``` + +**Step 2: Change visibility** +In the moved file, change `internal sealed class NamespaceMap` → `public sealed class NamespaceMap` and add an XML `` doc declaring the visibility change (one-line at the class level is fine — there are detailed remarks already). + +Also change any `internal` static methods that are part of the public surface (`FromSession`, `FromTable`, `TryResolve`, `ToStableReference`, etc.) to `public`. Keep the private ctor private. + +**Step 3: Add Opc.Ua reference to Contracts csproj** +Replace the empty Contracts csproj with: +```xml + + + net10.0 + enable + enable + true + + + + + +``` +(version pinned via `Directory.Packages.props` — confirm by `grep -n OPCFoundation Directory.Packages.props`.) + +**Step 4: Ensure runtime driver references Contracts** +Check `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` already has a `` line. If not, add it. + +**Step 5: Build the driver to verify the move** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` +Expected: 0 errors. (Existing `OpcUaClientDriver.cs` uses `NamespaceMap.FromSession`, `TryResolve`, `ToStableReference` — those calls keep working with no source change because the namespace is unchanged.) + +**Step 6: Build the full solution + run OpcUaClient tests** +Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` +Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj` +Expected: all green. If the tests reference `NamespaceMap` directly (likely, since it's namespace-mapping-critical), they should still resolve because the namespace is unchanged. + +**Step 7: Commit** +```bash +git add -A +git commit -m "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." +``` + +--- + +## Task 3 — Phase 3a: Scaffold Driver.OpcUaClient.Browser project + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 7 (different Browser project) + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj` +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add new project + +**Step 1: Create csproj** +```xml + + + net10.0 + enable + enable + true + + + + + + + + + + + +``` + +**Step 2: Add project to slnx** +Open `ZB.MOM.WW.OtOpcUa.slnx`, locate the `` block, add: +```xml + +``` +in alphabetical position. Match the surrounding format (slnx uses `.slnx` XML, not legacy GUID-based `.sln`). + +**Step 3: Verify build** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` +Expected: 0 errors. Project compiles empty. + +**Step 4: Commit** +```bash +git add -A +git commit -m "feat(opcuaclient.browser): scaffold project + slnx entry" +``` + +--- + +## Task 4 — Phase 3b: Implement OpcUaClientBrowseSession + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7 (different driver) + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs` + +**Step 1: Write the session class** +```csharp +using Opc.Ua; +using Opc.Ua.Client; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // NamespaceMap (now in Contracts) + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +/// +/// Live one-level-at-a-time browse over an OPC UA server session opened by +/// . Serializes all browse calls on +/// because Session.BrowseAsync is not safe to call +/// concurrently from multiple threads (same constraint as OpcUaClientDriver). +/// +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 bool _disposed; + + public Guid Token { get; } = Guid.NewGuid(); + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId) + { + _session = session; + _nsMap = nsMap; + _rootNodeId = rootNodeId; + } + + public Task> RootAsync(CancellationToken ct) + => BrowseOneLevelAsync(_rootNodeId, ct); + + public Task> ExpandAsync(string stableNodeId, CancellationToken ct) + { + if (!NamespaceMap.TryResolve(_session, stableNodeId, out var nodeId)) + throw new ArgumentException( + $"Cannot resolve NodeId '{stableNodeId}' against the live session.", nameof(stableNodeId)); + return BrowseOneLevelAsync(nodeId, ct); + } + + /// OPC UA variables don't have hierarchical sub-attributes the picker cares about, + /// so this always returns an empty list. Galaxy uses this path; OPC UA leaves it inert. + public Task> AttributesAsync(string nodeId, CancellationToken ct) + => Task.FromResult>(Array.Empty()); + + private async Task> BrowseOneLevelAsync(NodeId node, CancellationToken ct) + { + ObjectDisposedException.ThrowIf(_disposed, this); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var desc = new BrowseDescriptionCollection + { + new() + { + NodeId = node, + 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(null, null, 0, desc, ct).ConfigureAwait(false); + if (resp.Results.Count == 0) return Array.Empty(); + var refs = resp.Results[0].References; + var cp = resp.Results[0].ContinuationPoint; + while (cp is { Length: > 0 }) + { + var next = await _session.BrowseNextAsync(null, false, [cp], ct).ConfigureAwait(false); + if (next.Results.Count == 0) break; + if (next.Results[0].References is { Count: > 0 } more) refs.AddRange(more); + cp = next.Results[0].ContinuationPoint; + } + + LastUsedUtc = DateTime.UtcNow; + return refs.Select(ToBrowseNode).ToList(); + } + finally { _gate.Release(); } + } + + private BrowseNode ToBrowseNode(ReferenceDescription rf) + { + var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris); + var isObject = rf.NodeClass == NodeClass.Object; + return new BrowseNode( + NodeId: _nsMap.ToStableReference(childId), + DisplayName: rf.DisplayName?.Text + ?? rf.BrowseName?.Name + ?? childId.ToString() + ?? "(unnamed)", + Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf, + HasChildrenHint: isObject); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { if (_session is Session s) await s.CloseAsync().ConfigureAwait(false); } + catch { /* best-effort */ } + try { _session.Dispose(); } catch { /* best-effort */ } + _gate.Dispose(); + } +} +``` + +**Step 2: Verify build** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` +Expected: 0 errors. + +**Step 3: Commit** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs +git commit -m "feat(opcuaclient.browser): add lazy browse session impl" +``` + +--- + +## Task 5 — Phase 3c: Implement OpcUaClientDriverBrowser factory + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs` + +**Step 1: Write the factory** +```csharp +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; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +/// +/// 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. +/// +public sealed class OpcUaClientDriverBrowser : IDriverBrowser +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + + public OpcUaClientDriverBrowser(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public string DriverType => "OpcUaClient"; + + public async Task OpenAsync(string configJson, CancellationToken ct) + { + var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException("OpcUaClient options deserialized to null."); + + // Use EndpointUrls[0] if set, else EndpointUrl (same precedence as the runtime driver). + 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]."); + + var appConfig = await BuildBrowseAppConfigurationAsync(ct).ConfigureAwait(false); + var identity = BuildBrowseUserIdentity(opts); + + var perEndpointBudget = TimeSpan.FromSeconds( + Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30)); + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + 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); + + // Build the namespace map from the live session for stable NodeId encoding. + var nsMap = NamespaceMap.FromSession(session); + + var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot) + ? ObjectIds.ObjectsFolder + : NodeId.Parse(session.MessageContext, opts.BrowseRoot); + + _logger.LogInformation( + "AdminUI browse session opened against {Endpoint} (policy {Policy}, mode {Mode})", + endpoint, opts.SecurityPolicy, opts.SecurityMode); + + return new OpcUaClientBrowseSession(session, nsMap, rootNodeId); + } + + /// + /// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from + /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Keeps + /// browse-time cert trust decisions out of the deployed driver's trust store. + /// + private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) + { +#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; + } + + 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)), + // Certificate auth path can call the runtime driver's BuildCertificateIdentity if + // exposed; for v1 surface a clear failure so the operator uses a different auth + // mode for browsing. (Browser-time certificate-token auth deferred to follow-up.) + OpcUaAuthType.Certificate => throw new InvalidOperationException( + "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username."), + _ => new UserIdentity(new AnonymousIdentityToken()), + }; + + private static async Task 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; + } + + 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)), + }; + + private static string ShortName(string uri) => + uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)"; +} +``` + +**Step 2: Verify build** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` +Expected: 0 errors. + +**Step 3: Commit** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +git commit -m "feat(opcuaclient.browser): add transient-session factory" +``` + +--- + +## Task 6 — Phase 3d: OpcUaClient.Browser tests (against opc-plc fixture) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10 (different driver) + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs` +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add the test project + +**Step 1: csproj (mirror the existing OpcUaClient.Tests pattern)** +Look at `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj` for the test SDK + xUnit + Shouldly package set and copy verbatim. References should include: +```xml + + + +``` + +**Step 2: OpcUaClientDriverBrowserTests.cs** (unit-flavored — no live server) +```csharp +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; + +public class OpcUaClientDriverBrowserTests +{ + private readonly OpcUaClientDriverBrowser _sut = new(); + + [Fact] + public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient"); + + [Fact] + public async Task OpenAsync_with_empty_endpoint_throws() + { + var json = """{"EndpointUrl":"","EndpointUrls":[]}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken).AsTask()); + ex.Message.ShouldContain("EndpointUrl"); + } + + [Fact] + public async Task OpenAsync_with_null_json_throws() + { + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync("null", TestContext.Current.CancellationToken).AsTask()); + ex.Message.ShouldContain("null"); + } + + [Fact] + public async Task OpenAsync_with_certificate_auth_throws_clear_message() + { + var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken).AsTask()); + ex.Message.ShouldContain("Certificate"); + } +} +``` + +Note: `TestContext.Current.CancellationToken` is xUnit v3 style. If the existing OpcUaClient.Tests uses v2 (`async Task` without parameter), match that. Skim the existing test file to be sure. + +**Step 3: OpcUaClientBrowseSessionTests.cs** (live — gated on opc-plc fixture) +```csharp +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; + +/// +/// Live-server tests against the opc-plc Docker fixture at +/// opc.tcp://10.100.0.35:50000. Bring up with +/// lmxopcua-fix up opcuaclient before running. +/// +/// Skipped automatically when the fixture is unreachable. +[Trait("Category", "RequiresOpcPlc")] +public class OpcUaClientBrowseSessionTests +{ + private static string Endpoint => + Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000"; + + private static string ConfigJson => $$""" + { + "EndpointUrl":"{{Endpoint}}", + "SecurityPolicy":"None", + "SecurityMode":"None", + "AuthType":"Anonymous", + "SessionTimeout":"00:01:00", + "Timeout":"00:00:10", + "PerEndpointConnectTimeout":"00:00:10" + } + """; + + [Fact] + public async Task RootAsync_returns_at_least_one_node() + { + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken); + var roots = await session.RootAsync(TestContext.Current.CancellationToken); + roots.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task ExpandAsync_round_trips_stable_NodeId() + { + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken); + var roots = await session.RootAsync(TestContext.Current.CancellationToken); + 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, TestContext.Current.CancellationToken); + children.ShouldNotBeNull(); + } + + [Fact] + public async Task AttributesAsync_is_empty_for_opcuaclient() + { + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken); + var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", + TestContext.Current.CancellationToken); + attrs.ShouldBeEmpty(); + } +} +``` + +**Step 4: Add test project to slnx** +Add the new test csproj path to `ZB.MOM.WW.OtOpcUa.slnx` (under the `tests/Drivers/` solution folder). + +**Step 5: Run tests** +Run: `lmxopcua-fix up opcuaclient` (from PowerShell on the dev host) +Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` +Expected: all green. If the fixture is unreachable, the `RequiresOpcPlc` tests fail; the unit-only tests still pass. + +**Step 6: Commit** +```bash +git add -A +git commit -m "test(opcuaclient.browser): unit + opc-plc live coverage" +``` + +--- + +## Task 7 — Phase 4a: Scaffold Driver.Galaxy.Browser project + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 3 (different driver) + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj` +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add new project + +**Step 1: Create csproj** +```xml + + + net10.0 + enable + enable + true + + + + + + + + + + +``` +(Pin via `Directory.Packages.props` — verify the gateway client package name matches the one consumed elsewhere.) + +**Step 2: Add to slnx (alphabetical position under `src/Drivers/`)** + +**Step 3: Build clean** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` +Expected: 0 errors. + +**Step 4: Commit** +```bash +git add -A +git commit -m "feat(galaxy.browser): scaffold project + slnx entry" +``` + +--- + +## Task 8 — Phase 4b: Implement GalaxyBrowseSession + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4 (different driver) + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs` + +**Step 1: Write the session class** +```csharp +using System.Collections.Concurrent; +using MxGateway.Client; +using MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; + +/// +/// Lazy browse over a Galaxy via the gateway client's . +/// Stateless gRPC under the hood; per-call locking lives inside . +/// Tracks already-handed-out nodes by tag_name so +/// and can dispatch against the right cached handle. +/// +internal sealed class GalaxyBrowseSession : IBrowseSession +{ + private readonly MxGatewaySession _session; + private readonly GalaxyRepositoryClient _client; + private readonly ConcurrentDictionary _byTagName = new(); + private bool _disposed; + + public Guid Token { get; } = Guid.NewGuid(); + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client) + { + _session = session; + _client = client; + } + + public async Task> RootAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var roots = await _client.BrowseAsync(new BrowseChildrenOptions(), ct).ConfigureAwait(false); + LastUsedUtc = DateTime.UtcNow; + return Project(roots); + } + + public async Task> ExpandAsync(string tagName, CancellationToken ct) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_byTagName.TryGetValue(tagName, out var node)) + throw new ArgumentException( + $"Galaxy object '{tagName}' is not in the current browse-session cache. " + + "Re-open the browser or expand its parent first.", nameof(tagName)); + await node.ExpandAsync(ct).ConfigureAwait(false); + LastUsedUtc = DateTime.UtcNow; + return Project(node.Children); + } + + /// + /// Fetches the attributes of one Galaxy object via + /// DiscoverHierarchyAsync(MaxDepth=0, RootTagName, IncludeAttributes=true). + /// This returns the object itself (single-element list) with attributes populated. + /// + public async Task> AttributesAsync(string tagName, CancellationToken ct) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var rows = await _client.DiscoverHierarchyAsync( + new DiscoverHierarchyOptions + { + RootTagName = tagName, + MaxDepth = 0, + IncludeAttributes = true, + }, ct).ConfigureAwait(false); + + LastUsedUtc = DateTime.UtcNow; + var obj = rows.FirstOrDefault(); + if (obj is null) return Array.Empty(); + + var result = new List(obj.Attributes.Count); + foreach (var attr in obj.Attributes) + { + result.Add(new AttributeInfo( + Name: attr.AttributeName, + DriverDataType: attr.DataTypeName ?? attr.MxDataType.ToString(), + IsArray: attr.IsArray, + SecurityClass: MapSecurityClass(attr.SecurityClassification))); + } + return result; + } + + private IReadOnlyList Project(IReadOnlyList nodes) + { + var result = new List(nodes.Count); + foreach (var n in nodes) + { + var tagName = n.Object.TagName; + _byTagName[tagName] = n; + result.Add(new BrowseNode( + NodeId: tagName, + DisplayName: string.IsNullOrEmpty(n.Object.ContainedName) + ? n.Object.TagName + : n.Object.ContainedName, + Kind: BrowseNodeKind.Folder, // Galaxy objects always expandable in tree + HasChildrenHint: n.HasChildrenHint)); + } + return result; + } + + // Galaxy security_classification raw integer mapping (per docs/GalaxyRepository.md). + // Same buckets the runtime SecurityMap uses — strings are display-only. + private static string MapSecurityClass(int raw) => raw switch + { + 0 => "FreeAccess", + 1 => "Operate", + 2 => "Tune", + 3 => "Configure", + 4 => "ViewOnly", + _ => $"Unknown({raw})", + }; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await _session.DisposeAsync().ConfigureAwait(false); } + catch { /* best-effort */ } + } +} +``` + +**Step 2: Verify build** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` +Expected: 0 errors. If the `MxGateway.Client` namespace differs from what `GatewayGalaxyHierarchySource.cs` already uses, copy the exact namespaces from that file. + +**Step 3: Commit** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs +git commit -m "feat(galaxy.browser): add lazy browse session with attribute fetch" +``` + +--- + +## Task 9 — Phase 4c: Implement GalaxyDriverBrowser factory + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs` + +**Step 1: Write the factory** +```csharp +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using 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; + +/// +/// Opens transient gateway sessions for the AdminUI address picker. Distinct +/// ClientName from the runtime driver so the gateway can attribute load. +/// +public sealed class GalaxyDriverBrowser : IDriverBrowser +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + + public GalaxyDriverBrowser(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public string DriverType => "Galaxy"; + + public async Task OpenAsync(string configJson, CancellationToken ct) + { + var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException("Galaxy options deserialized to null."); + + if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint)) + throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint."); + if (string.IsNullOrWhiteSpace(opts.MxAccess.GalaxyName)) + throw new InvalidOperationException("Galaxy browser requires MxAccess.GalaxyName."); + + var clientOpts = new MxGatewayClientOptions + { + Endpoint = new Uri(opts.Gateway.Endpoint), + ApiKey = opts.Gateway.ApiKeySecretRef, // browser is a one-shot — pass through verbatim + ClientName = "OtOpcUa-AdminUI-Browse", + UseTls = opts.Gateway.UseTls, + }; + + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + connectCts.CancelAfter(TimeSpan.FromSeconds(30)); + + MxGatewaySession session = await MxGatewaySession.OpenAsync(clientOpts, connectCts.Token).ConfigureAwait(false); + try + { + var repoClient = session.GalaxyRepository(opts.MxAccess.GalaxyName); + _logger.LogInformation( + "AdminUI Galaxy browse session opened against {Endpoint} galaxy {Galaxy}", + opts.Gateway.Endpoint, opts.MxAccess.GalaxyName); + return new GalaxyBrowseSession(session, repoClient); + } + catch + { + await session.DisposeAsync().ConfigureAwait(false); + throw; + } + } +} +``` + +**NB**: the precise gateway client API names (`MxGatewaySession.OpenAsync`, `session.GalaxyRepository(...)`, `MxGatewayClientOptions.ApiKey`/`UseTls`) MUST be verified against the actual `ZB.MOM.WW.MxGateway.Client` namespace before commit. If the existing in-repo `GatewayGalaxyHierarchySource.cs` already opens a session, **use that exact code path** as the source of truth and adapt — don't invent a parallel pattern. Mirror its wiring. + +**Step 2: Verify build** +Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` +Expected: 0 errors. + +**Step 3: Commit** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs +git commit -m "feat(galaxy.browser): add transient gateway-session factory" +``` + +--- + +## Task 10 — Phase 4d: Galaxy.Browser tests (fake transport) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 6 + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs` +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` + +**Step 1: csproj** (mirror existing Galaxy.Tests pattern; reference the fake-transport project from the gateway client repo if accessible, OR copy the fake into this test project — confirm what the existing Galaxy.Tests does). + +**Step 2: GalaxyBrowseSessionTests.cs** — uses fake `IGalaxyRepositoryClientTransport` (the gateway client tests have one at `tests/.../FakeGalaxyRepositoryTransport.cs` in `/Users/dohertj2/Desktop/MxAccessGateway/clients/dotnet/`). Either: + - Reference it from there (if NuGet-published as part of `*.Testing`) + - Or build a minimal in-test fake satisfying the transport contract + +Tests: +- `RootAsync_returns_projected_browse_nodes` — fake returns 2 canned `GalaxyObject` rows → SessionRootAsync → 2 BrowseNodes with correct TagName/ContainedName/HasChildrenHint +- `ExpandAsync_unknown_tag_throws_ArgumentException` +- `ExpandAsync_cached_node_returns_children` — fake child rows wired to a parent's gobject_id +- `AttributesAsync_returns_projected_attribute_infos` — fake DiscoverHierarchy returns one object with attributes +- `Project_uses_TagName_when_ContainedName_empty` +- `MapSecurityClass_buckets_match_runtime_SecurityMap` + +**Step 3: GalaxyDriverBrowserTests.cs** — bad JSON, missing endpoint, missing galaxy name → clear errors. + +**Step 4: Add test project to slnx** + +**Step 5: Run** +Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/` +Expected: all green. No fixture needed. + +**Step 6: Commit** +```bash +git add -A +git commit -m "test(galaxy.browser): unit coverage with fake transport" +``` + +--- + +## Task 11 — Phase 5a: BrowseSessionRegistry + reaper + service in AdminUI + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 3-10 (different files) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add ProjectReference to Commons (if not present) + +**Step 1: BrowseSessionRegistry** +```csharp +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// In-process registry of live browse sessions, keyed by opaque . +/// Singleton. Process-local; sticky-cookie routing keeps tokens bound to the AdminUI +/// replica that created them. +/// +public sealed class BrowseSessionRegistry +{ + private readonly ConcurrentDictionary _sessions = new(); + + public void Register(IBrowseSession session) => _sessions[session.Token] = session; + + public bool TryGet(Guid token, out IBrowseSession session) => + _sessions.TryGetValue(token, out session!); + + /// Removes the session from the registry without disposing it. + public bool TryRemove(Guid token, out IBrowseSession session) => + _sessions.TryRemove(token, out session!); + + /// Snapshot for the reaper. Stable across iteration. + public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() => + _sessions.Select(kv => (kv.Key, kv.Value)).ToList(); +} +``` + +**Step 2: BrowseSessionReaper** +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Periodically evicts browse sessions idle for more than . +/// 30s tick, 2 min idle window. Disposes evicted sessions outside the dictionary so +/// a concurrent BrowseAsync sees the session disappear cleanly and gets a +/// translated NotFound error from the service layer. +/// +public sealed class BrowseSessionReaper( + BrowseSessionRegistry registry, + ILogger logger) : BackgroundService +{ + public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2); + public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30); + + 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."); + } + } + + // Final drain on shutdown. + await DrainAllAsync().ConfigureAwait(false); + } + + 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 { /* best-effort */ } + logger.LogDebug("Browse session {Token} closed reason=shutdown", token); + } + } +} +``` + +**Step 3: IBrowserSessionService + impl** +```csharp +// IBrowserSessionService.cs +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token); + +public interface IBrowserSessionService +{ + Task OpenAsync(string driverType, string configJson, CancellationToken ct); + Task> RootAsync(Guid token, CancellationToken ct); + Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct); + Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct); + Task CloseAsync(Guid token); +} + +public sealed class BrowseSessionNotFoundException(Guid token) + : InvalidOperationException($"Browse session {token} not found (may have been reaped)."); +``` + +```csharp +// BrowserSessionService.cs +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +public sealed class BrowserSessionService( + IEnumerable browsers, + BrowseSessionRegistry registry, + ILogger logger) : IBrowserSessionService +{ + public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20); + + private readonly IReadOnlyDictionary _browsersByType = + browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase); + + public async Task 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); + } + } + + public Task> RootAsync(Guid token, CancellationToken ct) => + InvokeAsync(token, ct, (s, c) => s.RootAsync(c)); + + public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => + InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c)); + + public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => + InvokeAsync>(token, ct, (s, c) => s.AttributesAsync(nodeId, c)); + + public async Task CloseAsync(Guid token) + { + if (!registry.TryRemove(token, out var session)) return; + try { await session.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ } + logger.LogDebug("Browse session {Token} closed reason=user-close", token); + } + + private async Task InvokeAsync( + Guid token, CancellationToken callerCt, Func> 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); + } +} +``` + +**Step 4: Build clean** +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` +Expected: 0 errors. + +**Step 5: Commit** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/ +git commit -m "feat(adminui): in-process browse session registry + TTL reaper + service" +``` + +--- + +## Task 12 — Phase 5b: Wire DI in AddAdminUI() + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (depends on Task 11) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:38-43` (the `AddAdminUI` method) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add ProjectReferences to the two `*.Browser` projects + +**Step 1: Add project references** +```xml + + +``` + +**Step 2: Extend AddAdminUI** +```csharp +public static IServiceCollection AddAdminUI(this IServiceCollection services) +{ + services.AddRazorComponents().AddInteractiveServerComponents(); + services.AddOtOpcUaDriverStatusServices(); + + // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md + services.AddSingleton(); + services.AddHostedService(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + return services; +} +``` + +Imports at top: `using ZB.MOM.WW.OtOpcUa;` if needed for full-qualified resolution. + +**Step 3: Build full solution** +Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` +Expected: 0 errors. + +**Step 4: Commit** +```bash +git add -A +git commit -m "feat(adminui): register browse services in AddAdminUI" +``` + +--- + +## Task 13 — Phase 5c: Tests for registry, reaper, service + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 6, 10 + +**Files:** +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs` +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs` +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs` +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs` — shared test double +- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (if a ProjectReference to Commons/Browsing isn't already transitive) + +**Step 1: FakeBrowseSession** +```csharp +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +internal sealed class FakeBrowseSession : IBrowseSession +{ + public Guid Token { get; } = Guid.NewGuid(); + public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow; + public bool Disposed { get; private set; } + public Func>>? RootHandler; + public Func>>? ExpandHandler; + + public Task> RootAsync(CancellationToken ct) + => RootHandler?.Invoke(ct) ?? Task.FromResult>(Array.Empty()); + public Task> ExpandAsync(string nodeId, CancellationToken ct) + => ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty()); + public Task> AttributesAsync(string nodeId, CancellationToken ct) + => Task.FromResult>(Array.Empty()); + public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } +} +``` + +**Step 2: BrowseSessionRegistryTests** — register/get/remove; concurrent Register from N tasks → all visible; TryGet on unknown returns false; TryRemove on unknown returns false. + +**Step 3: BrowseSessionReaperTests** +- `ReapOnceAsync_evicts_idle_sessions`: register a FakeBrowseSession with `LastUsedUtc = UtcNow - 3 min`; call `reaper.ReapOnceAsync(default)`; assert session is no longer in registry and `Disposed == true` +- `ReapOnceAsync_preserves_non_idle_sessions`: `LastUsedUtc = UtcNow`; reap; still in registry +- `ReapOnceAsync_concurrent_removal_does_not_double_dispose`: pre-remove the session; reap should be a no-op for that token + +**Step 4: BrowserSessionServiceTests** — using a fake `IDriverBrowser`: +- `OpenAsync_unknown_driver_returns_Ok_false` +- `OpenAsync_happy_path_returns_token_and_registers` +- `RootAsync_unknown_token_throws_BrowseSessionNotFoundException` +- `RootAsync_invokes_session_RootAsync` +- `RootAsync_enforces_per_call_timeout`: fake session that delays 30s → assert throws OperationCanceledException +- `CloseAsync_removes_and_disposes_session` + +**Step 5: Run** +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` +Expected: existing 51 + new ~12 = ~63 green. + +**Step 6: Commit** +```bash +git add -A +git commit -m "test(adminui): browse session registry, reaper, service" +``` + +--- + +## Task 14 — Phase 6: Shared DriverBrowseTree.razor component + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Phase 7 depends on this) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor` +- Optional: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Components/DriverBrowseTreeTests.cs` (bUnit, only if bUnit already used; otherwise skip — covered by manual smoke + picker integration in Task 15/16) + +**Step 1: DriverBrowseTree.razor** +```razor +@* Lazy tree component with per-node text filter. Driver-agnostic — consumes + IBrowserSessionService for root/expand. Selected leaf is bound back via + SelectedNodeId. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@inject IBrowserSessionService BrowserService + +
+ @if (_loading) + { +
Loading…
+ } + else if (_error is not null) + { +
@_error
+ } + else if (_roots is null || _roots.Count == 0) + { +
No nodes.
+ } + else + { + @foreach (var n in _roots) { @RenderNode(n, 0) } + } +
+ +@code { + [Parameter, EditorRequired] public Guid SessionToken { get; set; } + [Parameter] public string SelectedNodeId { get; set; } = ""; + [Parameter] public EventCallback OnNodeSelected { get; set; } + + private bool _loading = true; + private string? _error; + private List? _roots; + + 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 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) + { + SelectedNodeId = 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 = SelectedNodeId == item.Node.NodeId ? "bg-primary-subtle" : ""; +
+ @if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint) + { + + } + else + { + + } + @item.Node.DisplayName + @if (item.Node.Kind == BrowseNodeKind.Leaf) + { + leaf + } +
+ @if (item.Expanded && item.Loading) + { +
+ Loading… +
+ } + else if (item.Expanded && item.Error is not null) + { +
+ @item.Error +
+ } + else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 }) + { + + @foreach (var c in FilterChildren(item)) + { + @RenderNode(c, depth + 1) + } + } + }; + + private async Task ToggleAsync(TreeItem item) + { + item.Expanded = !item.Expanded; + if (item.Expanded && !item.Loaded) await ExpandAsync(item); + } + + private static IEnumerable 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? Children { get; set; } + public string? Filter { get; set; } + } +} +``` + +**Step 2: Build** +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` +Expected: 0 errors. Razor compilation may need to round-trip through the SDK. + +**Step 3: Commit** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor +git commit -m "feat(adminui): shared lazy DriverBrowseTree component with per-node filter" +``` + +--- + +## Task 15 — Phase 7a: Wire OpcUaClient picker body to browser + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 16 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor` + +**Step 1: Replace body** (preserve manual entry as a fallback; wrap browse behind DriverOperator gate) + +Add the necessary `@implements IAsyncDisposable`, `@using`s for browse + auth, and parameters `[Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}";` so the picker knows where to read the form JSON. Inject the browse service + auth. + +```razor +@* OPC UA Client address picker: + 1. Manual NodeId entry (existing, 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 + +
+
+ + +
+ OPC UA NodeId string, e.g. ns=2;s=Channel.Device.Tag or + i=1001. Use Browse to navigate the remote server. +
+
+
+ +@if (_canOperate) +{ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + } + @if (_openError is not null) { @TruncatedError() } +
+ + @if (_token != Guid.Empty) + { +
+ +
+ } +} + +
+ Result: + @_built +
+ +@code { + [Parameter] public string CurrentAddress { get; set; } = ""; + [Parameter] public EventCallback CurrentAddressChanged { get; set; } + [Parameter, EditorRequired] public Func 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 async Task OnInitializedAsync() + { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + _canOperate = authResult.Succeeded; + _built = _nodeId; + 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() + { + _built = _nodeId; + await CurrentAddressChanged.InvokeAsync(_built); + } + + private string TruncatedError() => + _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError); + + public async ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + try { await BrowserService.CloseAsync(_token); } catch { /* circuit teardown */ } + } + } +} +``` + +**Step 2: Update the parent OpcUaClient driver page to pass `GetConfigJson`** +Locate the parent that hosts `` and `` (likely `src/Server/.../Components/Pages/Drivers/OpcUaClientDriverPage.razor`). Pass a `GetConfigJson` parameter that serializes the live form state. Example one-liner: `GetConfigJson="() => JsonSerializer.Serialize(_form.ToOptions())"` using the existing form model. + +**Step 3: Build + smoke** +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` +Expected: 0 errors. + +Manual smoke (later in Task 18): launch AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, navigate, pick a Variable, save, verify the stored NodeId reads cleanly via the Client CLI. + +**Step 4: Commit** +```bash +git add -A +git commit -m "feat(adminui): wire OpcUaClient picker to live browser" +``` + +--- + +## Task 16 — Phase 7b: Wire Galaxy picker body to browser + attribute side-panel + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 15 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` + +**Step 1: Replace body** (manual entry stays as fallback; gated Browse + tree + attribute side-panel when an object is selected) + +Structure: manual tag/attribute inputs (existing, always shown), then DriverOperator-gated browse panel. The browse panel uses `DriverBrowseTree` for objects; when the user selects an object, the picker fetches its attributes via `BrowserService.AttributesAsync(_token, selectedTag)` and renders them as a side-panel list. Selecting an attribute commits `_built = $"{selectedTag}.{attr.Name}"`. + +```razor +@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 + +
+
+ + +
+
+ + +
+
+ +@if (_canOperate) +{ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + } + @if (_openError is not null) { @TruncatedError() } +
+ + @if (_token != Guid.Empty) + { +
+
+ + +
+
+ +
+ @if (_attrsLoading) { } + else if (_attrsError is not null) { @_attrsError } + else if (_attrs is null) { Pick an object. } + else if (_attrs.Count == 0) { No attributes. } + else + { + @foreach (var a in _attrs) + { + var sel = _attributeName == a.Name ? "bg-primary-subtle" : ""; +
+ @a.Name + @a.DriverDataType@(a.IsArray ? "[]" : "") · @a.SecurityClass +
+ } + } +
+
+
+ } +} + +
+ Result: + @_built +
+ +@code { + [Parameter] public string CurrentAddress { get; set; } = ""; + [Parameter] public EventCallback CurrentAddressChanged { get; set; } + [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; + + private string _tagName = ""; + private string _attributeName = ""; + private string _built = ""; + private Guid _token = Guid.Empty; + private bool _opening, _attrsLoading, _canOperate; + private string? _openError, _attrsError; + private IReadOnlyList? _attrs; + + protected override async Task OnInitializedAsync() + { + var auth = await AuthState.GetAuthenticationStateAsync(); + var ar = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + _canOperate = ar.Succeeded; + _built = Build(); await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; _openError = null; StateHasChanged(); + try + { + var result = await BrowserService.OpenAsync("Galaxy", GetConfigJson() ?? "{}", 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() + { + _built = Build(); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private string Build() => + string.IsNullOrWhiteSpace(_tagName) ? "" : + string.IsNullOrWhiteSpace(_attributeName) ? _tagName : $"{_tagName}.{_attributeName}"; + + private string TruncatedError() => + _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError); + + public async ValueTask DisposeAsync() + { + if (_token != Guid.Empty) { try { await BrowserService.CloseAsync(_token); } catch { } } + } +} +``` + +**Step 2: Update GalaxyDriverPage** to pass `GetConfigJson` (mirror Task 15). + +**Step 3: Build clean** +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` +Expected: 0 errors. + +**Step 4: Commit** +```bash +git add -A +git commit -m "feat(adminui): wire Galaxy picker to live browser + attribute side-panel" +``` + +--- + +## Task 17 — Phase 8a: opc-plc integration test (gated) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 18 + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj` (mirror existing OpcUaClient.IntegrationTests) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs` +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` + +**Step 1: csproj** — copy from existing OpcUaClient.IntegrationTests; ProjectRefs include Browser + Commons. + +**Step 2: BrowseRoundTripTests.cs** +```csharp +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; + +[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")] +public class BrowseRoundTripTests +{ + [Fact] + public async Task Three_level_expand_round_trip_resolves_back() + { + // (1) Open against opc-plc. (2) Drill down 3 levels by alternately expanding the + // first Folder. (3) Take the deepest returned NodeId string. (4) Re-ExpandAsync(string) + // — must succeed and yield a list (children OR empty for a leaf-like folder). + // + // This regression-tests the namespace-stable encoding: the string returned by step + // (3) must round-trip cleanly through TryResolve against the same session. + var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") + ?? "opc.tcp://10.100.0.35:50000"; + var json = $$""" + { + "EndpointUrl":"{{endpoint}}", + "SecurityPolicy":"None","SecurityMode":"None","AuthType":"Anonymous", + "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(); + 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 + var children = await session.ExpandAsync(current!.NodeId, TestContext.Current.CancellationToken); + children.ShouldNotBeNull(); + } +} +``` + +**Step 3: Run** +Run: `lmxopcua-fix up opcuaclient` +Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/` +Expected: green when the fixture is up; skipped/failed obviously when down. + +**Step 4: Commit** +```bash +git add -A +git commit -m "test(opcuaclient.browser): opc-plc integration round-trip" +``` + +--- + +## Task 18 — Phase 8b: Manual smoke, CLAUDE.md update, final commit + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 17 + +**Files:** +- Modify: `CLAUDE.md` (only if the browse pipeline warrants a one-liner in the Testing or Architecture section) +- Modify (optional): `docs/Client.CLI.md` cross-ref if relevant + +**Step 1: Full solution build + test sweep** +```bash +dotnet build ZB.MOM.WW.OtOpcUa.slnx +dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ +# OPC UA test suites need lmxopcua-fix up opcuaclient first +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ +``` +Expected: all green; OPC UA tests only if fixture up. + +**Step 2: Manual smoke** +- `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host` +- Sign in to AdminUI as a `DriverOperator` +- Open an OpcUaClient driver edit page, fill in `opc.tcp://10.100.0.35:50000`, click **Browse remote server** → tree appears → expand → pick a variable → "Use this address" → save +- Open a Galaxy driver edit page (if a galaxy is reachable), click **Browse galaxy** → tree appears → pick object → attribute side-panel appears → pick attribute → save +- Verify in `Client CLI`: + ```bash + dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=" + ``` + +**Step 3: CLAUDE.md (optional, one-liner)** +Append a single sentence to the "Testing" section: "Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`." + +**Step 4: Final commit** +```bash +git add -A +git commit -m "docs: link driver-browsers design from CLAUDE.md" || echo "no doc changes" +``` + +--- + +## Verification & gates + +After Task 18 the branch should: +- Build clean (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) +- AdminUI tests: existing 51 + new ~12 = ~63 green +- Galaxy.Browser.Tests: green without fixture +- OpcUaClient.Browser.Tests + IntegrationTests: green with opc-plc up +- Manual smoke validates the end-to-end UX + +## Risk hot-spots (for reviewers) + +1. **NamespaceMap visibility change.** Runtime driver references `NamespaceMap.FromSession`/`TryResolve`/`ToStableReference` — they all need to remain accessible (now `public` not `internal`). Check `Driver.OpcUaClient.Tests` keeps passing post-Phase 2. +2. **Per-call timeout cancellation.** `BrowserSessionService.InvokeAsync` uses a linked CTS; if the inner session impl doesn't propagate the token to the SDK, a wedged remote stalls the click. OpcUa session impl threads `ct` into `BrowseAsync` — verified. Galaxy session threads `ct` into both `BrowseAsync` and `LazyBrowseNode.ExpandAsync` — verified. +3. **Reaper race with in-flight ExpandAsync.** The reaper does `TryRemove` then `DisposeAsync` outside the dictionary; a concurrent `ExpandAsync` already inside the session's gate will throw `ObjectDisposedException` from `OpcUaClientBrowseSession.BrowseOneLevelAsync` — surface as a clear UI error. Test coverage in Task 13 explicitly asserts this. +4. **Galaxy gateway client API names.** Task 9 names (`MxGatewaySession.OpenAsync`, `session.GalaxyRepository(name)`, `MxGatewayClientOptions.ApiKey`) MUST be verified against the actual client surface — mirror `src/Drivers/.../Galaxy/Browse/GatewayGalaxyHierarchySource.cs` for the exact connect path used in production. +5. **Certificate-auth in OpcUaClient browser.** v1 explicitly rejects with a clear message — verify the error surfaces cleanly to the UI rather than crashing the picker. diff --git a/docs/plans/2026-05-28-driver-browsers-plan.md.tasks.json b/docs/plans/2026-05-28-driver-browsers-plan.md.tasks.json new file mode 100644 index 00000000..86ba86cc --- /dev/null +++ b/docs/plans/2026-05-28-driver-browsers-plan.md.tasks.json @@ -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" +}