# 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).