From 28f685965c677ec1996a009e190fe22fc673de41 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:48:59 -0400 Subject: [PATCH 01/24] feat(commons): add DataSourceReferenceOverride to InstanceConnectionBinding --- .../Entities/Instances/InstanceConnectionBinding.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs index 125a86aa..21ca4e7d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs @@ -11,6 +11,14 @@ public class InstanceConnectionBinding /// Foreign key to the data connection that provides values for this attribute. public int DataConnectionId { get; set; } + /// + /// 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; } + /// /// Creates a binding for the specified attribute name. /// From 5645eb61a32c64102d0dececca31f554f6c49d10 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:49:03 -0400 Subject: [PATCH 02/24] feat(commons): add IBrowsableDataConnection capability interface --- .../Protocol/IBrowsableDataConnection.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs new file mode 100644 index 00000000..ff7ae19c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs @@ -0,0 +1,48 @@ +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) { } +} From d727a6925be0074d40c3a0c3286cd3695bb7c771 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:49:53 -0400 Subject: [PATCH 03/24] feat(commons): add BrowseOpcUaNodeCommand + result + failure types --- .../Messages/Management/BrowseCommands.cs | 31 +++++++++++++++++++ .../Messages/BrowseCommandsRegistryTests.cs | 23 ++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs new file mode 100644 index 00000000..fbc58205 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -0,0 +1,31 @@ +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 +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs new file mode 100644 index 00000000..a2042bd3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +/// +/// Verifies that is discovered by +/// so it travels over the management +/// boundary as a known command (resolvable by wire name and round-trippable +/// through GetCommandName / Resolve). +/// +public class BrowseCommandsRegistryTests +{ + [Fact] + public void Registry_discovers_BrowseOpcUaNodeCommand() + { + // GetCommandName throws ArgumentException for any type the registry + // does not contain, so a successful call here is proof of discovery. + var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseOpcUaNodeCommand)); + + Assert.Equal("BrowseOpcUaNode", name); + Assert.Equal(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name)); + } +} From 4fc546383f825d2146cef14d783ec78e2111f927 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:49:59 -0400 Subject: [PATCH 04/24] feat(centralui): scaffold modal --- .../Dialogs/OpcUaBrowserDialog.razor | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor new file mode 100644 index 00000000..381397d1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor @@ -0,0 +1,92 @@ +@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(); + } +} From 18130a6937ff9fe64af0196341246b7528aab1ad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:51:05 -0400 Subject: [PATCH 05/24] feat(configdb): map InstanceConnectionBinding.DataSourceReferenceOverride --- .../Configurations/InstanceConfiguration.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs index 60de88cf..bd14aeeb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs @@ -107,6 +107,10 @@ public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration b.DataSourceReferenceOverride) + .HasMaxLength(512) + .IsRequired(false); + builder.HasOne() .WithMany() .HasForeignKey(b => b.DataConnectionId) From 2ff138f1e8566b73c9278967b667fb9c1849ac33 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:52:28 -0400 Subject: [PATCH 06/24] feat(templates): apply InstanceConnectionBinding override during flattening --- .../Flattening/FlatteningService.cs | 3 +- .../ConnectionBindingOverrideTests.cs | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs index aff9c2d2..de6c7f0d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs @@ -365,7 +365,8 @@ public class FlatteningService { BoundDataConnectionId = connection.Id, BoundDataConnectionName = connection.Name, - BoundDataConnectionProtocol = connection.Protocol + BoundDataConnectionProtocol = connection.Protocol, + DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference }; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs new file mode 100644 index 00000000..cfbe30f6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs @@ -0,0 +1,83 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; + +namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening; + +public class ConnectionBindingOverrideTests +{ + private readonly FlatteningService _sut = new(); + + private static Instance CreateInstance(string name = "TestInstance", int templateId = 1, int siteId = 1) => + new(name) { Id = 1, TemplateId = templateId, SiteId = siteId }; + + private static Template CreateTemplate(int id, string name) + { + return new Template(name) { Id = id }; + } + + private static Template CreateTemplateWithDataSourcedAttribute(string attributeName, string dataSourceReference) + { + var template = CreateTemplate(1, "Base"); + template.Attributes.Add(new TemplateAttribute(attributeName) + { + DataType = DataType.Double, + DataSourceReference = dataSourceReference + }); + return template; + } + + private static Dictionary SingleConnection(int id = 1) => + new() + { + [id] = new("OPC-Server1", "OpcUa", 1) { Id = id, PrimaryConfiguration = "opc.tcp://localhost:4840" } + }; + + [Fact] + public void Override_replaces_template_DataSourceReference_when_set() + { + var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + var instance = CreateInstance(); + instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump1.Speed" + }); + + var result = _sut.Flatten( + instance, + [template], + new Dictionary>(), + new Dictionary>(), + SingleConnection(id: 1)); + + Assert.True(result.IsSuccess); + var attr = result.Value.Attributes.Single(a => a.CanonicalName == "Speed"); + Assert.Equal("ns=2;s=Pump1.Speed", attr.DataSourceReference); + } + + [Fact] + public void Null_override_falls_back_to_template_default() + { + var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + var instance = CreateInstance(); + instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = null + }); + + var result = _sut.Flatten( + instance, + [template], + new Dictionary>(), + new Dictionary>(), + SingleConnection(id: 1)); + + Assert.True(result.IsSuccess); + var attr = result.Value.Attributes.Single(a => a.CanonicalName == "Speed"); + Assert.Equal("TemplateDefault", attr.DataSourceReference); + } +} From 7fc1f752f8e117eb1adad361fb13f3233debf1b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:53:10 -0400 Subject: [PATCH 07/24] feat(dcl): add BrowseChildrenAsync to IOpcUaClient (NotImplementedException stubs) --- .../Adapters/IOpcUaClient.cs | 20 +++++++++++++++++++ .../Adapters/RealOpcUaClient.cs | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs index f1c6c8bb..ee7860cb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// @@ -102,6 +104,19 @@ public interface IOpcUaClient : IAsyncDisposable /// becomes unreachable. The adapter layer uses this to trigger reconnection. /// event Action? ConnectionLost; + + /// + /// Enumerates the immediate children of + /// (or the server's ObjectsFolder when null). Throws + /// when the session is not + /// currently up. + /// + /// Node id whose children to browse, or null for the server root. + /// A cancellation token that can be used to cancel the operation. + /// A task that completes with the immediate children of the requested node. + Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default); } /// @@ -180,6 +195,11 @@ internal class StubOpcUaClient : IOpcUaClient return Task.FromResult(0); // Good status } + /// + public Task BrowseChildrenAsync( + string? parentNodeId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + /// public ValueTask DisposeAsync() { diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 9feb0924..86b5f077 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -325,6 +325,12 @@ public class RealOpcUaClient : IOpcUaClient string.IsNullOrWhiteSpace(configured) ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf) : configured; + + /// + // Real implementation lands in Task 8 of the OPC UA tag browser plan. + public Task BrowseChildrenAsync( + string? parentNodeId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); } /// From aff1323896301dcd6d17fe6b8070523230feb834 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:53:24 -0400 Subject: [PATCH 08/24] feat(commons): carry DataSourceReferenceOverride on ConnectionBinding (additive) --- .../Messages/Management/InstanceCommands.cs | 11 +++++++- .../Services/InstanceService.cs | 12 ++++---- .../ConnectionBindingSerializationTests.cs | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs index 9e13c71f..e7fcb0e9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs @@ -12,8 +12,17 @@ public record MgmtDeleteInstanceCommand(int InstanceId); /// . This is a named record (not a /// ValueTuple) so it serializes with stable, named JSON properties and can /// evolve additively per REQ-COM-5a. +/// +/// DataSourceReferenceOverride is an optional per-instance override of +/// the OPC UA node id (or other protocol address) for the bound attribute. +/// When non-null it replaces the template's DataSourceReference at +/// flattening time; when null the template default is used. +/// /// -public record ConnectionBinding(string AttributeName, int DataConnectionId); +public record ConnectionBinding( + string AttributeName, + int DataConnectionId, + string? DataSourceReferenceOverride = null); public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList Bindings); public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary Overrides); diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs index 0dd483a5..1c69a847 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs @@ -330,20 +330,22 @@ public class InstanceService var results = new List(); - foreach (var (attrName, connId) in bindings) + foreach (var b in bindings) { - if (existingMap.TryGetValue(attrName, out var existing)) + if (existingMap.TryGetValue(b.AttributeName, out var existing)) { - existing.DataConnectionId = connId; + existing.DataConnectionId = b.DataConnectionId; + existing.DataSourceReferenceOverride = b.DataSourceReferenceOverride; await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken); results.Add(existing); } else { - var binding = new InstanceConnectionBinding(attrName) + var binding = new InstanceConnectionBinding(b.AttributeName) { InstanceId = instanceId, - DataConnectionId = connId + DataConnectionId = b.DataConnectionId, + DataSourceReferenceOverride = b.DataSourceReferenceOverride }; await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken); results.Add(binding); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs index 9a7e504c..856aaf5a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs @@ -51,4 +51,32 @@ public class ConnectionBindingSerializationTests // ConnectionBinding is a record: each element compares by value. Assert.Equal(original.Bindings, deserialized.Bindings); } + + [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); + + Assert.NotNull(roundtripped); + Assert.Equal(original, roundtripped); + Assert.Equal("ns=2;s=Pump1.Speed", roundtripped!.DataSourceReferenceOverride); + } + + [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); + + Assert.NotNull(deserialized); + Assert.Equal("Speed", deserialized!.AttributeName); + Assert.Equal(7, deserialized.DataConnectionId); + Assert.Null(deserialized.DataSourceReferenceOverride); + } } From 8d42a9b208213ad339acf12f8d73db354febb8aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:53:48 -0400 Subject: [PATCH 09/24] docs(templates): document per-instance DataSourceReference override --- docs/requirements/Component-TemplateEngine.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index f212ef47..09d0c891 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -97,7 +97,7 @@ breadcrumb. Override and lock rules apply per entity type at the following granularity: -- **Attributes**: Value and Description are overridable. Data Type and Data Source Reference are fixed by the defining level. Lock applies to the entire attribute (when locked, no fields can be overridden). +- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `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. Lock applies to the entire attribute (when locked, no fields can be overridden). - **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates), Description, and On-Trigger Script reference are overridable. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. - **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script. - **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name. @@ -166,6 +166,8 @@ Each flattened configuration output includes a **revision hash** (computed from - Staleness detection: comparing the deployed revision to the current template-derived revision without a full diff. - Diff correlation: ensuring diffs are computed against a consistent baseline. +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. + ### On-Demand Validation The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring. From c8529798357390ed16da585bc60950113f91b29c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:53:48 -0400 Subject: [PATCH 10/24] docs(dcl): document browse capability + BrowseOpcUaNodeCommand --- docs/requirements/Component-DataConnectionLayer.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index 2e909e96..26c72903 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -140,6 +140,14 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per- - The existing subscription picks up the confirmed new value from the device and delivers it back to the Instance Actor as a standard value update. - The Instance Actor's in-memory value is **not** updated until the device confirms the write. +## 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. + ## Value Update Message Format Each value update delivered to an Instance Actor includes: From 545a22e014f4294874461291b67117b113750e4b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:55:57 -0400 Subject: [PATCH 11/24] test(templates): override changes drive revision hash forward --- .../ConnectionBindingOverrideTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs index cfbe30f6..f3db7c93 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs @@ -80,4 +80,40 @@ public class ConnectionBindingOverrideTests var attr = result.Value.Attributes.Single(a => a.CanonicalName == "Speed"); Assert.Equal("TemplateDefault", attr.DataSourceReference); } + + [Fact] + public void Override_change_changes_revision_hash() + { + var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault"); + + var instance1 = CreateInstance(); + instance1.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump1.Speed" + }); + + var instance2 = CreateInstance(); + instance2.ConnectionBindings.Add(new InstanceConnectionBinding("Speed") + { + DataConnectionId = 1, + DataSourceReferenceOverride = "ns=2;s=Pump2.Speed" + }); + + var connections = SingleConnection(id: 1); + var compositionMap = new Dictionary>(); + var composedChains = new Dictionary>(); + + var result1 = _sut.Flatten(instance1, [template], compositionMap, composedChains, connections); + var result2 = _sut.Flatten(instance2, [template], compositionMap, composedChains, connections); + + Assert.True(result1.IsSuccess); + Assert.True(result2.IsSuccess); + + var hasher = new RevisionHashService(); + var hash1 = hasher.ComputeHash(result1.Value); + var hash2 = hasher.ComputeHash(result2.Value); + + Assert.NotEqual(hash1, hash2); + } } From 41c78f77009e73ccb053c9c2872750a48961084d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:56:04 -0400 Subject: [PATCH 12/24] feat(centralui+comm): IOpcUaBrowseService + typed BrowseOpcUaNodeAsync on CommunicationService --- .../ServiceCollectionExtensions.cs | 5 ++ .../Services/IOpcUaBrowseService.cs | 37 ++++++++ .../Services/OpcUaBrowseService.cs | 88 +++++++++++++++++++ .../CommunicationService.cs | 24 +++++ 4 files changed, 154 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 9b8fdcb7..6cfebd3f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -50,6 +50,11 @@ public static class ServiceCollectionExtensions // Backs the Audit Log page's Export button via GET /api/centralui/audit/export. services.AddScoped(); + // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync + // that enforces the CentralUI-side Design-role trust boundary and translates + // transport failures into typed BrowseFailure results for the dialog. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs new file mode 100644 index 00000000..37fedeee --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// CentralUI facade over the central-to-site OPC UA browse command. Backs the +/// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls +/// , which forwards a +/// to the owning site via +/// . +/// +/// +/// The service is the trust boundary for the browse capability: it enforces the +/// Design role at central before any cross-cluster traffic is generated, +/// because site-side actors do not unwrap the central trust envelope. Transport +/// failures (timeouts, unreachable sites) are translated into a typed +/// so the dialog can render an inline error and +/// remain usable (manual node-id paste still works). +/// +public interface IOpcUaBrowseService +{ + /// + /// Enumerates the immediate children of an OPC UA node on the live server + /// backing at . + /// Pass null for to browse from the + /// server root (ObjectsFolder). + /// + /// The target site identifier. + /// Id of the site-local data connection to browse against. + /// Node to browse, or null to browse from the server root. + /// Cancellation token. + Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs new file mode 100644 index 00000000..2b50d9b3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Components.Authorization; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Communication; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default implementation — a thin facade over +/// that enforces the +/// CentralUI-side Design-role trust boundary and translates transport +/// exceptions into a typed result. +/// +/// +/// Site-side actors (SiteCommunicationActor + DeploymentManagerActor) +/// do not unwrap the central trust envelope, so the role check MUST run here — +/// never on the site. Transport failures collapse into Timeout or +/// ServerError so the dialog can show an inline banner while leaving the +/// manual node-id paste field usable. +/// +public sealed class OpcUaBrowseService : IOpcUaBrowseService +{ + private readonly CommunicationService _communication; + private readonly AuthenticationStateProvider _auth; + + /// + /// Initializes a new instance of the . + /// + /// Central-side cluster communication service. + /// Authentication state provider used for the Design-role guard. + public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + } + + /// + public async Task BrowseChildrenAsync( + string siteId, + int dataConnectionId, + string? parentNodeId, + CancellationToken cancellationToken = default) + { + // CentralUI-side role guard — sites don't enforce envelope-level roles, + // so the Design check must happen here before any cross-cluster traffic. + 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.BrowseOpcUaNodeAsync( + siteId, + new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId), + cancellationToken); + } + catch (TimeoutException ex) + { + // Akka Ask timed out — the site (or its OPC UA session) didn't answer + // within CommunicationOptions.QueryTimeout. Surface as a typed + // Timeout failure so the dialog can render an inline banner. + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.Timeout, ex.Message)); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel — propagate so Blazor can drop the response + // cleanly. Distinct from Timeout (which the dialog renders inline). + throw; + } + catch (Exception ex) + { + // Any other transport / serialization failure: keep the dialog + // alive and let the user fall back to manual node-id paste. + return new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 68bc6723..f91ddfe2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Communication.Actors; @@ -346,6 +347,29 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + // ── OPC UA Tag Browser (interactive design-time query) ── + + /// + /// Asks a site to enumerate the immediate children of an OPC UA node on the + /// live server backing the given data connection. Used by the CentralUI OPC UA + /// Tag Browser dialog. The Ask is bounded by + /// — interactive browse expansions are short, one-shot queries that share the + /// same latency budget as other remote queries (event logs, parked messages). + /// + /// The target site identifier. + /// The OPC UA browse command. + /// Cancellation token. + /// The browse result (children + truncation flag + structured failure). + public Task BrowseOpcUaNodeAsync( + string siteId, + BrowseOpcUaNodeCommand command, + CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, command); + return GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + // ── Pattern 8: Heartbeat (site→central, Tell) ── // Heartbeats are received by central, not sent. No method needed here. From d79d7fdf714d67a3e162ea967b857d0b864a3e1c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:56:56 -0400 Subject: [PATCH 13/24] feat(configdb): migration AddInstanceConnectionBindingOverride --- ...tanceConnectionBindingOverride.Designer.cs | 1667 +++++++++++++++++ ...20_AddInstanceConnectionBindingOverride.cs | 29 + .../ScadaBridgeDbContextModelSnapshot.cs | 4 + 3 files changed, 1700 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.Designer.cs new file mode 100644 index 00000000..1fc7a26d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.Designer.cs @@ -0,0 +1,1667 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260528155520_AddInstanceConnectionBindingOverride")] + partial class AddInstanceConnectionBindingOverride + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.cs new file mode 100644 index 00000000..5ee11772 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260528155520_AddInstanceConnectionBindingOverride.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class AddInstanceConnectionBindingOverride : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DataSourceReferenceOverride", + table: "InstanceConnectionBindings", + type: "nvarchar(512)", + maxLength: 512, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DataSourceReferenceOverride", + table: "InstanceConnectionBindings"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 6a77e91f..d4769c2b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -769,6 +769,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations b.Property("DataConnectionId") .HasColumnType("int"); + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + b.Property("InstanceId") .HasColumnType("int"); From 0b4b4c02f60ae7614f7a29dcf36fe4b2618e8c88 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:58:08 -0400 Subject: [PATCH 14/24] feat(dcl): implement IBrowsableDataConnection on OpcUaDataConnection --- .../Adapters/OpcUaDataConnection.cs | 8 +++- .../OpcUaDataConnectionBrowseTests.cs | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index 2510db86..665b2919 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// - Read/Write → Read/Write service calls /// - Quality → OPC UA StatusCode mapping /// -public class OpcUaDataConnection : IDataConnection +public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection { private readonly IOpcUaClientFactory _clientFactory; private readonly ILogger _logger; @@ -274,6 +274,12 @@ public class OpcUaDataConnection : IDataConnection return results; } + /// + public Task BrowseChildrenAsync( + string? parentNodeId, + CancellationToken cancellationToken = default) + => _client!.BrowseChildrenAsync(parentNodeId, cancellationToken); + /// public async Task WriteBatchAndWaitAsync( IDictionary values, string flagPath, object? flagValue, diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs new file mode 100644 index 00000000..0d0e06fd --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// Task 9 (opcua-tag-browser): implements +/// as a one-line forwarder onto the +/// underlying . This guards that wiring — the +/// adapter must not add its own browse semantics; all browse behaviour lives +/// in the client (and is covered by RealOpcUaClient's own tests). +/// +public class OpcUaDataConnectionBrowseTests +{ + [Fact] + public async Task BrowseChildrenAsync_ForwardsToUnderlyingClient() + { + var client = Substitute.For(); + var factory = Substitute.For(); + factory.Create().Returns(client); + client.IsConnected.Returns(true); + + var expected = new BrowseChildrenResult( + new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) }, + Truncated: false); + client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any()) + .Returns(expected); + + var adapter = new OpcUaDataConnection(factory, NullLogger.Instance); + // ConnectAsync installs the IOpcUaClient instance on the adapter; the + // forwarder dereferences that field directly. + await adapter.ConnectAsync(new Dictionary()); + + var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent"); + + Assert.Same(expected, actual); + await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any()); + } +} From 1d2e2c1614f13b799958d8a6fb91ac7425c2828b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:58:59 -0400 Subject: [PATCH 15/24] feat(centralui): tree rendering + lazy load + selection in OpcUaBrowserDialog --- .../Dialogs/OpcUaBrowserDialog.razor | 97 ++++++++++++++++++- .../Components/Dialogs/TreeRow.razor | 53 ++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor index 381397d1..04f0042d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor @@ -22,7 +22,19 @@ }
- + @if (_rootNodes.Count == 0 && _failure is null) + { + Loading… + } + else + { +
    + @foreach (var node in _rootNodes) + { + + } +
+ }

