Files
lmxopcua/docs/plans/2026-05-28-driver-browsers-plan.md
T
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

2059 lines
84 KiB
Markdown

# 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;
/// <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);
}
```
`IBrowseSession.cs`:
```csharp
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);
}
```
`BrowseNode.cs`:
```csharp
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);
```
**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 `<ProjectReference Include="../ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />` (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 `<summary>` 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
</Project>
```
(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 `<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\...">` 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
<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>
```
**Step 2: Add project to slnx**
Open `ZB.MOM.WW.OtOpcUa.slnx`, locate the `<Folder Name="/src/Drivers/">` block, add:
```xml
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
```
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;
/// <summary>
/// Live one-level-at-a-time browse over an OPC UA server session opened by
/// <see cref="OpcUaClientDriverBrowser"/>. Serializes all browse calls on
/// <see cref="_gate"/> because <c>Session.BrowseAsync</c> is not safe to call
/// concurrently from multiple threads (same constraint as <c>OpcUaClientDriver</c>).
/// </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 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<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
=> BrowseOneLevelAsync(_rootNodeId, ct);
public Task<IReadOnlyList<BrowseNode>> 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);
}
/// <summary>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.</summary>
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
private async Task<IReadOnlyList<BrowseNode>> 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<BrowseNode>();
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;
/// <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;
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
}
public string DriverType => "OpcUaClient";
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
{
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(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);
}
/// <summary>
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Keeps
/// browse-time cert trust decisions out of the deployed driver's trust store.
/// </summary>
private static async Task<ApplicationConfiguration> 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<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;
}
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
<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" />
```
**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<InvalidOperationException>(
() => _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<InvalidOperationException>(
() => _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<InvalidOperationException>(
() => _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;
/// <summary>
/// Live-server tests against the opc-plc Docker fixture at
/// <c>opc.tcp://10.100.0.35:50000</c>. Bring up with
/// <c>lmxopcua-fix up opcuaclient</c> before running.
/// </summary>
/// <remarks>Skipped automatically when the fixture is unreachable.</remarks>
[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
<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>
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
```
(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;
/// <summary>
/// Lazy browse over a Galaxy via the gateway client's <see cref="LazyBrowseNode"/>.
/// Stateless gRPC under the hood; per-call locking lives inside <see cref="LazyBrowseNode"/>.
/// Tracks already-handed-out nodes by <c>tag_name</c> so <see cref="ExpandAsync"/>
/// and <see cref="AttributesAsync"/> can dispatch against the right cached handle.
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
private readonly MxGatewaySession _session;
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary<string, LazyBrowseNode> _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<IReadOnlyList<BrowseNode>> 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<IReadOnlyList<BrowseNode>> 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);
}
/// <summary>
/// Fetches the attributes of one Galaxy object via
/// <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName, IncludeAttributes=true)</c>.
/// This returns the object itself (single-element list) with attributes populated.
/// </summary>
public async Task<IReadOnlyList<AttributeInfo>> 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<AttributeInfo>();
var result = new List<AttributeInfo>(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<BrowseNode> Project(IReadOnlyList<LazyBrowseNode> nodes)
{
var result = new List<BrowseNode>(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;
/// <summary>
/// Opens transient gateway sessions for the AdminUI address picker. Distinct
/// <c>ClientName</c> from the runtime driver so the gateway can attribute load.
/// </summary>
public sealed class GalaxyDriverBrowser : IDriverBrowser
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
};
private readonly ILogger<GalaxyDriverBrowser> _logger;
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
}
public string DriverType => "Galaxy";
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
{
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.");
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;
/// <summary>
/// In-process registry of live browse sessions, keyed by opaque <see cref="Guid"/>.
/// Singleton. Process-local; sticky-cookie routing keeps tokens bound to the AdminUI
/// replica that created them.
/// </summary>
public sealed class BrowseSessionRegistry
{
private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();
public void Register(IBrowseSession session) => _sessions[session.Token] = session;
public bool TryGet(Guid token, out IBrowseSession session) =>
_sessions.TryGetValue(token, out session!);
/// <summary>Removes the session from the registry without disposing it.</summary>
public bool TryRemove(Guid token, out IBrowseSession session) =>
_sessions.TryRemove(token, out session!);
/// <summary>Snapshot for the reaper. Stable across iteration.</summary>
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;
/// <summary>
/// Periodically evicts browse sessions idle for more than <see cref="IdleTtl"/>.
/// 30s tick, 2 min idle window. Disposes evicted sessions outside the dictionary so
/// a concurrent <c>BrowseAsync</c> sees the session disappear cleanly and gets a
/// translated NotFound error from the service layer.
/// </summary>
public sealed class BrowseSessionReaper(
BrowseSessionRegistry registry,
ILogger<BrowseSessionReaper> 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<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
Task<IReadOnlyList<AttributeInfo>> 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<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger) : IBrowserSessionService
{
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
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);
}
}
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(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<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);
}
}
```
**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
<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" />
```
**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<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddSingleton<Commons.Browsing.IDriverBrowser,
Driver.OpcUaClient.Browser.OpcUaClientDriverBrowser>();
services.AddSingleton<Commons.Browsing.IDriverBrowser,
Driver.Galaxy.Browser.GalaxyDriverBrowser>();
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<CancellationToken, Task<IReadOnlyList<BrowseNode>>>? RootHandler;
public Func<string, CancellationToken, Task<IReadOnlyList<BrowseNode>>>? ExpandHandler;
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
=> RootHandler?.Invoke(ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
=> ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
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
<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 {
[Parameter, EditorRequired] public Guid SessionToken { get; set; }
[Parameter] public string SelectedNodeId { get; set; } = "";
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
private bool _loading = true;
private string? _error;
private List<TreeItem>? _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" : "";
<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 async Task ToggleAsync(TreeItem item)
{
item.Expanded = !item.Expanded;
if (item.Expanded && !item.Loaded) await ExpandAsync(item);
}
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; }
}
}
```
**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<string> 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
<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"
@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>. 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>
</div>
@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 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 `<DriverTagPicker>` and `<OpcUaClientAddressPickerBody>` (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
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DelmiaReceiver_001"
@bind="_tagName" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-5">
<label class="form-label">Attribute name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DownloadPath"
@bind="_attributeName" @bind:after="OnChangedAsync" />
</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 @(_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>
</div>
@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, _attrsLoading, _canOperate;
private string? _openError, _attrsError;
private IReadOnlyList<AttributeInfo>? _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=<picked node>"
```
**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.