diff --git a/docs/plans/2026-05-28-opcua-tag-browser.md b/docs/plans/2026-05-28-opcua-tag-browser.md new file mode 100644 index 00000000..32a69167 --- /dev/null +++ b/docs/plans/2026-05-28-opcua-tag-browser.md @@ -0,0 +1,1912 @@ +# OPC UA Tag Browser Popup — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` to implement this plan task-by-task. + +**Goal:** Add a popup OPC UA address-space browser to `InstanceConfigure.razor` so users can pick the actual physical tag for each attribute binding, with the picked value stored as a per-instance override that beats the template's `DataSourceReference` at flattening time. + +**Architecture:** ClusterClient `BrowseOpcUaNodeCommand` → `SiteCommunicationActor` → `DataConnectionManagerActor` → new `IBrowsableDataConnection` capability on `OpcUaDataConnection` → `RealOpcUaClient.BrowseChildrenAsync` against the live `Opc.Ua.Client.Session`. Override value carried additively on the existing `ConnectionBinding` record; persisted on `InstanceConnectionBinding`; applied once during flattening in `FlatteningService.ApplyConnectionBindings`. + +**Tech Stack:** C# 12 / .NET 10, Akka.NET, EF Core 9, MS SQL Server 2022, Blazor Server + Bootstrap 5, OPC Foundation .NET Standard SDK. + +**Source design doc:** [`docs/plans/2026-05-28-opcua-tag-browser-design.md`](2026-05-28-opcua-tag-browser-design.md) — read this first if any task is ambiguous. + +**Deviation from design (one):** The design proposed a site-side `Design`-role check on the browse command. The existing site-side actors (`SiteCommunicationActor`) do not unwrap `ManagementEnvelope` — central is trusted. We match that pattern: enforce the role at the CentralUI page/service layer, not at the site. See Task 14 for the CentralUI-side guard. + +**Verification commands (used throughout):** + +- Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` +- All tests in a project: `dotnet test tests//.csproj` +- Single test: `dotnet test tests//.csproj --filter "FullyQualifiedName~"` +- Cluster rebuild for UI smoke: `bash docker/deploy.sh` +- CLI for inspection: `dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- --url http://localhost:9000 --username multi-role --password password instance get ` + +--- + +## Phase 1 — Schema + Contracts (foundation) + +### Task 1: Add `DataSourceReferenceOverride` to `InstanceConnectionBinding` entity + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs` + +**Step 1: Edit the POCO** + +Add a single nullable string property. The file currently has `Id`, `InstanceId`, `AttributeName`, `DataConnectionId`. Append after `DataConnectionId`: + +```csharp +/// +/// Optional per-instance override of the OPC UA node identifier (or other +/// protocol address) for this attribute. When non-null, this value replaces +/// the template's DataSourceReference during flattening. When null, +/// the template default is used. +/// +public string? DataSourceReferenceOverride { get; set; } +``` + +**Step 2: Build** + +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj` +Expected: 0 errors. + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs +git commit -m "feat(commons): add DataSourceReferenceOverride to InstanceConnectionBinding" +``` + +--- + +### Task 2: Add override to `ConnectionBinding` wire record + map through ManagementActor + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs:16` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleSetConnectionBindings`, around line 676) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs` (new) + +**Step 1: Update the record (additive — new field defaulted to null)** + +`InstanceCommands.cs` line 16, replace: + +```csharp +public record ConnectionBinding(string AttributeName, int DataConnectionId); +``` + +with: + +```csharp +public record ConnectionBinding( + string AttributeName, + int DataConnectionId, + string? DataSourceReferenceOverride = null); +``` + +Updating the existing XML doc comment block above to mention the override is fine but not required. + +**Step 2: Map the field through `ManagementActor.HandleSetConnectionBindings`** + +Read `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` around line 676 — `HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user)`. The method translates `cmd.Bindings` into `InstanceConnectionBinding` entities for EF. Find the entity construction (it currently sets `AttributeName` and `DataConnectionId`) and add the override: + +```csharp +new InstanceConnectionBinding(b.AttributeName) +{ + DataConnectionId = b.DataConnectionId, + DataSourceReferenceOverride = b.DataSourceReferenceOverride +} +``` + +(If the existing code uses a different shape — e.g., upsert by AttributeName updating an existing entity — apply the same single-field change there.) + +**Step 3: Write the failing serialization test** + +Create `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs`: + +```csharp +using System.Text.Json; +using FluentAssertions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +public class ConnectionBindingSerializationTests +{ + [Fact] + public void Roundtrip_preserves_override_when_set() + { + var original = new ConnectionBinding("Speed", 7, "ns=2;s=Pump1.Speed"); + + var json = JsonSerializer.Serialize(original); + var roundtripped = JsonSerializer.Deserialize(json)!; + + roundtripped.Should().Be(original); + roundtripped.DataSourceReferenceOverride.Should().Be("ns=2;s=Pump1.Speed"); + } + + [Fact] + public void Roundtrip_defaults_override_to_null_when_absent() + { + // Older site builds will not emit the new field — deserialization + // must produce a null override and equal an explicit-null instance. + const string legacyJson = """{"AttributeName":"Speed","DataConnectionId":7}"""; + + var deserialized = JsonSerializer.Deserialize(legacyJson)!; + + deserialized.AttributeName.Should().Be("Speed"); + deserialized.DataConnectionId.Should().Be(7); + deserialized.DataSourceReferenceOverride.Should().BeNull(); + } +} +``` + +**Step 4: Run the tests + build** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingSerializationTests" +dotnet build ZB.MOM.WW.ScadaBridge.slnx +``` + +Expected: both tests pass, build clean. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs \ + src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs \ + tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs +git commit -m "feat(commons): carry DataSourceReferenceOverride on ConnectionBinding (additive)" +``` + +--- + +### Task 3: EF mapping for the new column + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 5, Task 6 (NOT Task 4 — Task 4 runs the migration generator and needs this mapping in place) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs` + +**Step 1: Locate the `InstanceConnectionBinding` mapping** + +Open the file. EF entity mappings for `InstanceConnectionBinding` are configured here (the file is named `InstanceConfiguration.cs` and covers Instance + child collections). Locate the `Entity` block (it may be a separate `IEntityTypeConfiguration` nested class or inline in `Configure(...)`). + +**Step 2: Add the column** + +Inside that block, append: + +```csharp +builder.Property(b => b.DataSourceReferenceOverride) + .HasMaxLength(512) + .IsRequired(false); +``` + +If the mapping file uses the `EntityTypeBuilder` builder under a different variable name, adapt accordingly. The intent: `NVARCHAR(512) NULL` (matches the existing `DataSourceReference` length on `TemplateAttribute`). + +**Step 3: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj +``` + +Expected: 0 errors. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs +git commit -m "feat(configdb): map InstanceConnectionBinding.DataSourceReferenceOverride" +``` + +--- + +### Task 4: EF Core migration `AddInstanceConnectionBindingOverride` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5, Task 6 (blocked by Task 3) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/_AddInstanceConnectionBindingOverride.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/_AddInstanceConnectionBindingOverride.Designer.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs` (auto-regenerated) + +**Step 1: Generate the migration** + +```bash +cd src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase +dotnet ef migrations add AddInstanceConnectionBindingOverride --context ScadaBridgeDbContext +cd - +``` + +(If the project layout requires `--project` / `--startup-project` flags, mirror the most recent migration command in `docker/deploy.sh` or the README. As of the last migration `20260523201950_AddNotificationSourceNode`, this command worked from the project folder.) + +**Step 2: Verify the generated `Up` is exactly the column add** + +Open the generated `_AddInstanceConnectionBindingOverride.cs`. The `Up` method should contain only: + +```csharp +migrationBuilder.AddColumn( + name: "DataSourceReferenceOverride", + table: "InstanceConnectionBindings", + type: "nvarchar(512)", + maxLength: 512, + nullable: true); +``` + +and `Down` should drop the same column. If EF generated extra changes (renames, type alterations on unrelated columns), STOP — that means another mapping change drifted in. Surface to the user; do not silently commit. + +**Step 3: Apply against the running dev MS SQL** + +```bash +dotnet ef database update --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --context ScadaBridgeDbContext --connection "Server=localhost,11433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true" +``` + +(Port `11433` is `docker/`'s mapped MS SQL port — check `docker/docker-compose.yml` if differs.) + +Expected: migration applies, "Done." Verify with sqlcmd: + +```bash +docker exec scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ScadaBridge_Dev1#' -C -d ScadaBridgeConfig -Q "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='InstanceConnectionBindings' AND COLUMN_NAME='DataSourceReferenceOverride'" +``` + +Expected: one row, column present. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ +git commit -m "feat(configdb): migration AddInstanceConnectionBindingOverride" +``` + +--- + +### Task 5: `IBrowsableDataConnection` interface + `BrowseNode` types + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 6 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs` + +**Step 1: Create the file** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + +/// +/// Optional capability for an implementation +/// that supports browsing the server's address space. Consumed only by +/// management/UI flows (e.g. the OPC UA tag picker on the instance config +/// page) — never by Instance Actors on the hot path. +/// +public interface IBrowsableDataConnection +{ + /// + /// Returns the immediate children of , or + /// the server's root-level nodes when null. + /// + /// Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder). + /// Cancellation token; on cancellation the implementation should throw . + Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default); +} + +/// Child nodes returned by the server in browse order. +/// True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry. +public record BrowseChildrenResult( + IReadOnlyList Children, + bool Truncated); + +/// Server-issued node identifier (e.g. "ns=2;s=Devices.Pump1.Speed"). +/// Human-readable display name from the server's DisplayName attribute. +/// Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable). +/// Hint so the UI can render an expand chevron without a second roundtrip. +public record BrowseNode( + string NodeId, + string DisplayName, + BrowseNodeClass NodeClass, + bool HasChildren); + +public enum BrowseNodeClass { Object, Variable, Method, Other } + +/// +/// Thrown by when +/// the underlying session is not currently connected. Translated to +/// BrowseFailureKind.ConnectionNotConnected by the site-side handler. +/// +public sealed class ConnectionNotConnectedException : InvalidOperationException +{ + public ConnectionNotConnectedException(string message) : base(message) { } +} +``` + +**Step 2: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj +``` + +Expected: 0 errors. + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs +git commit -m "feat(commons): add IBrowsableDataConnection capability interface" +``` + +--- + +### Task 6: `BrowseCommands.cs` (browse message + result + failure) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 5 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs` (new) + +**Step 1: Create the messages** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +/// +/// Sent from CentralUI to a specific site to enumerate the immediate children +/// of an OPC UA node on the live server backing the given data connection. +/// +/// Id of the site-local data connection to browse against. +/// Node to browse, or null to browse from the server root (ObjectsFolder). +public record BrowseOpcUaNodeCommand( + int DataConnectionId, + string? ParentNodeId); + +public record BrowseOpcUaNodeResult( + IReadOnlyList Children, + bool Truncated, + BrowseFailure? Failure); + +public record BrowseFailure( + BrowseFailureKind Kind, + string Message); + +public enum BrowseFailureKind +{ + ConnectionNotFound, + ConnectionNotConnected, + NotBrowsable, + Timeout, + ServerError +} +``` + +**Step 2: Write the registry-discovery test** + +`tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs`: + +```csharp +using FluentAssertions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +public class BrowseCommandsRegistryTests +{ + [Fact] + public void Registry_discovers_BrowseOpcUaNodeCommand() + { + var types = ManagementCommandRegistry.GetAllCommandTypes(); + types.Should().Contain(typeof(BrowseOpcUaNodeCommand)); + } +} +``` + +(If the registry exposes a different accessor — `KnownCommands`, `AllCommands` etc. — adapt. The point: confirm the new command shows up in the auto-discovery surface so Akka serialization treats it as a known management message.) + +**Step 3: Run + build** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~BrowseCommandsRegistryTests" +dotnet build ZB.MOM.WW.ScadaBridge.slnx +``` + +Expected: test passes, build clean. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs \ + tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs +git commit -m "feat(commons): add BrowseOpcUaNodeCommand + result + failure types" +``` + +--- + +## Phase 2 — DCL Browse Capability (depends on Phase 1) + +### Task 7: Add `BrowseChildrenAsync` to `IOpcUaClient` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (Tasks 8 and 9 depend on this signature) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs` + +**Step 1: Append the method to the interface** + +Add to `IOpcUaClient`: + +```csharp +/// +/// Enumerates the immediate children of +/// (or the server's ObjectsFolder when null). Throws +/// when the session is not +/// currently up. +/// +Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default); +``` + +Add the necessary `using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;` at the top if it isn't already present. + +**Step 2: Build (will fail for any test/fake implementations of IOpcUaClient)** + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: errors in test fakes that implement `IOpcUaClient` (probably one or two). Note the file paths. + +**Step 3: Add throwing stubs in each impl that doesn't yet implement it** + +For every reported missing-member error, add a single stub method to that impl: + +```csharp +public Task BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); +``` + +(Tasks 8 and 9 will provide the real implementations on `RealOpcUaClient` and `OpcUaDataConnection`. Other test fakes/stubs can stay `NotImplementedException` until a specific test needs them.) + +**Step 4: Build again** + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx +``` + +Expected: 0 errors. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs +# plus any test fakes that needed the stub +git commit -m "feat(dcl): add BrowseChildrenAsync to IOpcUaClient (NotImplementedException stubs)" +``` + +--- + +### Task 8: Implement `BrowseChildrenAsync` on `RealOpcUaClient` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 9 (different methods on different types — but Task 9 also adds the wrapping in `OpcUaDataConnection`; running serially is safer if you're uncertain whether OpcUaDataConnection holds a reference to the IOpcUaClient at construction time) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs` (new) + +**Step 1: Write the failing test (against the live OPC UA infra server)** + +`tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs`: + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// Live OPC UA browse tests. Requires the infra OPC UA server to be running +/// (cd infra && docker compose up -d opcua). Skipped if the endpoint is +/// unreachable so CI without the infra stack stays green. +/// +[Trait("Category", "RequiresOpcUa")] +public class RealOpcUaClientBrowseTests +{ + private const string Endpoint = "opc.tcp://localhost:4840"; + + [SkippableFact] + public async Task BrowseChildren_at_root_returns_known_object_folder() + { + var client = new RealOpcUaClient(/* construct per existing test helpers */); + try + { + await client.ConnectAsync(new Dictionary { ["EndpointUrl"] = Endpoint }); + } + catch + { + Skip.If(true, "OPC UA test server not reachable on " + Endpoint); + } + + var result = await client.BrowseChildrenAsync(parentNodeId: null); + + result.Children.Should().NotBeEmpty(); + // OPC UA standard: under ObjectsFolder ns=0;i=85 there are at least + // 'Server' (ns=0;i=2253). Test asserts the server's display name shows + // up so we know we're browsing the right place. + result.Children.Should().Contain(n => n.DisplayName == "Server"); + } + + [Fact] + public async Task BrowseChildren_throws_when_not_connected() + { + var client = new RealOpcUaClient(/* same constructor */); + + Func act = () => client.BrowseChildrenAsync(parentNodeId: null); + + await act.Should().ThrowAsync(); + } +} +``` + +(Match `RealOpcUaClient`'s actual constructor — the existing test file in this folder will show the pattern; if the test project uses `RealOpcUaClientFixture` or similar, reuse it.) + +**Step 2: Run, confirm fail** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~RealOpcUaClientBrowseTests" +``` + +Expected: FAIL — `NotImplementedException` from the stub added in Task 7. + +**Step 3: Implement the method** + +In `RealOpcUaClient.cs`, replace the stub with: + +```csharp +public async Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default) +{ + var session = _session; // existing field — adapt to the actual name in RealOpcUaClient + if (session is null || !session.Connected) + throw new ConnectionNotConnectedException("OPC UA session is not connected."); + + // ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. + var nodeToBrowse = string.IsNullOrEmpty(parentNodeId) + ? Opc.Ua.ObjectIds.ObjectsFolder + : Opc.Ua.NodeId.Parse(parentNodeId); + + var browseDescription = new Opc.Ua.BrowseDescription + { + NodeId = nodeToBrowse, + BrowseDirection = Opc.Ua.BrowseDirection.Forward, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = (uint)(Opc.Ua.NodeClass.Object | Opc.Ua.NodeClass.Variable | Opc.Ua.NodeClass.Method), + ResultMask = (uint)Opc.Ua.BrowseResultMask.All + }; + + var browseDescriptions = new Opc.Ua.BrowseDescriptionCollection { browseDescription }; + + var response = await Task.Run(() => + { + session.Browse( + requestHeader: null, + view: null, + requestedMaxReferencesPerNode: 1000u, + nodesToBrowse: browseDescriptions, + results: out var results, + diagnosticInfos: out _); + return results; + }, cancellationToken); + + var first = response[0]; + var children = new List(first.References.Count); + foreach (var r in first.References) + { + children.Add(new BrowseNode( + NodeId: r.NodeId.ToString(), + DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)", + NodeClass: MapNodeClass(r.NodeClass), + HasChildren: r.NodeClass == Opc.Ua.NodeClass.Object)); + } + + var truncated = first.ContinuationPoint != null && first.ContinuationPoint.Length > 0; + return new BrowseChildrenResult(children, truncated); +} + +private static BrowseNodeClass MapNodeClass(Opc.Ua.NodeClass nc) => nc switch +{ + Opc.Ua.NodeClass.Object => BrowseNodeClass.Object, + Opc.Ua.NodeClass.Variable => BrowseNodeClass.Variable, + Opc.Ua.NodeClass.Method => BrowseNodeClass.Method, + _ => BrowseNodeClass.Other +}; +``` + +If the OPC Foundation SDK in this project uses a slightly different API surface (e.g. `session.BrowseAsync(...)` exists), prefer the async variant; the structure above is correct for the synchronous-wrap path. Inspect the existing `SubscribeAsync` / `ReadAsync` in the same file for the project's preferred wrap idiom. + +**Step 4: Run the test** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~RealOpcUaClientBrowseTests" +``` + +Expected: both tests pass (or `BrowseChildren_at_root_returns_known_object_folder` is skipped if `cd infra && docker compose up -d opcua` hasn't been run). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs \ + tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs +git commit -m "feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient" +``` + +--- + +### Task 9: Implement `IBrowsableDataConnection` on `OpcUaDataConnection` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 10, Task 11 (different files) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs` (new) + +**Step 1: Implement the capability** + +`OpcUaDataConnection` already implements `IDataConnection` and holds a reference to an `IOpcUaClient` (the field name in the existing file is likely `_client` — adapt). Add the interface and a one-line forwarder: + +```csharp +public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection +{ + // ... existing fields & methods ... + + public Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default) + => _client.BrowseChildrenAsync(parentNodeId, cancellationToken); +} +``` + +Add `using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;` if needed. + +**Step 2: Write the forwarder test** + +`tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs`: + +```csharp +using FluentAssertions; +using Moq; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +public class OpcUaDataConnectionBrowseTests +{ + [Fact] + public async Task BrowseChildrenAsync_forwards_to_underlying_client() + { + var client = new Mock(); + var expected = new BrowseChildrenResult( + new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) }, + Truncated: false); + client.Setup(c => c.BrowseChildrenAsync("ns=2;s=Parent", It.IsAny())) + .ReturnsAsync(expected); + + var sut = new OpcUaDataConnection(/* construct per existing helpers, injecting client.Object */); + + var actual = await sut.BrowseChildrenAsync("ns=2;s=Parent"); + + actual.Should().BeSameAs(expected); + client.VerifyAll(); + } +} +``` + +(Match `OpcUaDataConnection`'s actual constructor. If the project already has a builder/fixture for it in the test project, use that.) + +**Step 3: Run + build** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~OpcUaDataConnectionBrowseTests" +dotnet build ZB.MOM.WW.ScadaBridge.slnx +``` + +Expected: pass, clean build. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs \ + tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs +git commit -m "feat(dcl): implement IBrowsableDataConnection on OpcUaDataConnection" +``` + +--- + +### Task 10: Handle `BrowseOpcUaNodeCommand` in `DataConnectionManagerActor` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 9 (different file) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs` (new) + +**Step 1: Add the receive handler** + +Inside the actor's `ReceiveAsync`/`Receive` block (find existing receive registrations; the actor uses `ReceiveActor`), add: + +```csharp +ReceiveAsync(HandleBrowse); +``` + +Then add the handler method on the actor class: + +```csharp +private async Task HandleBrowse(BrowseOpcUaNodeCommand cmd) +{ + var sender = Sender; + BrowseOpcUaNodeResult reply; + + try + { + // Existing pattern in this actor: connections are keyed by name via + // an internal dictionary, but central addresses by Id. Look up the + // IDataConnection instance by DataConnectionId — adapt to the + // actor's existing lookup (might be _connectionsById, or a + // _connectionsByName plus an id→name map). + if (!TryGetConnectionById(cmd.DataConnectionId, out var conn)) + { + reply = Fail(BrowseFailureKind.ConnectionNotFound, $"No data connection with id {cmd.DataConnectionId} at this site."); + } + else if (conn is not IBrowsableDataConnection browsable) + { + reply = Fail(BrowseFailureKind.NotBrowsable, "This data connection's protocol does not support browsing."); + } + else + { + var browseResult = await browsable.BrowseChildrenAsync(cmd.ParentNodeId); + reply = new BrowseOpcUaNodeResult(browseResult.Children, browseResult.Truncated, Failure: null); + } + } + catch (ConnectionNotConnectedException ex) + { + reply = Fail(BrowseFailureKind.ConnectionNotConnected, ex.Message); + } + catch (OperationCanceledException) + { + reply = Fail(BrowseFailureKind.Timeout, "Browse cancelled."); + } + catch (Exception ex) + { + // Includes Opc.Ua.ServiceResultException (Bad_*). Carry the SDK + // message verbatim — the dialog renders it as-is. + reply = Fail(BrowseFailureKind.ServerError, ex.Message); + } + + sender.Tell(reply); +} + +private static BrowseOpcUaNodeResult Fail(BrowseFailureKind kind, string message) + => new(Array.Empty(), Truncated: false, new BrowseFailure(kind, message)); + +private bool TryGetConnectionById(int id, out IDataConnection conn) +{ + // Adapt to the existing private state shape. If the actor keeps + // connections in _connections (keyed by name) and the DataConnection + // entity table is available via injected EF/repository, look up name + // by id then return _connections[name]. If the actor already keys by + // id, this is a one-liner. + throw new NotImplementedException("Replace with actor's actual id-based connection lookup"); +} +``` + +**Step 2: Wire the lookup correctly** + +Read the actor's existing receive handlers (e.g., the one that adds/removes connections at `DataConnectionManagerActor.cs:121`) to see how connections are keyed internally. The Manager almost certainly already needs id→child-actor or id→connection lookups for other commands; use the same store. + +If the only existing index is by name and there's a `RegisteredDataConnection` (or similar) message carrying the id at registration time, hold both indices. + +**Step 3: Write actor-spec tests** + +`tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs`: + +```csharp +using Akka.Actor; +using Akka.TestKit.Xunit2; +using FluentAssertions; +using Moq; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; + +public class DataConnectionManagerBrowseHandlerTests : TestKit +{ + [Fact] + public void Unknown_connection_id_returns_ConnectionNotFound() + { + var manager = SetUpManagerWithNoConnections(); + manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 999, ParentNodeId: null)); + + var reply = ExpectMsg(); + reply.Failure.Should().NotBeNull(); + reply.Failure!.Kind.Should().Be(BrowseFailureKind.ConnectionNotFound); + } + + [Fact] + public void Non_browsable_connection_returns_NotBrowsable() + { + // Register a connection whose IDataConnection impl does NOT implement IBrowsableDataConnection. + var manager = SetUpManagerWithBareConnection(id: 7); + manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null)); + + var reply = ExpectMsg(); + reply.Failure!.Kind.Should().Be(BrowseFailureKind.NotBrowsable); + } + + [Fact] + public void Success_path_returns_mapped_children() + { + var browsable = new Mock(); + browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny())) + .ReturnsAsync(new BrowseChildrenResult( + new[] { new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, false) }, + Truncated: false)); + + var manager = SetUpManagerWithBrowsableConnection(id: 7, browsable.Object); + manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null)); + + var reply = ExpectMsg(); + reply.Failure.Should().BeNull(); + reply.Children.Should().HaveCount(1); + reply.Children[0].NodeId.Should().Be("ns=2;s=A"); + } + + [Fact] + public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() + { + var browsable = new Mock(); + browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny())) + .ThrowsAsync(new ConnectionNotConnectedException("session down")); + + var manager = SetUpManagerWithBrowsableConnection(id: 7, browsable.Object); + manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null)); + + var reply = ExpectMsg(); + reply.Failure!.Kind.Should().Be(BrowseFailureKind.ConnectionNotConnected); + } + + // ... SetUpManager* helpers spin up the actor with a stub connection + // registry — mirror the existing fixture style in this test project. +} +``` + +**Step 4: Run** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~DataConnectionManagerBrowseHandlerTests" +``` + +Expected: all four tests pass. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs \ + tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs +git commit -m "feat(dcl): handle BrowseOpcUaNodeCommand in DataConnectionManagerActor" +``` + +--- + +### Task 11: Forward `BrowseOpcUaNodeCommand` in `SiteCommunicationActor` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 9 (different file) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs` + +**Step 1: Find the existing DCL forwarding pattern** + +Read `SiteCommunicationActor.cs` around line 94–145 to see the existing `Receive<...> => _xxxProxy.Forward(msg)` pattern. There's likely no `_dataConnectionManagerProxy` field yet — the manager is reachable through a different path (the `DeploymentManagerActor` holds connections via children, or the manager itself is a singleton on the site cluster). + +**Decision:** if `DataConnectionManagerActor` is a site-local singleton with a known path, route directly: + +```csharp +Receive(msg => +{ + Context.ActorSelection("/user/data-connection-manager").Forward(msg); +}); +``` + +If it's a child of `DeploymentManagerActor`, forward through that proxy: + +```csharp +Receive(msg => _deploymentManagerProxy.Forward(msg)); +``` + +— and add a `Receive` handler at the top of `DeploymentManagerActor` that forwards to its child manager. Match the existing site topology. + +(Read `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` or the Site Runtime bootstrap to confirm the actor path for `DataConnectionManagerActor` — there's typically one place where top-level actors are registered.) + +**Step 2: Build** + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx +``` + +Expected: 0 errors. + +**Step 3: Integration test — central → site browse round-trip** + +Add a coarse integration test in `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Browse/CentralToSiteBrowseTests.cs`: + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Browse; + +[Trait("Category", "RequiresCluster")] +public class CentralToSiteBrowseTests : IClassFixture // reuse existing fixture +{ + private readonly ClusterFixture _cluster; + public CentralToSiteBrowseTests(ClusterFixture cluster) => _cluster = cluster; + + [Fact] + public async Task Browse_round_trips_from_central_to_site_OPC_UA_server() + { + var result = await _cluster.CommunicationService.SendCommandToSiteAsync( + siteId: "site-a", + command: new BrowseOpcUaNodeCommand( + DataConnectionId: _cluster.SiteAOpcUaConnectionId, + ParentNodeId: null)); + + result.Failure.Should().BeNull(); + result.Children.Should().NotBeEmpty(); + result.Children.Should().Contain(n => n.DisplayName == "Server"); + } +} +``` + +If `ClusterFixture` doesn't expose `SiteAOpcUaConnectionId`, add it by reading the configured connection via the existing repository. If the integration test project doesn't already wire up a docker-backed fixture, mark the test `[Trait("Category", "RequiresCluster")]` and document that it runs under `bash docker/deploy.sh && dotnet test --filter Category=RequiresCluster`. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs \ + tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Browse/CentralToSiteBrowseTests.cs +# plus DeploymentManagerActor if you went through the proxy path +git commit -m "feat(comm): route BrowseOpcUaNodeCommand from central to site DCL manager" +``` + +--- + +## Phase 3 — Flattening uses the override (depends on Phase 1) + +### Task 12: Apply override in `FlatteningService.ApplyConnectionBindings` + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 7–11, 14–22 (different file) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs` (`ApplyConnectionBindings`, lines 348–371) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs` (new) + +**Step 1: Write the failing test** + +`tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs`: + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +// ... using for the entities your existing flattening tests use + +namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening; + +public class ConnectionBindingOverrideTests +{ + [Fact] + public void Override_replaces_template_DataSourceReference_when_set() + { + var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + var instance = TestInstances.For(template); + instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump1.Speed" + }); + + var flat = SUT.Flatten(template, instance, dataConnections: TestConnections.One(id: 1)); + + flat.Attributes.Should().ContainSingle(a => a.CanonicalName == "Speed") + .Which.DataSourceReference.Should().Be("ns=2;s=Pump1.Speed"); + } + + [Fact] + public void Null_override_falls_back_to_template_default() + { + var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + var instance = TestInstances.For(template); + instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = null + }); + + var flat = SUT.Flatten(template, instance, dataConnections: TestConnections.One(id: 1)); + + flat.Attributes.Should().ContainSingle(a => a.CanonicalName == "Speed") + .Which.DataSourceReference.Should().Be("TemplateDefault"); + } +} +``` + +(Reuse whatever test helpers already exist in this test project — `TestTemplates`, `TestInstances`, etc. The two existing flattening test files in the project show the conventions; mirror them.) + +**Step 2: Run, confirm fail** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingOverrideTests" +``` + +Expected: FAIL on the first test — flat attribute carries `"TemplateDefault"`, not the override. + +**Step 3: Patch `ApplyConnectionBindings`** + +In `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs`, lines 364–369, add `DataSourceReference` to the `with` clause: + +```csharp +attributes[binding.AttributeName] = existing with +{ + BoundDataConnectionId = connection.Id, + BoundDataConnectionName = connection.Name, + BoundDataConnectionProtocol = connection.Protocol, + DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference +}; +``` + +That's it — one line. `RevisionHashService.cs:57` already hashes `DataSourceReference` on the flattened attribute, so the revision hash naturally captures override changes and deploys roll forward on edit. No change needed there. + +**Step 4: Run, confirm pass** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingOverrideTests" +dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj # full project — regression check +``` + +Expected: new tests pass, no regressions. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs \ + tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs +git commit -m "feat(templates): apply InstanceConnectionBinding override during flattening" +``` + +--- + +### Task 13: Revision-hash regression test (verify override mutates the hash) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 12 (after T12 lands) + +**Files:** +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs` (extend) + +**Step 1: Add the test** + +Append to the file from Task 12: + +```csharp +[Fact] +public void Override_change_changes_revision_hash() +{ + var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + var connections = TestConnections.One(id: 1); + + var instance1 = TestInstances.For(template); + instance1.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump1.Speed" + }); + + var instance2 = TestInstances.For(template); + instance2.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump2.Speed" + }); + + var hash1 = SUT.Flatten(template, instance1, connections).RevisionHash; + var hash2 = SUT.Flatten(template, instance2, connections).RevisionHash; + + hash1.Should().NotBe(hash2); +} +``` + +**Step 2: Run** + +```bash +dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~Override_change_changes_revision_hash" +``` + +Expected: pass. + +**Step 3: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs +git commit -m "test(templates): override changes drive revision hash forward" +``` + +--- + +## Phase 4 — Central UI (depends on Phases 1 and 2) + +### Task 14: `IOpcUaBrowseService` + impl + DI + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 15 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs` (DI registration) + +**Step 1: Create the interface + impl** + +`Services/IOpcUaBrowseService.cs`: + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +public interface IOpcUaBrowseService +{ + Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default); +} +``` + +`Services/OpcUaBrowseService.cs`: + +```csharp +using Microsoft.AspNetCore.Components.Authorization; +using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Communication; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +public class OpcUaBrowseService : IOpcUaBrowseService +{ + private readonly CommunicationService _communication; + private readonly AuthenticationStateProvider _auth; + + public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth) + { + _communication = communication; + _auth = auth; + } + + public async Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default) + { + // CentralUI-side role guard. The site doesn't unwrap envelopes; + // central is the trust boundary. + var state = await _auth.GetAuthenticationStateAsync(); + if (!state.User.IsInRole("Design")) + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized.")); + + try + { + return await _communication.SendCommandToSiteAsync( + siteId, + new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId), + cancellationToken); + } + catch (TimeoutException ex) + { + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.Timeout, ex.Message)); + } + catch (Exception ex) + { + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); + } + } +} +``` + +**Step 2: Register in DI** + +In `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs`, find the scoped service registrations (near other `builder.Services.AddScoped<...>()` lines) and add: + +```csharp +builder.Services.AddScoped(); +``` + +**Step 3: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +``` + +Expected: 0 errors. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs \ + src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs \ + src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs +git commit -m "feat(centralui): IOpcUaBrowseService wraps CommunicationService + role guard" +``` + +--- + +### Task 15: `` scaffold — parameters, modal shell, state + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 14 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor` + +**Step 1: Create the file** + +Use the `frontend-design` skill at this point for the visual polish — the structure below is a starting scaffold. Bootstrap 5 modal, no third-party UI framework. + +```razor +@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@inject IOpcUaBrowseService BrowseService + +@if (_isVisible) +{ + +} + +@code { + [Parameter] public string SiteId { get; set; } = ""; + [Parameter] public int DataConnectionId { get; set; } + [Parameter] public string ConnectionName { get; set; } = ""; + [Parameter] public string? InitialNodeId { get; set; } + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public EventCallback OnCancelled { get; set; } + + private bool _isVisible; + private string? _selectedNodeId; + private string _manualNodeId = ""; + private BrowseFailure? _failure; + private string _failureMessage = ""; + + public async Task ShowAsync() + { + _isVisible = true; + _manualNodeId = InitialNodeId ?? ""; + _selectedNodeId = InitialNodeId; + await LoadRootAsync(); + } + + private async Task LoadRootAsync() + { + // Task 16 + } + + private Task RetryRootLoad() => LoadRootAsync(); + + private void UseManual() + { + _selectedNodeId = _manualNodeId.Trim(); + } + + private async Task Confirm() + { + _isVisible = false; + if (!string.IsNullOrWhiteSpace(_selectedNodeId)) + await OnSelected.InvokeAsync(_selectedNodeId!); + } + + private async Task Cancel() + { + _isVisible = false; + await OnCancelled.InvokeAsync(); + } +} +``` + +**Step 2: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +``` + +Expected: 0 errors. + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor +git commit -m "feat(centralui): scaffold modal" +``` + +--- + +### Task 16: Tree rendering + lazy load + selection in `OpcUaBrowserDialog` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (extends Task 15's file) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor` + +**Step 1: Add tree model + node component** + +Inside `@code`, add: + +```csharp +private record TreeNode(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren) +{ + public List? Children { get; set; } // null = not loaded yet + public bool Expanded { get; set; } + public bool Loading { get; set; } + public bool Truncated { get; set; } +} + +private List _rootNodes = new(); + +private async Task LoadRootAsync() +{ + _failure = null; + _rootNodes = new(); + StateHasChanged(); + + var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, parentNodeId: null); + if (result.Failure is not null) + { + SetFailure(result.Failure); + return; + } + + _rootNodes = result.Children.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)).ToList(); + StateHasChanged(); +} + +private async Task ToggleAsync(TreeNode node) +{ + if (!node.HasChildren) return; + + if (node.Expanded) + { + node.Expanded = false; + return; + } + + if (node.Children is null) + { + node.Loading = true; + StateHasChanged(); + var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, node.NodeId); + node.Loading = false; + + if (result.Failure is not null) + { + SetFailure(result.Failure); + return; + } + + node.Children = result.Children + .Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)) + .ToList(); + node.Truncated = result.Truncated; + } + + node.Expanded = true; +} + +private void Select(TreeNode node) +{ + if (node.NodeClass != BrowseNodeClass.Variable) return; + _selectedNodeId = node.NodeId; + _manualNodeId = node.NodeId; +} +``` + +**Step 2: Render the tree** + +Replace the `` line with: + +```razor +@if (_rootNodes.Count == 0 && _failure is null) +{ + Loading… +} +else +{ +
    + @foreach (var node in _rootNodes) + { + + } +