@@ -56,6 +68,28 @@ private string _manualNodeId = ""; private BrowseFailure? _failure; private string _failureMessage = ""; + private List _rootNodes = new(); + + public sealed class TreeNode + { + public TreeNode(string nodeId, string displayName, BrowseNodeClass nodeClass, bool hasChildren) + { + NodeId = nodeId; + DisplayName = displayName; + NodeClass = nodeClass; + HasChildren = hasChildren; + } + + public string NodeId { get; } + public string DisplayName { get; } + public BrowseNodeClass NodeClass { get; } + public bool HasChildren { get; } + + public List? Children { get; set; } // null = not loaded yet + public bool Expanded { get; set; } + public bool Loading { get; set; } + public bool Truncated { get; set; } + } public async Task ShowAsync() { @@ -67,7 +101,66 @@ private async Task LoadRootAsync() { - // Task 16 + _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; + } + + // NOTE: Task 17 will replace this body with the full BrowseFailureKind switch + // that maps each failure kind to a friendly UI message. + private void SetFailure(BrowseFailure failure) + { + _failure = failure; + _failureMessage = failure.Message; } private Task RetryRootLoad() => LoadRootAsync(); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor new file mode 100644 index 00000000..5ae9038b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor @@ -0,0 +1,53 @@ +@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; } +} From 6999aedc603f4dbb4bc48a1cf7ef0dbf99531cf2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:59:03 -0400 Subject: [PATCH 16/24] feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient --- .../Adapters/RealOpcUaClient.cs | 66 ++++++++++++++++- .../Adapters/RealOpcUaClientBrowseTests.cs | 73 +++++++++++++++++++ ...adaBridge.DataConnectionLayer.Tests.csproj | 7 ++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 86b5f077..27d6b1e0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -327,10 +327,70 @@ public class RealOpcUaClient : IOpcUaClient : configured; /// - // Real implementation lands in Task 8 of the OPC UA tag browser plan. - public Task BrowseChildrenAsync( + public async Task BrowseChildrenAsync( string? parentNodeId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + // Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session + // reference once, fail fast with a typed exception if the link is + // down, then call the SDK's async API directly (no Task.Run wrap — + // the OPC Foundation SDK already provides true async I/O). + var session = _session; + if (session is null || !session.Connected) + { + throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException( + "OPC UA session is not connected."); + } + + // ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty + // / null input means "browse the root"; anything else is parsed as + // an absolute NodeId expression. + var nodeToBrowse = string.IsNullOrEmpty(parentNodeId) + ? ObjectIds.ObjectsFolder + : NodeId.Parse(parentNodeId); + + // NodeClassMask intentionally excludes ReferenceType, View, Variable- + // Type, ObjectType, DataType. UI only needs Objects (navigable), + // Variables (selectable), Methods (display-only). + var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method); + + var (_, continuationPoint, references) = await session.BrowseAsync( + null, + null, + nodeToBrowse, + 1000u, + BrowseDirection.Forward, + ReferenceTypeIds.HierarchicalReferences, + true, + nodeClassMask, + cancellationToken).ConfigureAwait(false); + + var refs = references ?? new ReferenceDescriptionCollection(); + var children = new List(refs.Count); + foreach (var r in refs) + { + children.Add(new Commons.Interfaces.Protocol.BrowseNode( + NodeId: r.NodeId.ToString(), + DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)", + NodeClass: MapNodeClass(r.NodeClass), + HasChildren: r.NodeClass == NodeClass.Object)); + } + + // A non-empty continuation point means the server had more refs than + // our requestedMaxReferencesPerNode cap. The UI surfaces a "more + // children, type the node id manually" hint rather than auto-paging; + // BrowseNext is not invoked here. Discarding the continuation point + // is acceptable because the server expires it on session close. + var truncated = continuationPoint != null && continuationPoint.Length > 0; + return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated); + } + + private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch + { + NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object, + NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable, + NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method, + _ => Commons.Interfaces.Protocol.BrowseNodeClass.Other + }; } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs new file mode 100644 index 00000000..1b6c66eb --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs @@ -0,0 +1,73 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// Tests for . +/// +/// Two shapes here: +/// +/// 1. A live round-trip against the infra OPC UA server +/// (opc.tcp://localhost:50000 — see infra/docker-compose.yml, +/// started via cd infra && docker compose up -d opcua). +/// Marked [SkippableFact] so it reports Skipped — not failed — on +/// machines that don't have the infra stack running. The live test asserts +/// that the server root browse returns the standard "Server" node, which +/// proves we targeted ObjectsFolder (ns=0;i=85) and that the +/// response mapping survived the round trip. +/// +/// 2. A pure unit test that exercises the not-connected guard — no infra +/// needed, runs in every build. +/// +[Trait("Category", "RequiresOpcUa")] +public class RealOpcUaClientBrowseTests +{ + // The infra/docker-compose.yml opcua container maps the OPC PLC simulator + // on host port 50000 (not the OPC UA default 4840). Matches what the + // existing docker/ and docker-env2/ topologies dial into. + private const string EndpointUrl = "opc.tcp://localhost:50000"; + + [SkippableFact] + public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node() + { + await using var client = new RealOpcUaClient(); + + // Probe the endpoint before asserting anything. If the infra OPC UA + // server isn't up, ConnectAsync surfaces a socket/timeout error from + // deep inside the OPC Foundation SDK — we treat that as "infra not + // available" and skip rather than fail, mirroring the SkippableFact + // pattern already used in ConfigurationDatabase/AuditLog tests. + try + { + await client.ConnectAsync( + EndpointUrl, + new OpcUaConnectionOptions(AutoAcceptUntrustedCerts: true)); + } + catch (Exception ex) + { + Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}"); + return; // Skip.If throws; this is unreachable but keeps the compiler happy. + } + + var result = await client.BrowseChildrenAsync(parentNodeId: null); + + // Under ObjectsFolder (ns=0;i=85) every OPC UA-compliant server + // exposes a 'Server' object at ns=0;i=2253 — its presence confirms + // we hit the right root and that DisplayName mapping survives the + // round trip. + Assert.NotEmpty(result.Children); + Assert.Contains(result.Children, n => n.DisplayName == "Server"); + } + + [Fact] + public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null() + { + // No ConnectAsync — _session is still null, so the typed guard at the + // top of BrowseChildrenAsync should fire before any SDK call. + await using var client = new RealOpcUaClient(); + + await Assert.ThrowsAsync( + () => client.BrowseChildrenAsync(parentNodeId: null)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj index c55f29ed..207b1e85 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj @@ -15,6 +15,13 @@ + + From d2851745971da2aa27f9862e6ee71d40f088d4a3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:09:43 -0400 Subject: [PATCH 17/24] feat(dcl+ui): rename BrowseOpcUaNode -> ConnectionName-keyed; implement site handler + dialog failure mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrowseOpcUaNodeCommand: int DataConnectionId -> string ConnectionName (site DataConnectionManagerActor indexes children by name; CentralUI already has the connection name in scope via the dropdown — no extra plumbing across the trust boundary). - IOpcUaBrowseService / OpcUaBrowseService: parameter renamed accordingly. - OpcUaBrowserDialog: collapse the duplicate ConnectionName parameters (display label and routing key are the same string). - Task 10: DataConnectionManagerActor forwards BrowseOpcUaNodeCommand to its child by name (owns ConnectionNotFound); DataConnectionActor adds the receive across all three lifecycle states (Connecting / Connected / Reconnecting) and maps adapter outcomes to BrowseFailureKind (NotBrowsable / ConnectionNotConnected / Timeout / ServerError). - Task 17: SetFailure in OpcUaBrowserDialog implements the full BrowseFailureKind switch with friendly UI messages. - Tests: DataConnectionManagerBrowseHandlerTests covers ConnectionNotFound, NotBrowsable, success, and ConnectionNotConnectedException paths. --- .../Dialogs/OpcUaBrowserDialog.razor | 28 ++- .../Services/IOpcUaBrowseService.cs | 6 +- .../Services/OpcUaBrowseService.cs | 4 +- .../Messages/Management/BrowseCommands.cs | 11 +- .../Actors/DataConnectionActor.cs | 83 +++++++++ .../Actors/DataConnectionManagerActor.cs | 29 +++ ...DataConnectionManagerBrowseHandlerTests.cs | 165 ++++++++++++++++++ 7 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor index 04f0042d..b1895772 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor @@ -57,7 +57,12 @@ @code { [Parameter] public string SiteId { get; set; } = ""; - [Parameter] public int DataConnectionId { get; set; } + /// + /// Name of the site-local data connection. Serves both as the modal-header + /// display label AND as the routing key for the browse round-trip — the + /// site's DataConnectionManagerActor indexes its children by + /// connection name (no id-keyed lookup at the site). + /// [Parameter] public string ConnectionName { get; set; } = ""; [Parameter] public string? InitialNodeId { get; set; } [Parameter] public EventCallback OnSelected { get; set; } @@ -105,7 +110,7 @@ _rootNodes = new(); StateHasChanged(); - var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, parentNodeId: null); + var result = await BrowseService.BrowseChildrenAsync(SiteId, ConnectionName, parentNodeId: null); if (result.Failure is not null) { SetFailure(result.Failure); @@ -130,7 +135,7 @@ { node.Loading = true; StateHasChanged(); - var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, node.NodeId); + var result = await BrowseService.BrowseChildrenAsync(SiteId, ConnectionName, node.NodeId); node.Loading = false; if (result.Failure is not null) @@ -155,12 +160,23 @@ _manualNodeId = node.NodeId; } - // NOTE: Task 17 will replace this body with the full BrowseFailureKind switch - // that maps each failure kind to a friendly UI message. + // Task 17: map each BrowseFailureKind to a friendly UI message. The raw + // failure.Message is surfaced verbatim only for ServerError (which carries + // the OPC UA SDK's own Bad_* text) and as the default fallback for any + // future failure kind added without a UI mapping. private void SetFailure(BrowseFailure failure) { _failure = failure; - _failureMessage = failure.Message; + _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(); } private Task RetryRootLoad() => LoadRootAsync(); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs index 37fedeee..ea9aaf0e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs @@ -21,17 +21,17 @@ public interface IOpcUaBrowseService { /// /// Enumerates the immediate children of an OPC UA node on the live server - /// backing at . + /// backing at . /// Pass null for to browse from the /// server root (ObjectsFolder). /// /// The target site identifier. - /// Id of the site-local data connection to browse against. + /// Name of the site-local data connection to browse against — the site's DataConnectionManagerActor indexes its children by name. /// Node to browse, or null to browse from the server root. /// Cancellation token. Task BrowseChildrenAsync( string siteId, - int dataConnectionId, + string connectionName, string? parentNodeId, CancellationToken cancellationToken = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs index 2b50d9b3..6adec7f3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs @@ -37,7 +37,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService /// public async Task BrowseChildrenAsync( string siteId, - int dataConnectionId, + string connectionName, string? parentNodeId, CancellationToken cancellationToken = default) { @@ -56,7 +56,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService { return await _communication.BrowseOpcUaNodeAsync( siteId, - new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId), + new BrowseOpcUaNodeCommand(connectionName, parentNodeId), cancellationToken); } catch (TimeoutException ex) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs index fbc58205..73fffa58 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -6,10 +6,17 @@ 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. +/// +/// Keyed by (not id) because the site-side +/// DataConnectionManagerActor indexes its children by connection name — +/// the central UI already has the connection name in scope (dropdown), so a +/// string carries no extra plumbing across the trust boundary. The central +/// DataConnections table's id is intentionally not exposed at the site. +/// +/// Name 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 ConnectionName, string? ParentNodeId); public record BrowseOpcUaNodeResult( diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 2c6b5963..dc5ab774 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Event; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; @@ -233,6 +234,13 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // apply it so its state survives into the next ReSubscribeAll. HandleSubscribeCompleted(sc); break; + case BrowseOpcUaNodeCommand browse: + // Browse is an interactive design-time query; never stash. The + // adapter has no session yet in this state, so reply with a + // typed ConnectionNotConnected failure so the dialog can render + // an inline banner. + HandleBrowse(browse); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -293,6 +301,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers case RetryTagResolution: HandleRetryTagResolution(); break; + case BrowseOpcUaNodeCommand browse: + HandleBrowse(browse); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -412,6 +423,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // apply it so its state survives into the next ReSubscribeAll. HandleSubscribeCompleted(sc); break; + case BrowseOpcUaNodeCommand browse: + // Browse is design-time and never stashed. While reconnecting + // the adapter has no live session, so the adapter call will + // throw ConnectionNotConnectedException — mapped by HandleBrowse. + HandleBrowse(browse); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -947,6 +964,72 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers }).PipeTo(sender); } + // ── OPC UA Tag Browser (interactive design-time query) ── + + /// + /// Handles a forwarded by the + /// . The capability check (does + /// this adapter support browsing?) and all browse-failure mapping live + /// here because the adapter is held by this actor, not the manager. + /// + /// Failure mapping: + /// + /// — adapter is not . + /// — adapter threw . + /// — adapter threw . + /// — any other exception, message carried verbatim. + /// + /// + /// The reply is sent via PipeTo(sender) — the same pattern used by + /// — so the captured is + /// safe to use from the continuation (which runs off the actor thread). + /// + private void HandleBrowse(BrowseOpcUaNodeCommand command) + { + var sender = Sender; + + if (_adapter is not IBrowsableDataConnection browsable) + { + _log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName); + sender.Tell(new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure( + BrowseFailureKind.NotBrowsable, + $"Connection '{_connectionName}' does not support browsing."))); + return; + } + + _log.Debug("[{0}] Browsing OPC UA children of {1}", _connectionName, command.ParentNodeId ?? "(root)"); + + browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null); + } + + var baseEx = t.Exception?.GetBaseException(); + return baseEx switch + { + ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)), + OperationCanceledException => new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")), + _ => new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure( + BrowseFailureKind.ServerError, + baseEx?.Message ?? "Unknown browse error.")), + }; + }).PipeTo(sender); + } + // ── Tag Resolution Retry (WP-12) ── private void HandleRetryTagResolution() diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs index 5ff66fd1..18e0998b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Event; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; @@ -45,6 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor Receive(HandleRouteWrite); Receive(HandleRemoveConnection); Receive(HandleGetAllHealthReports); + Receive(HandleBrowse); } private void HandleCreateConnection(CreateConnectionCommand command) @@ -111,6 +113,33 @@ public class DataConnectionManagerActor : ReceiveActor } } + /// + /// Routes a from the central UI's OPC UA + /// Tag Browser to the child that owns the + /// named connection. The manager is the only actor that knows whether a + /// connection exists at this site — so it owns the + /// failure. Everything + /// else (capability check, session state, server errors) lives inside the + /// child where the adapter is held. + /// + private void HandleBrowse(BrowseOpcUaNodeCommand command) + { + if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) + { + actor.Forward(command); + } + else + { + _log.Warning("No connection actor for {0} during browse", command.ConnectionName); + Sender.Tell(new BrowseOpcUaNodeResult( + Array.Empty(), + Truncated: false, + new BrowseFailure( + BrowseFailureKind.ConnectionNotFound, + $"No data connection named '{command.ConnectionName}' at this site."))); + } + } + private void HandleRemoveConnection(RemoveConnectionCommand command) { if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs new file mode 100644 index 00000000..110fdce3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs @@ -0,0 +1,165 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; +using ZB.MOM.WW.ScadaBridge.HealthMonitoring; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; + +/// +/// Task 10 (opcua-tag-browser): the site-side +/// + child +/// together resolve +/// against the live adapter and surface +/// every browse outcome as a typed . The split is: +/// the manager owns (only it +/// knows the per-site connection set); everything else lives in the child where +/// the adapter is held — from the +/// capability check, / +/// / +/// from the adapter call. These tests guard that split. +/// +public class DataConnectionManagerBrowseHandlerTests : TestKit +{ + private readonly IDataConnectionFactory _factory; + private readonly ISiteHealthCollector _healthCollector; + private readonly DataConnectionOptions _options; + + public DataConnectionManagerBrowseHandlerTests() + : base(@"akka.loglevel = WARNING") + { + _factory = Substitute.For(); + _healthCollector = Substitute.For(); + _options = new DataConnectionOptions + { + ReconnectInterval = TimeSpan.FromSeconds(30), + TagResolutionRetryInterval = TimeSpan.FromSeconds(30), + }; + } + + [Fact] + public void Unknown_connection_name_returns_ConnectionNotFound() + { + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + + // No CreateConnectionCommand sent — the manager has zero children, so a + // browse against any name must be rejected with ConnectionNotFound + // (the manager is the only actor with site-level visibility). + manager.Tell(new BrowseOpcUaNodeCommand("unknown-connection", ParentNodeId: null)); + + var reply = ExpectMsg(); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); + Assert.Empty(reply.Children); + } + + [Fact] + public void Non_browsable_adapter_returns_NotBrowsable() + { + // Bare IDataConnection — no IBrowsableDataConnection. The child actor's + // capability check must surface this as NotBrowsable. + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + adapter.Status.Returns(ConnectionHealth.Connected); + _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-bare", "OpcUa", new Dictionary(), null, 3)); + + // Give the manager a moment to spawn the child actor. We do not need to + // wait for Connected — the browse handler runs in all states. + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); + Assert.Empty(reply.Children); + } + + [Fact] + public void Success_path_returns_mapped_children() + { + // Adapter implementing both IDataConnection (so DataConnectionActor can + // run its lifecycle) AND IBrowsableDataConnection (so the browse handler + // takes the success path). + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + + var children = new[] + { + new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, HasChildren: false), + new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true), + }; + ((IBrowsableDataConnection)adapter) + .BrowseChildrenAsync(null, Arg.Any()) + .Returns(new BrowseChildrenResult(children, Truncated: false)); + + _factory.Create("OpcUa", Arg.Any>()) + .Returns((IDataConnection)adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-ok", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Null(reply.Failure); + Assert.Equal(2, reply.Children.Count); + Assert.Equal("ns=2;s=A", reply.Children[0].NodeId); + Assert.Equal("ns=2;s=B", reply.Children[1].NodeId); + Assert.False(reply.Truncated); + } + + [Fact] + public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() + { + var adapter = Substitute.For(); + ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); + + ((IBrowsableDataConnection)adapter) + .BrowseChildrenAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException( + new ConnectionNotConnectedException("OPC UA session is not connected."))); + + _factory.Create("OpcUa", Arg.Any>()) + .Returns((IDataConnection)adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-down", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); + Assert.Empty(reply.Children); + } +} From e6f9f91bb325fd32e6e1f22f4037d8623fd1fe02 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:12:29 -0400 Subject: [PATCH 18/24] feat(comm): route BrowseOpcUaNodeCommand from central to site DCL manager Wires the OPC UA Tag Browser cross-cluster path: central UI Asks via CommunicationService.BrowseOpcUaNodeAsync -> ClusterClient -> site SiteCommunicationActor -> /user/dcl-manager (Task 10 handler). Uses ActorSelection.Tell(msg, Sender) since DataConnectionManagerActor is not a child of DeploymentManagerActor and ActorSelection has no Forward() helper; preserving Sender keeps the BrowseOpcUaNodeResult routing back to the original Ask. Integration test deferred: tests/ZB.MOM.WW.ScadaBridge.IntegrationTests has no ClusterFixture (only ScadaBridgeWebApplicationFactory, which does not expose a Communication service nor a seeded site OPC UA connection). Round-trip will be exercised manually under Task 19. --- .../Actors/SiteCommunicationActor.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index 728302a3..d83beabf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; @@ -144,6 +145,24 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers Receive(msg => _deploymentManagerProxy.Forward(msg)); Receive(msg => _deploymentManagerProxy.Forward(msg)); + // OPC UA Tag Browser (interactive design-time query) — forward to the + // site-local Data Connection Manager actor, which owns the in-memory + // map of live data-connection adapters keyed by ConnectionName and + // executes the browse against the appropriate OPC UA client. The + // manager is not a child of DeploymentManagerActor, so we route via + // ActorSelection rather than the _deploymentManagerProxy field; the + // path matches the registration in AkkaHostedService. ActorSelection + // has no Forward() helper, so we Tell with the original Sender so the + // BrowseOpcUaNodeResult routes straight back to the central UI's Ask + // (via the CentralCommunicationActor sender chain), not to us. + Receive(msg => + { + _log.Debug( + "Routing BrowseOpcUaNodeCommand for connection '{0}' to DataConnectionManager", + msg.ConnectionName); + Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender); + }); + // Pattern 7: Remote Queries Receive(msg => { From 3162370a8f30c395ddd527f3db25d8a057a10a21 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:14:26 -0400 Subject: [PATCH 19/24] feat(centralui): add OPC UA browse button + override column to InstanceConfigure --- .../Pages/Deployment/InstanceConfigure.razor | 123 +++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 109cb0ca..ddd95f87 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -9,6 +9,7 @@ @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.DeploymentManager +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @@ -108,17 +109,22 @@ Attribute Tag Path Connection + Override + @foreach (var attr in _bindingDataSourceAttrs) { + var connId = GetBindingConnectionId(attr.Name); + var canBrowse = connId > 0; + var isOpcUa = IsOpcUa(connId); @attr.Name @attr.DataSourceReference + + + + + @if (isOpcUa) + { + + } + } @@ -331,6 +354,14 @@ + + @* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser + tracks which binding row's override input receives the picked node id. *@ + } @@ -350,8 +381,24 @@ private List _bindingDataSourceAttrs = new(); private List _siteConnections = new(); private Dictionary _bindingSelections = new(); + /// + /// Per-attribute DataSourceReferenceOverride values (Task 18). Mirrors + /// by attribute name. Loaded from the + /// existing rows on init; round-tripped + /// through on SaveBindings. + /// + private Dictionary _bindingOverrides = new(); private int _bulkConnectionId; + // OPC UA tag browser (Task 18) — single dialog rendered at page bottom; + // _browserAttrInEdit tracks which row gets the picked node id on Select. + private OpcUaBrowserDialog? _browserRef; + private string? _browserAttrInEdit; + private string _browserSiteIdentifier = ""; + private string _browserConnectionName = ""; + private string? _browserInitial; + private string _siteIdentifier = ""; + // Overrides private List _overrideAttrs = new(); private Dictionary _overrideValues = new(); @@ -407,7 +454,11 @@ _templateName = template?.Name ?? $"#{_instance.TemplateId}"; var sites = await SiteRepository.GetAllSitesAsync(); - _siteName = sites.FirstOrDefault(s => s.Id == _instance.SiteId)?.Name ?? $"#{_instance.SiteId}"; + var site = sites.FirstOrDefault(s => s.Id == _instance.SiteId); + _siteName = site?.Name ?? $"#{_instance.SiteId}"; + // Task 18: cache the site's machine identifier — the OPC UA browse + // dialog routes by SiteIdentifier (string), not the numeric site id. + _siteIdentifier = site?.SiteIdentifier ?? ""; // Areas _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_instance.SiteId)).ToList(); @@ -420,7 +471,11 @@ _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList(); var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id); foreach (var b in existingBindings) + { _bindingSelections[b.AttributeName] = b.DataConnectionId; + if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride)) + _bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride; + } // Overrides _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); @@ -474,12 +529,74 @@ _bindingSelections[attr.Name] = _bulkConnectionId; } + // ── Task 18: per-attribute override input + OPC UA tag browser ────────── + + private string? GetOverrideForAttr(string attrName) + => _bindingOverrides.GetValueOrDefault(attrName); + + private void OnOverrideForAttrChanged(string attrName, ChangeEventArgs e) + { + var val = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(val)) + _bindingOverrides.Remove(attrName); + else + _bindingOverrides[attrName] = val; + } + + /// Looks up the template default DataSourceReference for an attribute. + private string? GetTemplateDefault(string attrName) + => _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; + + /// True when the row's selected data connection is OPC UA. + private bool IsOpcUa(int connectionId) + => connectionId > 0 + && string.Equals( + _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol, + "OpcUa", + StringComparison.OrdinalIgnoreCase); + + /// + /// Opens the OPC UA tag browser dialog for the given attribute row. Remembers + /// which attribute is being edited so can + /// write the picked node id back to the right override input. + /// + private async Task OpenBrowser(string attrName) + { + var connId = GetBindingConnectionId(attrName); + var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); + if (conn is null) return; + + _browserAttrInEdit = attrName; + _browserConnectionName = conn.Name; + _browserSiteIdentifier = _siteIdentifier; + _browserInitial = _bindingOverrides.GetValueOrDefault(attrName) + ?? GetTemplateDefault(attrName); + + if (_browserRef is not null) + await _browserRef.ShowAsync(); + } + + private void OnBrowserSelected(string nodeId) + { + if (_browserAttrInEdit is null) return; + _bindingOverrides[_browserAttrInEdit] = nodeId; + _browserAttrInEdit = null; + } + private async Task SaveBindings() { _saving = true; try { - var bindings = _bindingSelections.Select(kv => new ConnectionBinding(kv.Key, kv.Value)).ToList(); + // Task 18: include the per-attribute DataSourceReferenceOverride on + // the wire record so it round-trips through SetConnectionBindingsAsync + // into the InstanceConnectionBinding entity. + var bindings = _bindingSelections + .Select(kv => new ConnectionBinding( + kv.Key, + kv.Value, + _bindingOverrides.GetValueOrDefault(kv.Key))) + .ToList(); var user = await GetCurrentUserAsync(); var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); if (result.IsSuccess) From c2919c2c3832e2fef7e435b42da8fd1935fb5518 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:15:39 -0400 Subject: [PATCH 20/24] docs(centralui): document OPC UA browse popup + override column --- docs/requirements/Component-CentralUI.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 9eacaf94..c65d927b 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -93,7 +93,9 @@ Central cluster only. Sites have no user interface. ### Instance Management (Deployment Role) - Create instances from templates at a specific site. - Assign instances to areas. -- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once. +- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once. Each row also exposes: + - **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). - Set instance-level attribute overrides (non-locked attributes only). - Filter/search instances by site, area, template, or status. - **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration. From c1e16cf9fff4bbfcd44e326303b31e44ae036c3c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:36:46 -0400 Subject: [PATCH 21/24] fix(centralui): role guard uses RoleClaimType, not IsInRole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimsIdentity is built without an explicit roleType, so IsInRole("Design") checks ClaimTypes.Role while actual claims use "Role" — the guard always returned not-authorized. Switch to HasClaim(RoleClaimType, "Design"). --- .../Services/OpcUaBrowseService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs index 6adec7f3..1fb2d686 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components.Authorization; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; @@ -44,7 +45,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService // CentralUI-side role guard — sites don't enforce envelope-level roles, // so the Design check must happen here before any cross-cluster traffic. var state = await _auth.GetAuthenticationStateAsync(); - if (!state.User.IsInRole("Design")) + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) { return new BrowseOpcUaNodeResult( Array.Empty(), From 2c138b6a2561d6d24f33f085f6bf9fae1e54799b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:40:35 -0400 Subject: [PATCH 22/24] fix(centralui): pass siteId+connectionName into ShowAsync explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Razor parameter binding propagates on the next render, so reading SiteId inside LoadRootAsync raced against the parent's "set field, then call ShowAsync()" pattern — central received an empty siteId and rejected with "No ClusterClient for site ,". Take the values as args instead. --- .../Components/Dialogs/OpcUaBrowserDialog.razor | 17 ++++++++++++----- .../Pages/Deployment/InstanceConfigure.razor | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor index b1895772..d1556d41 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor @@ -96,11 +96,18 @@ public bool Truncated { get; set; } } - public async Task ShowAsync() + private string _runtimeSiteId = ""; + private string _runtimeConnectionName = ""; + + public async Task ShowAsync(string siteId, string connectionName, string? initialNodeId) { + // Snapshot at click time. Razor parameter binding propagates on the next + // render, which would race the immediate LoadRootAsync below. + _runtimeSiteId = siteId; + _runtimeConnectionName = connectionName; _isVisible = true; - _manualNodeId = InitialNodeId ?? ""; - _selectedNodeId = InitialNodeId; + _manualNodeId = initialNodeId ?? ""; + _selectedNodeId = initialNodeId; await LoadRootAsync(); } @@ -110,7 +117,7 @@ _rootNodes = new(); StateHasChanged(); - var result = await BrowseService.BrowseChildrenAsync(SiteId, ConnectionName, parentNodeId: null); + var result = await BrowseService.BrowseChildrenAsync(_runtimeSiteId, _runtimeConnectionName, parentNodeId: null); if (result.Failure is not null) { SetFailure(result.Failure); @@ -135,7 +142,7 @@ { node.Loading = true; StateHasChanged(); - var result = await BrowseService.BrowseChildrenAsync(SiteId, ConnectionName, node.NodeId); + var result = await BrowseService.BrowseChildrenAsync(_runtimeSiteId, _runtimeConnectionName, node.NodeId); node.Loading = false; if (result.Failure is not null) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index ddd95f87..3e2c5d8b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -573,7 +573,7 @@ ?? GetTemplateDefault(attrName); if (_browserRef is not null) - await _browserRef.ShowAsync(); + await _browserRef.ShowAsync(_siteIdentifier, conn.Name, _browserInitial); } private void OnBrowserSelected(string nodeId) From f401a9ea0e736e0e9208105ab3aee9c90b7fd868 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:51:45 -0400 Subject: [PATCH 23/24] fix(comm+site): route BrowseOpcUaNodeCommand via DeploymentManagerActor singleton --- .../Actors/SiteCommunicationActor.cs | 23 ++++++------------- .../Actors/DeploymentManagerActor.cs | 10 ++++++++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index d83beabf..57c8ee1e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -146,22 +146,13 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers Receive(msg => _deploymentManagerProxy.Forward(msg)); // OPC UA Tag Browser (interactive design-time query) — forward to the - // site-local Data Connection Manager actor, which owns the in-memory - // map of live data-connection adapters keyed by ConnectionName and - // executes the browse against the appropriate OPC UA client. The - // manager is not a child of DeploymentManagerActor, so we route via - // ActorSelection rather than the _deploymentManagerProxy field; the - // path matches the registration in AkkaHostedService. ActorSelection - // has no Forward() helper, so we Tell with the original Sender so the - // BrowseOpcUaNodeResult routes straight back to the central UI's Ask - // (via the CentralCommunicationActor sender chain), not to us. - Receive(msg => - { - _log.Debug( - "Routing BrowseOpcUaNodeCommand for connection '{0}' to DataConnectionManager", - msg.ConnectionName); - Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender); - }); + // Deployment Manager singleton, which always lands on the active site + // node. Routing to the site-local /user/dcl-manager directly is wrong + // because the standby node has a dcl-manager too, but its + // DataConnectionActor children (which own the live OPC UA sessions) + // only exist on the singleton's node. The singleton then re-forwards + // to its own /user/dcl-manager, which DOES have the connection. + Receive(msg => _deploymentManagerProxy.Forward(msg)); // Pattern 7: Remote Queries Receive(msg => diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 65721653..6cd65aa3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; @@ -147,6 +148,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(RouteInboundApiGetAttributes); Receive(RouteInboundApiSetAttributes); + // OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager. + // BrowseOpcUaNodeCommand is routed to this singleton (active node) by + // SiteCommunicationActor so the dcl-manager we forward to is guaranteed + // to be the one holding the live DataConnectionActor children. ActorSelection + // has no Forward() extension in this Akka.NET version, so we Tell with the + // original Sender preserved (semantically identical to Forward). + Receive(msg => + Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); + // Internal startup messages Receive(HandleStartupConfigsLoaded); Receive(HandleSharedScriptsLoaded); From 2a7dee4afacb90857507dbc7563e33ca8c3a02a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 13:25:48 -0400 Subject: [PATCH 24/24] =?UTF-8?q?feat(centralui+dcl):=20Test=20Bindings=20?= =?UTF-8?q?popup=20=E2=80=94=20one-shot=20live=20read=20of=20bound=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Test Bindings button to the Connection Bindings table on the Configure Instance page that opens a modal showing the live current value of every bound attribute. Reuses the routing path that the OPC UA tag browser landed on: Central: TestBindingsDialog → IBindingTester → CommunicationService → ReadTagValuesCommand → SiteEnvelope (Ask) Site: SiteCommunicationActor → DeploymentManagerActor singleton → DataConnectionManagerActor → child DataConnectionActor → _adapter.ReadBatchAsync Split mirrors the browse handler: • Manager owns ConnectionNotFound (only it sees the per-site connection set). • Child owns ConnectionNotConnected (pre-call status check, never stash — read is interactive design-time), Timeout (OperationCanceledException), ServerError (any other exception). Per-tag failures from ReadBatchAsync become failure TagReadOutcomes without aborting the batch. CentralUI: • IBindingTester / BindingTester — Design-role guard via HasClaim against JwtTokenService.RoleClaimType (not IsInRole — see c1e16cf), typed transport-failure translation. • TestBindingsDialog — ShowAsync(siteId, rows, instanceLabel) method-arg pattern (no Razor parameter race; see 2c138b6), groups rows by connection and issues one ReadAsync per connection in parallel, per-row error subline + per-connection banner, Refresh button re-issues the reads. • InstanceConfigure.razor — Test Bindings button next to Save Bindings, disabled when no testable rows. OPC UA only today (other protocols have no ReadTagValuesCommand wiring yet). Tests: • Commons: ReadTagValuesCommand discovered by ManagementCommandRegistry. • DataConnectionLayer: unknown connection → ConnectionNotFound, not-connected adapter → ConnectionNotConnected (ReadBatchAsync NOT called), success-path mapping (Good/Bad + per-tag error), cancellation → Timeout. • CentralUI: register IBindingTester (and the previously-missing IOpcUaBrowseService) on the existing InstanceConfigureAuditDrillinTests Bunit container so the page renders cleanly with the new dialog. --- .../Dialogs/TestBindingsDialog.razor | 271 ++++++++++++++++++ .../Pages/Deployment/InstanceConfigure.razor | 63 +++- .../ServiceCollectionExtensions.cs | 7 + .../Services/BindingTester.cs | 81 ++++++ .../Services/IBindingTester.cs | 39 +++ .../Management/ReadTagValuesCommand.cs | 70 +++++ .../Actors/SiteCommunicationActor.cs | 6 + .../CommunicationService.cs | 24 ++ .../Actors/DataConnectionActor.cs | 109 +++++++ .../Actors/DataConnectionManagerActor.cs | 28 ++ .../Actors/DeploymentManagerActor.cs | 7 + .../InstanceConfigureAuditDrillinTests.cs | 7 + .../ReadTagValuesCommandRegistryTests.cs | 24 ++ ...nectionManagerReadTagValuesHandlerTests.cs | 174 +++++++++++ 14 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TestBindingsDialog.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TestBindingsDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TestBindingsDialog.razor new file mode 100644 index 00000000..d5a2d43f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TestBindingsDialog.razor @@ -0,0 +1,271 @@ +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@inject IBindingTester Tester + +@if (_isVisible) +{ + +} + +@code { + /// + /// A single binding row to test — built by the page at click time from its + /// _bindings table and passed in via . Carries + /// the effective tag path (override ?? template default) so the dialog + /// doesn't need page-side knowledge to compute it. + /// + public sealed record BindingRowToTest( + string AttributeName, + string ConnectionName, + string EffectiveTagPath); + + [Parameter] public EventCallback OnCancelled { get; set; } + + private sealed record ConnectionBanner(string ConnectionName, string Message); + + private bool _isVisible; + private bool _loading; + private string _instanceLabel = ""; + private string _runtimeSiteId = ""; + private DateTimeOffset _lastReadAt; + private List _rows = new(); + private readonly List _connectionBanners = new(); + // Keyed by (connection, tagPath) so two connections referencing the same + // tag path don't collide. + private readonly Dictionary<(string Connection, string TagPath), TagReadOutcome> _outcomes = new(); + + /// + /// Opens the dialog and triggers an immediate one-shot read. Method-arg + /// pattern (mirroring OpcUaBrowserDialog.ShowAsync) — Razor + /// parameter binding would propagate on the next render and race the + /// LoadAsync below. + /// + /// Site identifier (machine name) used by for routing. + /// Rows to test (one per attribute with a connection + effective tag path). + /// Optional label rendered in the modal header (instance unique name). + public async Task ShowAsync( + string siteId, + IReadOnlyList rows, + string instanceLabel = "") + { + _runtimeSiteId = siteId; + _instanceLabel = instanceLabel; + _rows = rows.ToList(); + _outcomes.Clear(); + _connectionBanners.Clear(); + _isVisible = true; + await LoadAsync(); + } + + private async Task LoadAsync() + { + if (_rows.Count == 0) + { + _loading = false; + StateHasChanged(); + return; + } + + _loading = true; + _outcomes.Clear(); + _connectionBanners.Clear(); + StateHasChanged(); + + // Group by connection name — one ReadTagValuesCommand per connection, + // issued in parallel. Distinct tag paths only (a single attribute may + // appear multiple times in the page, but reads are per (conn, tag)). + var groups = _rows + .GroupBy(r => r.ConnectionName, StringComparer.Ordinal) + .Select(g => new + { + Connection = g.Key, + TagPaths = g.Select(r => r.EffectiveTagPath).Distinct(StringComparer.Ordinal).ToList(), + }) + .ToList(); + + var tasks = groups.Select(g => Tester.ReadAsync(_runtimeSiteId, g.Connection, g.TagPaths)).ToArray(); + ReadTagValuesResult[] results; + try + { + results = await Task.WhenAll(tasks); + } + catch (Exception ex) + { + // Last-ditch: a service-layer exception that escaped the typed + // failure mapping (shouldn't happen — BindingTester translates + // everything but OperationCanceledException). Surface as a single + // banner so the dialog stays usable. + _connectionBanners.Add(new ConnectionBanner("(all)", $"Read failed: {ex.Message}")); + _loading = false; + _lastReadAt = DateTimeOffset.UtcNow; + StateHasChanged(); + return; + } + + for (var i = 0; i < groups.Count; i++) + { + var group = groups[i]; + var result = results[i]; + + if (result.Failure is not null) + { + _connectionBanners.Add(new ConnectionBanner(group.Connection, FormatFailure(result.Failure))); + continue; + } + + foreach (var outcome in result.Outcomes) + { + _outcomes[(group.Connection, outcome.TagPath)] = outcome; + } + } + + _lastReadAt = DateTimeOffset.UtcNow; + _loading = false; + StateHasChanged(); + } + + private Task RefreshAsync() => LoadAsync(); + + private TagReadOutcome? LookupOutcome(BindingRowToTest row) + => _outcomes.GetValueOrDefault((row.ConnectionName, row.EffectiveTagPath)); + + // Maps ReadTagValuesFailureKind to a friendly banner. Raw failure.Message + // is surfaced verbatim only for ServerError (which carries the adapter's + // own message text). + private static string FormatFailure(ReadTagValuesFailure failure) => failure.Kind switch + { + ReadTagValuesFailureKind.ConnectionNotFound => "Connection no longer exists at the site.", + ReadTagValuesFailureKind.ConnectionNotConnected => "Connection not yet established — retry shortly.", + ReadTagValuesFailureKind.Timeout => "Read timed out — the server may be slow. Try Refresh.", + ReadTagValuesFailureKind.ServerError => $"Server error: {failure.Message}", + _ => failure.Message, + }; + + private static string FormatValue(object? value) => value switch + { + null => "(null)", + string s => s, + IFormattable f => f.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => value.ToString() ?? "(null)", + }; + + private static string QualityBadge(string quality) => quality switch + { + "Good" => "bg-success", + "Uncertain" => "bg-warning text-dark", + _ => "bg-danger", + }; + + private async Task Close() + { + _isVisible = false; + await OnCancelled.InvokeAsync(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 3e2c5d8b..e2f717e7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -154,8 +154,18 @@ } -
    +
    + @* Test Bindings: one-shot live read of every bound attribute + whose row has a connection picked AND an effective tag + path. Disabled when no testable rows. Currently OPC UA + only — other protocols (none yet) would need their own + wire+adapter support to round-trip through ReadTagValuesCommand. *@ +
    }
    @@ -362,6 +372,11 @@ ConnectionName="@_browserConnectionName" InitialNodeId="@_browserInitial" OnSelected="OnBrowserSelected" /> + + @* Test Bindings dialog — one-shot live read of every bound attribute. + Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation + race (same pattern as OpcUaBrowserDialog). *@ + } @@ -399,6 +414,10 @@ private string? _browserInitial; private string _siteIdentifier = ""; + // Test Bindings dialog — single instance, args passed via ShowAsync (no + // Razor parameter propagation race; same pattern as the OPC UA browser). + private TestBindingsDialog? _testBindingsRef; + // Overrides private List _overrideAttrs = new(); private Dictionary _overrideValues = new(); @@ -583,6 +602,48 @@ _browserAttrInEdit = null; } + // ── Test Bindings (one-shot live read of bound tags) ──────────────────── + + /// + /// Builds the list of testable rows: attributes that have a connection + /// picked AND a non-empty effective tag path AND an OPC UA connection + /// (the only protocol routed through ReadTagValuesCommand today). + /// + private List BuildTestableRows() + { + var rows = new List(); + foreach (var attr in _bindingDataSourceAttrs) + { + var connId = GetBindingConnectionId(attr.Name); + if (connId <= 0) continue; + + var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); + if (conn is null) continue; + + // OPC UA only — other protocols don't have a site-side + // ReadTagValuesCommand handler wired up yet. + if (!string.Equals(conn.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)) + continue; + + var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name) + ?? GetTemplateDefault(attr.Name); + if (string.IsNullOrWhiteSpace(effectivePath)) continue; + + rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath)); + } + return rows; + } + + private bool HasTestableBindings() => BuildTestableRows().Count > 0; + + private async Task OpenTestBindings() + { + if (_testBindingsRef is null) return; + var rows = BuildTestableRows(); + if (rows.Count == 0) return; + await _testBindingsRef.ShowAsync(_siteIdentifier, rows, _instance?.UniqueName ?? ""); + } + private async Task SaveBindings() { _saving = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 6cfebd3f..7db879ac 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -55,6 +55,13 @@ public static class ServiceCollectionExtensions // transport failures into typed BrowseFailure results for the dialog. services.AddScoped(); + // Test Bindings: facade over CommunicationService.ReadTagValuesAsync — + // same Design-role guard + typed-failure translation as the browse + // service. Backs the Test Bindings dialog on the Configure Instance + // page (one-shot live read of every bound attribute, grouped by + // connection). + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs new file mode 100644 index 00000000..2c19d8b1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Components.Authorization; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default implementation — a thin facade over +/// that enforces the +/// CentralUI-side Design-role trust boundary and translates transport +/// exceptions into a typed result. Mirrors +/// . +/// +public sealed class BindingTester : IBindingTester +{ + private readonly CommunicationService _communication; + private readonly AuthenticationStateProvider _auth; + + /// + /// Initializes a new instance of the . + /// + /// Central-side cluster communication service. + /// Authentication state provider used for the Design-role guard. + public BindingTester(CommunicationService communication, AuthenticationStateProvider auth) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + } + + /// + public async Task ReadAsync( + string siteId, + string connectionName, + IReadOnlyList tagPaths, + CancellationToken ct = default) + { + // CentralUI-side role guard — sites don't enforce envelope-level + // roles, so the Design check must happen here before any cross-cluster + // traffic. Use HasClaim against JwtTokenService.RoleClaimType (not + // IsInRole, per c1e16cf). + var state = await _auth.GetAuthenticationStateAsync(); + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) + { + return new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, "Not authorized.")); + } + + try + { + return await _communication.ReadTagValuesAsync( + siteId, + new ReadTagValuesCommand(connectionName, tagPaths), + ct); + } + catch (TimeoutException ex) + { + // Akka Ask timed out — the site (or its OPC UA session) didn't + // answer within CommunicationOptions.QueryTimeout. Surface as a + // typed Timeout failure so the dialog can render an inline banner. + return new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, ex.Message)); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel — propagate so Blazor can drop the + // response cleanly. Distinct from Timeout. + throw; + } + catch (Exception ex) + { + // Any other transport / serialization failure: keep the dialog + // alive with a typed banner. + return new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ex.Message)); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs new file mode 100644 index 00000000..91a08dde --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs @@ -0,0 +1,39 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// CentralUI facade over the central-to-site "Test Bindings" read command. +/// Backs the Test Bindings dialog on the Configure Instance page: on open and +/// on Refresh, the dialog issues one per distinct +/// connection (grouped from the page's bindings table) and renders the +/// per-tag outcomes. +/// +/// +/// The service is the trust boundary for the read capability: it enforces the +/// Design role at central before any cross-cluster traffic is +/// generated, because site-side actors do not unwrap the central trust +/// envelope. Transport failures (timeouts, unreachable sites) are translated +/// into a typed so the dialog can render an +/// inline banner without crashing — same shape as +/// . +/// +public interface IBindingTester +{ + /// + /// Reads the current value of one or more tags on the live server backing + /// at . The + /// caller is expected to group its bindings by connection name and issue + /// one call per group (in parallel — the dialog uses + /// Task.WhenAll). + /// + /// The target site identifier. + /// Name of the site-local data connection — the site's DataConnectionManagerActor indexes its children by name. + /// Tag paths to read. + /// Cancellation token. + Task ReadAsync( + string siteId, + string connectionName, + IReadOnlyList tagPaths, + CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs new file mode 100644 index 00000000..b0416016 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs @@ -0,0 +1,70 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +/// +/// Sent from CentralUI to a specific site to read the current value of one or +/// more tags on the live server backing a named data connection. Backs the +/// "Test Bindings" dialog on the Configure Instance page — a one-shot read of +/// every bound attribute, grouped by connection, with no subscription. +/// +/// +/// Keyed by (not id) for the same reason as +/// : the site-side +/// DataConnectionManagerActor indexes its children by connection name, +/// and the central UI already has the connection name in scope from the +/// bindings table. The central DataConnections table's id is not +/// exposed at the site. +/// +/// Name of the site-local data connection to read against. +/// Tag paths to read (one batch per connection — caller groups by connection name). +public record ReadTagValuesCommand( + string ConnectionName, + IReadOnlyList TagPaths); + +/// +/// Per-tag outcome of a . The site returns +/// one outcome per requested tag path; a single failing tag never aborts the +/// batch (the underlying IDataConnection.ReadBatchAsync contract). +/// +/// Tag path that was read — matches an entry in the request. +/// True when the read returned a value; false when the per-tag read failed. +/// Read value (may be null even on success); always null on failure. +/// Quality code as a string (Good/Bad/Uncertain); always Bad on failure. +/// Source timestamp on success; the central-noted UTC time of the failure otherwise. +/// Per-tag error message on failure; null on success. +public record TagReadOutcome( + string TagPath, + bool Success, + object? Value, + string Quality, + DateTimeOffset Timestamp, + string? ErrorMessage); + +/// +/// Reply to a . Either +/// is populated (one entry per requested tag, in any order) and +/// is null, or is set and +/// is empty — the latter is the connection-level +/// short-circuit (unknown connection, not connected, server error, etc.) where +/// no per-tag attempt was made. +/// +public record ReadTagValuesResult( + IReadOnlyList Outcomes, + ReadTagValuesFailure? Failure); + +/// +/// Connection-level failure carried by . The +/// dialog maps each to a friendly +/// banner; is surfaced verbatim for the +/// case. +/// +public record ReadTagValuesFailure( + ReadTagValuesFailureKind Kind, + string Message); + +public enum ReadTagValuesFailureKind +{ + ConnectionNotFound, + ConnectionNotConnected, + Timeout, + ServerError +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index 57c8ee1e..b82596d8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -154,6 +154,12 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers // to its own /user/dcl-manager, which DOES have the connection. Receive(msg => _deploymentManagerProxy.Forward(msg)); + // Test Bindings (interactive design-time read) — same routing rationale + // as BrowseOpcUaNodeCommand above: the singleton always lands on the + // active site node, which is the node that owns the DataConnectionActor + // children holding the live OPC UA sessions. + Receive(msg => _deploymentManagerProxy.Forward(msg)); + // Pattern 7: Remote Queries Receive(msg => { diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index f91ddfe2..8d06e110 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -370,6 +370,30 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + // ── Test Bindings (one-shot live read of bound tags) ── + + /// + /// Asks a site to read the current value of one or more tags on the live + /// server backing the given data connection. Used by the CentralUI "Test + /// Bindings" dialog on the Configure Instance page. The Ask is bounded by + /// — same latency budget + /// as (both are interactive one-shot + /// design-time queries). + /// + /// The target site identifier. + /// The read-tag-values command (connection name + tag paths). + /// Cancellation token. + /// The read result — per-tag outcomes plus an optional connection-level failure. + public Task ReadTagValuesAsync( + string siteId, + ReadTagValuesCommand command, + CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, command); + return GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + // ── Pattern 8: Heartbeat (site→central, Tell) ── // Heartbeats are received by central, not sent. No method needed here. diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index dc5ab774..918111f7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -241,6 +241,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // an inline banner. HandleBrowse(browse); break; + case ReadTagValuesCommand read: + // Same rule as browse — never stash; adapter is not yet + // connected, so HandleReadTagValues short-circuits to + // ConnectionNotConnected. + HandleReadTagValues(read); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -304,6 +310,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers case BrowseOpcUaNodeCommand browse: HandleBrowse(browse); break; + case ReadTagValuesCommand read: + HandleReadTagValues(read); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -429,6 +438,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // throw ConnectionNotConnectedException — mapped by HandleBrowse. HandleBrowse(browse); break; + case ReadTagValuesCommand read: + // Same rule as browse — never stashed; while reconnecting the + // adapter is not Connected so HandleReadTagValues short-circuits + // to a ConnectionNotConnected failure. + HandleReadTagValues(read); + break; case GetHealthReport: ReplyWithHealthReport(); break; @@ -1030,6 +1045,100 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers }).PipeTo(sender); } + // ── Test Bindings (one-shot live read of bound tags) ── + + /// + /// Handles a forwarded by the + /// . Short-circuits to a + /// failure + /// when the adapter is not currently Connected (Connecting / Reconnecting + /// states) so the dialog can render an inline banner without waiting for + /// the adapter to fail per-tag with a generic "client is not connected" + /// message. Otherwise calls _adapter.ReadBatchAsync and maps the + /// resulting per-tag map onto a list of + /// (preserving every requested tag — missing + /// adapter entries become failure outcomes). + /// + /// Failure mapping mirrors : + /// + /// — adapter status is not . + /// — batch cancelled (). + /// — any other exception, message carried verbatim. + /// + /// + /// The reply is sent via PipeTo(sender) — same pattern as + /// and — so the + /// captured is safe to use from the continuation. + /// + private void HandleReadTagValues(ReadTagValuesCommand command) + { + var sender = Sender; + + if (_adapter.Status != ConnectionHealth.Connected) + { + _log.Debug("[{0}] Test-bindings read requested but adapter status is {1}", _connectionName, _adapter.Status); + sender.Tell(new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure( + ReadTagValuesFailureKind.ConnectionNotConnected, + "Connection is not yet established."))); + return; + } + + _log.Debug("[{0}] Test-bindings read of {1} tag(s)", _connectionName, command.TagPaths.Count); + + var tagPaths = command.TagPaths.ToList(); + _adapter.ReadBatchAsync(tagPaths).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var nowUtc = DateTimeOffset.UtcNow; + var outcomes = new List(tagPaths.Count); + foreach (var tagPath in tagPaths) + { + if (t.Result.TryGetValue(tagPath, out var result) && result.Success && result.Value is not null) + { + outcomes.Add(new TagReadOutcome( + tagPath, + Success: true, + Value: result.Value.Value, + Quality: result.Value.Quality.ToString(), + Timestamp: result.Value.Timestamp, + ErrorMessage: null)); + } + else + { + var errMsg = result?.ErrorMessage + ?? (t.Result.ContainsKey(tagPath) + ? "Read returned no value." + : "Tag missing from adapter result."); + outcomes.Add(new TagReadOutcome( + tagPath, + Success: false, + Value: null, + Quality: "Bad", + Timestamp: nowUtc, + ErrorMessage: errMsg)); + } + } + return new ReadTagValuesResult(outcomes, Failure: null); + } + + var baseEx = t.Exception?.GetBaseException(); + return baseEx switch + { + OperationCanceledException => new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "Read cancelled.")), + _ => new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure( + ReadTagValuesFailureKind.ServerError, + baseEx?.Message ?? "Unknown read error.")), + }; + }).PipeTo(sender); + } + // ── Tag Resolution Retry (WP-12) ── private void HandleRetryTagResolution() diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs index 18e0998b..1bf21ee0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -47,6 +47,7 @@ public class DataConnectionManagerActor : ReceiveActor Receive(HandleRemoveConnection); Receive(HandleGetAllHealthReports); Receive(HandleBrowse); + Receive(HandleReadTagValues); } private void HandleCreateConnection(CreateConnectionCommand command) @@ -140,6 +141,33 @@ public class DataConnectionManagerActor : ReceiveActor } } + /// + /// Routes a from the CentralUI's Test + /// Bindings dialog to the child that + /// owns the named connection. Same split as — + /// the manager owns + /// because it is + /// the only actor with site-level visibility; every other failure + /// (not connected, server error, timeout) is resolved by the child where + /// the adapter is held. + /// + private void HandleReadTagValues(ReadTagValuesCommand command) + { + if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) + { + actor.Forward(command); + } + else + { + _log.Warning("No connection actor for {0} during test-bindings read", command.ConnectionName); + Sender.Tell(new ReadTagValuesResult( + Array.Empty(), + new ReadTagValuesFailure( + ReadTagValuesFailureKind.ConnectionNotFound, + $"No data connection named '{command.ConnectionName}' at this site."))); + } + } + private void HandleRemoveConnection(RemoveConnectionCommand command) { if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 6cd65aa3..a7175a7f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -157,6 +157,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(msg => Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); + // Test Bindings — same singleton-only re-forward as the browse handler + // above. Routed to this singleton (active node) by SiteCommunicationActor + // so the dcl-manager we forward to is guaranteed to hold the live + // DataConnectionActor children. + Receive(msg => + Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); + // Internal startup messages Receive(HandleStartupConfigsLoaded); Receive(HandleSharedScriptsLoaded); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index 02be7c87..9e9f5287 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; @@ -43,6 +44,12 @@ public class InstanceConfigureAuditDrillinTests : BunitContext Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For())); Services.AddSingleton(Substitute.For()); + // The page renders and at + // the bottom; their @inject directives need a registered service even + // though this test doesn't open either dialog. + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); + // Auth: a system-wide Deployment user so SiteScope grants everything. var claims = new[] { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs new file mode 100644 index 00000000..a25d8181 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs @@ -0,0 +1,24 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +/// +/// Verifies that is discovered by +/// so it travels over the management +/// boundary as a known command (resolvable by wire name and round-trippable +/// through GetCommandName / Resolve). Mirrors +/// . +/// +public class ReadTagValuesCommandRegistryTests +{ + [Fact] + public void Registry_discovers_ReadTagValuesCommand() + { + // GetCommandName throws ArgumentException for any type the registry + // does not contain, so a successful call here is proof of discovery. + var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand)); + + Assert.Equal("ReadTagValues", name); + Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs new file mode 100644 index 00000000..a4f05dc5 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs @@ -0,0 +1,174 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; +using ZB.MOM.WW.ScadaBridge.HealthMonitoring; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; + +/// +/// Test Bindings (one-shot live read of bound tags): the site-side +/// + child +/// together resolve +/// against the live adapter and surface +/// every outcome as either a per-tag or a typed +/// connection-level . The split mirrors the +/// browse handler: the manager owns +/// (only it knows +/// the per-site connection set); everything else lives in the child where the +/// adapter is held — +/// from the pre-call status check, +/// / from the adapter call. +/// +public class DataConnectionManagerReadTagValuesHandlerTests : TestKit +{ + private readonly IDataConnectionFactory _factory; + private readonly ISiteHealthCollector _healthCollector; + private readonly DataConnectionOptions _options; + + public DataConnectionManagerReadTagValuesHandlerTests() + : base(@"akka.loglevel = WARNING") + { + _factory = Substitute.For(); + _healthCollector = Substitute.For(); + _options = new DataConnectionOptions + { + ReconnectInterval = TimeSpan.FromSeconds(30), + TagResolutionRetryInterval = TimeSpan.FromSeconds(30), + }; + } + + [Fact] + public void Unknown_connection_name_returns_ConnectionNotFound() + { + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + + // No CreateConnectionCommand sent — the manager has zero children, so + // any read must be rejected with ConnectionNotFound (only the manager + // has site-level visibility into the connection set). + manager.Tell(new ReadTagValuesCommand("unknown-connection", new[] { "ns=2;s=A" })); + + var reply = ExpectMsg(); + Assert.NotNull(reply.Failure); + Assert.Equal(ReadTagValuesFailureKind.ConnectionNotFound, reply.Failure!.Kind); + Assert.Empty(reply.Outcomes); + } + + [Fact] + public void Adapter_not_connected_returns_ConnectionNotConnected() + { + // Adapter that reports Disconnected — the child actor's pre-call + // status check must short-circuit to ConnectionNotConnected without + // calling ReadBatchAsync (avoids the per-tag "client is not + // connected" noise that the OpcUa adapter would otherwise produce). + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("simulated failure"))); + adapter.Status.Returns(ConnectionHealth.Disconnected); + _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-down", "OpcUa", new Dictionary(), null, 3)); + + // Wait for the child actor to spin up; the read handler runs in every + // lifecycle state, so we don't need to wait for a specific Become. + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new ReadTagValuesCommand("conn-down", new[] { "ns=2;s=A" })); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(ReadTagValuesFailureKind.ConnectionNotConnected, reply.Failure!.Kind); + Assert.Empty(reply.Outcomes); + + // ReadBatchAsync must NOT be called when the status guard short-circuits. + adapter.DidNotReceive().ReadBatchAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public void Success_path_maps_results_to_TagReadOutcomes() + { + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + adapter.Status.Returns(ConnectionHealth.Connected); + + var ts = new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero); + var batch = new Dictionary + { + ["ns=2;s=A"] = new ReadResult(true, new TagValue(42.7, QualityCode.Good, ts), null), + // Adapter-reported per-tag failure (e.g. unknown node id): mapped to + // a failure TagReadOutcome with Quality=Bad and Value=null. + ["ns=2;s=B"] = new ReadResult(false, null, "BadNodeIdUnknown"), + }; + adapter.ReadBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(batch); + _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-ok", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new ReadTagValuesCommand("conn-ok", new[] { "ns=2;s=A", "ns=2;s=B" })); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Null(reply.Failure); + Assert.Equal(2, reply.Outcomes.Count); + + var aOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=A"); + Assert.True(aOutcome.Success); + Assert.Equal(42.7, aOutcome.Value); + Assert.Equal("Good", aOutcome.Quality); + Assert.Equal(ts, aOutcome.Timestamp); + Assert.Null(aOutcome.ErrorMessage); + + var bOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=B"); + Assert.False(bOutcome.Success); + Assert.Null(bOutcome.Value); + Assert.Equal("Bad", bOutcome.Quality); + Assert.Equal("BadNodeIdUnknown", bOutcome.ErrorMessage); + } + + [Fact] + public void Adapter_OperationCancelled_returns_Timeout() + { + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + adapter.Status.Returns(ConnectionHealth.Connected); + adapter.ReadBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.FromException>( + new OperationCanceledException("test cancel"))); + _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); + manager.Tell(new CreateConnectionCommand( + "conn-slow", "OpcUa", new Dictionary(), null, 3)); + + AwaitCondition( + () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + + manager.Tell(new ReadTagValuesCommand("conn-slow", new[] { "ns=2;s=A" })); + + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.NotNull(reply.Failure); + Assert.Equal(ReadTagValuesFailureKind.Timeout, reply.Failure!.Kind); + Assert.Empty(reply.Outcomes); + } +}