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.
84 KiB
Live address browsers (OpcUaClient + Galaxy) — implementation plan
For Claude: REQUIRED SUB-SKILL: Use
superpowers-extended-cc:executing-plansorsuperpowers-extended-cc:subagent-driven-developmentto 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:
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:
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:
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
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— addPackageReferencetoOPCFoundation.NetStandard.Opc.Ua.Client(NamespaceMap needsISession/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
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:
<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
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
<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:
<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
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
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
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
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
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:
<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)
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)
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
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
<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
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
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
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
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
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 cannedGalaxyObjectrows → SessionRootAsync → 2 BrowseNodes with correct TagName/ContainedName/HasChildrenHintExpandAsync_unknown_tag_throws_ArgumentExceptionExpandAsync_cached_node_returns_children— fake child rows wired to a parent's gobject_idAttributesAsync_returns_projected_attribute_infos— fake DiscoverHierarchy returns one object with attributesProject_uses_TagName_when_ContainedName_emptyMapSecurityClass_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
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
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
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
// 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).");
// 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
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(theAddAdminUImethod) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj— add ProjectReferences to the two*.Browserprojects
Step 1: Add project references
<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
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
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
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 withLastUsedUtc = UtcNow - 3 min; callreaper.ReapOnceAsync(default); assert session is no longer in registry andDisposed == trueReapOnceAsync_preserves_non_idle_sessions:LastUsedUtc = UtcNow; reap; still in registryReapOnceAsync_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_falseOpenAsync_happy_path_returns_token_and_registersRootAsync_unknown_token_throws_BrowseSessionNotFoundExceptionRootAsync_invokes_session_RootAsyncRootAsync_enforces_per_call_timeout: fake session that delays 30s → assert throws OperationCanceledExceptionCloseAsync_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
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
@* 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…</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…
</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
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, @usings 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.
@* 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
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}".
@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
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
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
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.mdcross-ref if relevant
Step 1: Full solution build + test sweep
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: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
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)
- NamespaceMap visibility change. Runtime driver references
NamespaceMap.FromSession/TryResolve/ToStableReference— they all need to remain accessible (nowpublicnotinternal). CheckDriver.OpcUaClient.Testskeeps passing post-Phase 2. - Per-call timeout cancellation.
BrowserSessionService.InvokeAsyncuses 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 threadsctintoBrowseAsync— verified. Galaxy session threadsctinto bothBrowseAsyncandLazyBrowseNode.ExpandAsync— verified. - Reaper race with in-flight ExpandAsync. The reaper does
TryRemovethenDisposeAsyncoutside the dictionary; a concurrentExpandAsyncalready inside the session's gate will throwObjectDisposedExceptionfromOpcUaClientBrowseSession.BrowseOneLevelAsync— surface as a clear UI error. Test coverage in Task 13 explicitly asserts this. - Galaxy gateway client API names. Task 9 names (
MxGatewaySession.OpenAsync,session.GalaxyRepository(name),MxGatewayClientOptions.ApiKey) MUST be verified against the actual client surface — mirrorsrc/Drivers/.../Galaxy/Browse/GatewayGalaxyHierarchySource.csfor the exact connect path used in production. - 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.