From 2a7dee4afacb90857507dbc7563e33ca8c3a02a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 13:25:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(centralui+dcl):=20Test=20Bindings=20popup?= =?UTF-8?q?=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); + } +}