c962b86bde
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.
2059 lines
84 KiB
Markdown
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…</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**
|
|
```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.
|