+} +``` + +Create `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor` as a small recursive child component: + +```razor +@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol +
  • + + @if (Node.HasChildren) + { + @(Node.Expanded ? "▼" : "▶") + } + else + { +   + } + + + @if (Node.NodeClass == BrowseNodeClass.Variable) + { + + @Node.DisplayName (@Node.NodeId) + + } + else + { + @Node.DisplayName + } + + @if (Node.Loading) + { + loading… + } + + @if (Node.Expanded && Node.Children is not null) + { +
      + @foreach (var child in Node.Children) + { + + } + @if (Node.Truncated) + { +
    • Results truncated — use manual entry if your tag isn't listed.
    • + } +
    + } +
  • + +@code { + [Parameter] public OpcUaBrowserDialog.TreeNode Node { get; set; } = default!; + [Parameter] public EventCallback OnToggle { get; set; } + [Parameter] public EventCallback OnSelect { get; set; } + [Parameter] public string? SelectedNodeId { get; set; } +} +``` + +(If `TreeNode` should not be a nested type — Razor visibility quirks — move it to a top-level `OpcUaBrowserTreeNode` record in its own file under `Components/Dialogs/`.) + +**Step 3: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +``` + +Expected: 0 errors. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/ +git commit -m "feat(centralui): tree rendering + lazy load + selection in OpcUaBrowserDialog" +``` + +--- + +### Task 17: Error banner mapping + final polish on `OpcUaBrowserDialog` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (extends same file as Task 16) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor` + +**Step 1: Add the failure→message mapping** + +In `@code`: + +```csharp +private void SetFailure(BrowseFailure failure) +{ + _failure = failure; + _failureMessage = failure.Kind switch + { + BrowseFailureKind.ConnectionNotFound => "Connection no longer exists at the site.", + BrowseFailureKind.ConnectionNotConnected => "OPC UA session not connected — retry shortly or use manual entry.", + BrowseFailureKind.NotBrowsable => "This connection does not support browsing.", + BrowseFailureKind.Timeout => "Browse timed out — the server may be slow. Try again or enter the node id manually.", + BrowseFailureKind.ServerError => $"OPC UA server error: {failure.Message}", + _ => failure.Message + }; + StateHasChanged(); +} +``` + +**Step 2: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +``` + +Expected: 0 errors. + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor +git commit -m "feat(centralui): map BrowseFailureKind to user-facing messages" +``` + +--- + +### Task 18: Add Override column + Browse button to `InstanceConfigure.razor` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 17 (after T16 lands) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` + +**Step 1: Locate the Connection Bindings table** + +Open the file. Find the table inside the "Connection Bindings" section (the Explore report described columns "Attribute", "Tag Path", "Connection"). Locate the row template (`@foreach (var binding in _bindings)` or similar). + +**Step 2: Add two new cells per row** + +After the existing "Tag Path" column header, add `Override`. In the row template, after the existing "Tag Path" cell, add: + +```razor + + + + + @{ + var canBrowse = CanBrowse(binding); + } + @if (IsOpcUa(binding)) + { + + } + +``` + +(`binding.DataSourceReferenceOverride` requires the row's binding to be the `InstanceConnectionBinding` POCO — make sure the page's edit model holds the override field. If the page uses a separate VM `ConnectionBindingRow`, add `public string? DataSourceReferenceOverride { get; set; }` to it and map round-trip in the load/save.) + +**Step 3: Add the helpers + dialog placeholder in @code** + +Near the bottom of the @code block: + +```csharp +private string? GetTemplateDefault(string attributeName) + => _templateAttributes.FirstOrDefault(a => a.CanonicalName == attributeName)?.DataSourceReference; + +private bool IsOpcUa(BindingRow row) + => row.DataConnectionId > 0 + && _siteConnections.FirstOrDefault(c => c.Id == row.DataConnectionId)?.Protocol == DataConnectionProtocol.OpcUa; + +private bool CanBrowse(BindingRow row) + => row.DataConnectionId > 0; + +private async Task OpenBrowser(BindingRow row) +{ + _browserRowInEdit = row; + _browserConnectionId = row.DataConnectionId; + _browserConnectionName = _siteConnections.First(c => c.Id == row.DataConnectionId).Name; + _browserInitial = row.DataSourceReferenceOverride + ?? GetTemplateDefault(row.AttributeName); + await _browserRef.ShowAsync(); +} + +private void OnBrowserSelected(string nodeId) +{ + if (_browserRowInEdit is not null) + _browserRowInEdit.DataSourceReferenceOverride = nodeId; +} +``` + +(`BindingRow` / `_siteConnections` / `_templateAttributes` are placeholders — match the page's actual field names.) + +**Step 4: Render the dialog at the bottom of the page** + +```razor + +``` + +Add `private OpcUaBrowserDialog? _browserRef;` in @code along with the other fields used above. + +**Step 5: Build** + +```bash +dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +``` + +Expected: 0 errors. + +**Step 6: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +git commit -m "feat(centralui): add OPC UA browse button + override column to InstanceConfigure" +``` + +--- + +### Task 19: End-to-end smoke (manual) + +**Classification:** trivial +**Estimated implement time:** ~5 min (manual) +**Parallelizable with:** none (validates Tasks 1–18 together) + +**Files:** none (manual exercise) + +**Step 1: Rebuild the cluster** + +```bash +bash docker/deploy.sh +bash docker/seed-sites.sh # if cluster is fresh +``` + +**Step 2: Sign in + walk the flow** + +1. Open , sign in as `multi-role` / `password`. +2. Navigate to a deployed instance's Configure page. +3. On the Connection Bindings table, pick an OPC UA connection on a data-sourced attribute. Confirm the **Browse…** button shows up enabled. +4. Click Browse. Confirm the dialog opens, the root browse populates within a couple of seconds, "Server" is visible under the root. +5. Expand a folder; expand a device; click a Variable; confirm the footer shows the selected node id; click **Select**. +6. Confirm the Override input now shows the picked node id. +7. Save the bindings. Reload the page; confirm the override persists. + +**Step 3: Offline-path smoke** + +1. `docker stop scadabridge-site-a-a scadabridge-site-a-b` (whichever site backs the configured connection). +2. Click Browse. Confirm the error banner appears, manual-paste field is still usable. +3. Type a node id in manual-paste, click **Use** → **Select**. Confirm the override persists despite the site being down. +4. `docker start scadabridge-site-a-a scadabridge-site-a-b` to restore. + +**Step 4: Commit nothing (manual task)** + +If anything fails, surface it — do not silently patch over. Open a follow-up task instead. + +--- + +## Phase 5 — Docs (parallelizable, can run after Phase 1) + +### Task 20: Update `Component-DataConnectionLayer.md` + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 21, 22 + +**Files:** +- Modify: `docs/requirements/Component-DataConnectionLayer.md` + +**Step 1: Add a "Browsing" subsection** + +Append (or insert near other capability-style sections) a short section: + +```markdown +### Browsing the address space + +DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for protocols that support it, exposed via `IBrowsableDataConnection`. Only consumed by management/UI (the OPC UA tag picker on the instance configure page); Instance Actors never call it. + +- `OpcUaDataConnection` implements `IBrowsableDataConnection`; custom protocols do not. +- `DataConnectionManagerActor` handles `BrowseOpcUaNodeCommand` (fields: `DataConnectionId`, `ParentNodeId`) and replies with `BrowseOpcUaNodeResult` (children + `Truncated` + structured `BrowseFailure?`). +- Browse runs against the live session; no caching at DCL. +``` + +**Step 2: List the new actor message** + +Find any "Actor surface / Messages" table or list and append `BrowseOpcUaNodeCommand → BrowseOpcUaNodeResult` (handled by `DataConnectionManagerActor`). + +**Step 3: Commit** + +```bash +git add docs/requirements/Component-DataConnectionLayer.md +git commit -m "docs(dcl): document browse capability + BrowseOpcUaNodeCommand" +``` + +--- + +### Task 21: Update `Component-TemplateEngine.md` + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 20, 22 + +**Files:** +- Modify: `docs/requirements/Component-TemplateEngine.md` + +**Step 1: Soften the "fixed at instance level" claim** + +Find the line around the existing instance-level commentary (the Explore report referenced line 100). Replace text that says `DataSourceReference` is fixed at instance level with: + +```markdown +`DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. +``` + +**Step 2: Note the override participates in the revision hash** + +Find the section that discusses the revision hash / staleness detection and add one sentence: + +```markdown +The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected. +``` + +**Step 3: Commit** + +```bash +git add docs/requirements/Component-TemplateEngine.md +git commit -m "docs(templates): document per-instance DataSourceReference override" +``` + +--- + +### Task 22: Update `Component-CentralUI.md` + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 20, 21 + +**Files:** +- Modify: `docs/requirements/Component-CentralUI.md` + +**Step 1: Extend the Connection Bindings description** + +Find the Connection Bindings section (the Explore report referenced line 96). Extend it to describe the new Override column and Browse button: + +```markdown +- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context. +- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error). +``` + +**Step 2: Commit** + +```bash +git add docs/requirements/Component-CentralUI.md +git commit -m "docs(centralui): document OPC UA browse popup + override column" +``` + +--- + +## Parallelism summary + +| Phase | Tasks | Notes | +|---|---|---| +| 1 | 1, 2, 5, 6 in parallel; 3 → 4 sequential | T3 is mapping; T4 generates migration from T3's mapping | +| 2 | 7 → 8 → 9; 10, 11 can run in parallel after 9 | T7 is interface change, must precede T8/9 | +| 3 | 12 → 13 | T13 only adds a test against T12's behavior | +| 4 | 14 ‖ 15 → 16 → 17 → 18 → 19 | UI assembled top-down; T19 is manual smoke | +| 5 | 20, 21, 22 all in parallel | After T12 lands, all three doc updates can run concurrently | + +## Definition of done + +- All tasks committed; `git log origin/main..HEAD` shows the slice. +- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean (0 errors, 0 warnings). +- `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green (the 5 pre-existing StaleTagMonitor flakes are accepted; other failures must be fixed). +- Manual smoke (Task 19) passes online + offline paths. +- Three docs updated (Tasks 20–22). diff --git a/docs/plans/2026-05-28-opcua-tag-browser.md.tasks.json b/docs/plans/2026-05-28-opcua-tag-browser.md.tasks.json new file mode 100644 index 00000000..4b63fcac --- /dev/null +++ b/docs/plans/2026-05-28-opcua-tag-browser.md.tasks.json @@ -0,0 +1,28 @@ +{ + "planPath": "docs/plans/2026-05-28-opcua-tag-browser.md", + "tasks": [ + {"id": 70, "subject": "Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity", "status": "pending"}, + {"id": 71, "subject": "Task 2: Add override to ConnectionBinding wire record + ManagementActor mapping", "status": "pending"}, + {"id": 72, "subject": "Task 3: EF mapping for DataSourceReferenceOverride column", "status": "pending"}, + {"id": 73, "subject": "Task 4: EF Core migration AddInstanceConnectionBindingOverride", "status": "pending", "blockedBy": [72]}, + {"id": 74, "subject": "Task 5: IBrowsableDataConnection interface + BrowseNode types", "status": "pending"}, + {"id": 75, "subject": "Task 6: BrowseCommands.cs (BrowseOpcUaNodeCommand + result + failure)", "status": "pending"}, + {"id": 76, "subject": "Task 7: Add BrowseChildrenAsync to IOpcUaClient", "status": "pending", "blockedBy": [74, 75]}, + {"id": 77, "subject": "Task 8: Implement BrowseChildrenAsync on RealOpcUaClient", "status": "pending", "blockedBy": [76]}, + {"id": 78, "subject": "Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection", "status": "pending", "blockedBy": [76]}, + {"id": 79, "subject": "Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor", "status": "pending", "blockedBy": [75, 76]}, + {"id": 80, "subject": "Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor", "status": "pending", "blockedBy": [79]}, + {"id": 81, "subject": "Task 12: Apply override in FlatteningService.ApplyConnectionBindings", "status": "pending", "blockedBy": [70]}, + {"id": 82, "subject": "Task 13: Revision-hash regression test", "status": "pending", "blockedBy": [81]}, + {"id": 83, "subject": "Task 14: IOpcUaBrowseService + impl + DI registration", "status": "pending", "blockedBy": [75]}, + {"id": 84, "subject": "Task 15: Scaffold OpcUaBrowserDialog modal", "status": "pending"}, + {"id": 85, "subject": "Task 16: Tree rendering + lazy load + selection in dialog", "status": "pending", "blockedBy": [83, 84]}, + {"id": 86, "subject": "Task 17: Error banner mapping + polish on dialog", "status": "pending", "blockedBy": [85]}, + {"id": 87, "subject": "Task 18: Add Override column + Browse button to InstanceConfigure.razor", "status": "pending", "blockedBy": [83, 86]}, + {"id": 88, "subject": "Task 19: End-to-end manual smoke (online + offline)", "status": "pending", "blockedBy": [70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87]}, + {"id": 89, "subject": "Task 20: Update Component-DataConnectionLayer.md", "status": "pending", "blockedBy": [81]}, + {"id": 90, "subject": "Task 21: Update Component-TemplateEngine.md", "status": "pending", "blockedBy": [81]}, + {"id": 91, "subject": "Task 22: Update Component-CentralUI.md", "status": "pending", "blockedBy": [87]} + ], + "lastUpdated": "2026-05-28T00:00:00Z" +}