Five phases, PR-shippable per phase: schema/contracts, DCL browse capability, flattening uses override, Central UI popup + integration, docs. Per-task classification, time estimates, and parallelism declared.
69 KiB
OPC UA Tag Browser Popup — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use
superpowers-extended-cc:executing-plansto 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 — 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/<ProjectName>/<ProjectName>.csproj - Single test:
dotnet test tests/<ProjectName>/<ProjectName>.csproj --filter "FullyQualifiedName~<TestName>" - 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 <id>
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:
/// <summary>
/// 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 <c>DataSourceReference</c> during flattening. When null,
/// the template default is used.
/// </summary>
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
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:
public record ConnectionBinding(string AttributeName, int DataConnectionId);
with:
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:
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:
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<ConnectionBinding>(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<ConnectionBinding>(legacyJson)!;
deserialized.AttributeName.Should().Be("Speed");
deserialized.DataConnectionId.Should().Be(7);
deserialized.DataSourceReferenceOverride.Should().BeNull();
}
}
Step 4: Run the tests + build
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
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<InstanceConnectionBinding> block (it may be a separate IEntityTypeConfiguration<InstanceConnectionBinding> nested class or inline in Configure(...)).
Step 2: Add the column
Inside that block, append:
builder.Property(b => b.DataSourceReferenceOverride)
.HasMaxLength(512)
.IsRequired(false);
If the mapping file uses the EntityTypeBuilder<InstanceConnectionBinding> builder under a different variable name, adapt accordingly. The intent: NVARCHAR(512) NULL (matches the existing DataSourceReference length on TemplateAttribute).
Step 3: Build
dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj
Expected: 0 errors.
Step 4: Commit
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/<timestamp>_AddInstanceConnectionBindingOverride.cs - Create:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddInstanceConnectionBindingOverride.Designer.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs(auto-regenerated)
Step 1: Generate the migration
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 <timestamp>_AddInstanceConnectionBindingOverride.cs. The Up method should contain only:
migrationBuilder.AddColumn<string>(
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
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:
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
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
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
/// <summary>
/// Optional capability for an <see cref="IDataConnection"/> 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.
/// </summary>
public interface IBrowsableDataConnection
{
/// <summary>
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
/// the server's root-level nodes when null.
/// </summary>
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
CancellationToken cancellationToken = default);
}
/// <param name="Children">Child nodes returned by the server in browse order.</param>
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
public record BrowseChildrenResult(
IReadOnlyList<BrowseNode> Children,
bool Truncated);
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
/// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param>
/// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param>
public record BrowseNode(
string NodeId,
string DisplayName,
BrowseNodeClass NodeClass,
bool HasChildren);
public enum BrowseNodeClass { Object, Variable, Method, Other }
/// <summary>
/// Thrown by <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/> when
/// the underlying session is not currently connected. Translated to
/// <c>BrowseFailureKind.ConnectionNotConnected</c> by the site-side handler.
/// </summary>
public sealed class ConnectionNotConnectedException : InvalidOperationException
{
public ConnectionNotConnectedException(string message) : base(message) { }
}
Step 2: Build
dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
Expected: 0 errors.
Step 3: Commit
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
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <summary>
/// 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.
/// </summary>
/// <param name="DataConnectionId">Id of the site-local data connection to browse against.</param>
/// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param>
public record BrowseOpcUaNodeCommand(
int DataConnectionId,
string? ParentNodeId);
public record BrowseOpcUaNodeResult(
IReadOnlyList<BrowseNode> 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:
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
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
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:
/// <summary>
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
/// (or the server's ObjectsFolder when null). Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up.
/// </summary>
Task<BrowseChildrenResult> 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)
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:
public Task<BrowseChildrenResult> 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
dotnet build ZB.MOM.WW.ScadaBridge.slnx
Expected: 0 errors.
Step 5: Commit
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:
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;
/// <summary>
/// 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.
/// </summary>
[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<string, string> { ["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<Task> act = () => client.BrowseChildrenAsync(parentNodeId: null);
await act.Should().ThrowAsync<ConnectionNotConnectedException>();
}
}
(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
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:
public async Task<BrowseChildrenResult> 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<BrowseNode>(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
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
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:
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
{
// ... existing fields & methods ...
public Task<BrowseChildrenResult> 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:
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<IOpcUaClient>();
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<CancellationToken>()))
.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
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
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:
ReceiveAsync<BrowseOpcUaNodeCommand>(HandleBrowse);
Then add the handler method on the actor class:
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<BrowseNode>(), 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:
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<BrowseOpcUaNodeResult>();
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<BrowseOpcUaNodeResult>();
reply.Failure!.Kind.Should().Be(BrowseFailureKind.NotBrowsable);
}
[Fact]
public void Success_path_returns_mapped_children()
{
var browsable = new Mock<IBrowsableDataConnection>();
browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny<CancellationToken>()))
.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<BrowseOpcUaNodeResult>();
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<IBrowsableDataConnection>();
browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new ConnectionNotConnectedException("session down"));
var manager = SetUpManagerWithBrowsableConnection(id: 7, browsable.Object);
manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>();
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
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
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:
Receive<BrowseOpcUaNodeCommand>(msg =>
{
Context.ActorSelection("/user/data-connection-manager").Forward(msg);
});
If it's a child of DeploymentManagerActor, forward through that proxy:
Receive<BrowseOpcUaNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
— and add a Receive<BrowseOpcUaNodeCommand> 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
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:
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<ClusterFixture> // 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<BrowseOpcUaNodeResult>(
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
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:
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
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:
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
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
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:
[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
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
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:
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public interface IOpcUaBrowseService
{
Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
string siteId,
int dataConnectionId,
string? parentNodeId,
CancellationToken cancellationToken = default);
}
Services/OpcUaBrowseService.cs:
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<BrowseOpcUaNodeResult> 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<Commons.Interfaces.Protocol.BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
try
{
return await _communication.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(
siteId,
new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId),
cancellationToken);
}
catch (TimeoutException ex)
{
return new BrowseOpcUaNodeResult(
Array.Empty<Commons.Interfaces.Protocol.BrowseNode>(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
}
catch (Exception ex)
{
return new BrowseOpcUaNodeResult(
Array.Empty<Commons.Interfaces.Protocol.BrowseNode>(),
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:
builder.Services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>();
Step 3: Build
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
Expected: 0 errors.
Step 4: Commit
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: <OpcUaBrowserDialog/> 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.
@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)
{
<div class="modal show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Browse OPC UA — @ConnectionName</h5>
<button type="button" class="btn-close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
@if (_failure is not null)
{
<div class="alert alert-danger">
@_failureMessage
<button class="btn btn-sm btn-outline-danger ms-2" @onclick="RetryRootLoad">Retry</button>
</div>
}
<div class="opcua-browser-tree">
<!-- Task 16 fills this in -->
</div>
<hr />
<div class="input-group">
<span class="input-group-text">Manual node id:</span>
<input class="form-control" @bind="_manualNodeId" placeholder="ns=2;s=..." />
<button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button>
</div>
</div>
<div class="modal-footer">
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
<button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
</div>
</div>
</div>
</div>
}
@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<string> 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
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
Expected: 0 errors.
Step 3: Commit
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor
git commit -m "feat(centralui): scaffold <OpcUaBrowserDialog/> 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:
private record TreeNode(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren)
{
public List<TreeNode>? Children { get; set; } // null = not loaded yet
public bool Expanded { get; set; }
public bool Loading { get; set; }
public bool Truncated { get; set; }
}
private List<TreeNode> _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 <!-- Task 16 fills this in --> line with:
@if (_rootNodes.Count == 0 && _failure is null)
{
<em class="text-muted">Loading…</em>
}
else
{
<ul class="list-unstyled mb-0">
@foreach (var node in _rootNodes)
{
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" SelectedNodeId="@_selectedNodeId" />
}
</ul>
}
Create src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor as a small recursive child component:
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
<li>
<span style="cursor: @(Node.HasChildren ? "pointer" : "default");" @onclick="() => OnToggle.InvokeAsync(Node)">
@if (Node.HasChildren)
{
<span>@(Node.Expanded ? "▼" : "▶")</span>
}
else
{
<span style="display:inline-block; width:1em;"> </span>
}
</span>
@if (Node.NodeClass == BrowseNodeClass.Variable)
{
<a href="javascript:void(0)"
class="@(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => OnSelect.InvokeAsync(Node)"
@ondblclick="() => OnSelect.InvokeAsync(Node)">
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
</a>
}
else
{
<span class="text-muted">@Node.DisplayName</span>
}
@if (Node.Loading)
{
<em class="ms-2 text-muted">loading…</em>
}
@if (Node.Expanded && Node.Children is not null)
{
<ul class="list-unstyled ms-4">
@foreach (var child in Node.Children)
{
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" SelectedNodeId="@SelectedNodeId" />
}
@if (Node.Truncated)
{
<li><small class="text-warning">Results truncated — use manual entry if your tag isn't listed.</small></li>
}
</ul>
}
</li>
@code {
[Parameter] public OpcUaBrowserDialog.TreeNode Node { get; set; } = default!;
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnToggle { get; set; }
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> 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
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
Expected: 0 errors.
Step 4: Commit
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:
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
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
Expected: 0 errors.
Step 3: Commit
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 <th>Override</th><th></th>. In the row template, after the existing "Tag Path" cell, add:
<td>
<input class="form-control form-control-sm"
@bind="binding.DataSourceReferenceOverride"
placeholder="@(GetTemplateDefault(binding.AttributeName) ?? "(no default)")" />
</td>
<td>
@{
var canBrowse = CanBrowse(binding);
}
@if (IsOpcUa(binding))
{
<button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse OPC UA address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(binding)">
Browse…
</button>
}
</td>
(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:
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
<OpcUaBrowserDialog @ref="_browserRef"
SiteId="@_siteId"
DataConnectionId="@_browserConnectionId"
ConnectionName="@_browserConnectionName"
InitialNodeId="@_browserInitial"
OnSelected="OnBrowserSelected" />
Add private OpcUaBrowserDialog? _browserRef; in @code along with the other fields used above.
Step 5: Build
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
Expected: 0 errors.
Step 6: Commit
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 docker/deploy.sh
bash docker/seed-sites.sh # if cluster is fresh
Step 2: Sign in + walk the flow
- Open http://localhost:9000, sign in as
multi-role/password. - Navigate to a deployed instance's Configure page.
- On the Connection Bindings table, pick an OPC UA connection on a data-sourced attribute. Confirm the Browse… button shows up enabled.
- Click Browse. Confirm the dialog opens, the root browse populates within a couple of seconds, "Server" is visible under the root.
- Expand a folder; expand a device; click a Variable; confirm the footer shows the selected node id; click Select.
- Confirm the Override input now shows the picked node id.
- Save the bindings. Reload the page; confirm the override persists.
Step 3: Offline-path smoke
docker stop scadabridge-site-a-a scadabridge-site-a-b(whichever site backs the configured connection).- Click Browse. Confirm the error banner appears, manual-paste field is still usable.
- Type a node id in manual-paste, click Use → Select. Confirm the override persists despite the site being down.
docker start scadabridge-site-a-a scadabridge-site-a-bto 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:
### 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
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:
`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:
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
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:
- **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
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..HEADshows the slice. dotnet build ZB.MOM.WW.ScadaBridge.slnxclean (0 errors, 0 warnings).dotnet test ZB.MOM.WW.ScadaBridge.slnxgreen (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).