feat(centralui+dcl): Test Bindings popup — one-shot live read of bound tags

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.
This commit is contained in:
Joseph Doherty
2026-05-28 13:25:48 -04:00
parent f401a9ea0e
commit 2a7dee4afa
14 changed files with 909 additions and 1 deletions
@@ -0,0 +1,271 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IBindingTester Tester
@if (_isVisible)
{
<div class="modal show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Test Bindings — @_instanceLabel</h5>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-primary btn-sm" @onclick="RefreshAsync" disabled="@_loading">
@(_loading ? "Loading…" : "Refresh")
</button>
<button class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
@if (!_loading && _rows.Count > 0)
{
<span class="text-muted small ms-auto">Last read: @_lastReadAt.ToLocalTime().ToString("HH:mm:ss")</span>
}
</div>
@foreach (var banner in _connectionBanners)
{
<div class="alert alert-warning py-2 mb-2 small">
<strong>@banner.ConnectionName:</strong> @banner.Message
</div>
}
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Attribute</th>
<th>Connection</th>
<th>Tag Path</th>
<th>Value</th>
<th style="width: 90px;">Quality</th>
<th style="width: 110px;">Time</th>
</tr>
</thead>
<tbody>
@if (_rows.Count == 0)
{
<tr>
<td colspan="6" class="text-muted text-center small p-3">
@(_loading ? "Loading…" : "No bound attributes to test.")
</td>
</tr>
}
else
{
@foreach (var row in _rows)
{
var outcome = LookupOutcome(row);
<tr>
<td class="small">@row.AttributeName</td>
<td class="small text-muted">@row.ConnectionName</td>
<td class="small font-monospace text-break">@row.EffectiveTagPath</td>
<td class="small font-monospace">
@if (_loading)
{
<em class="text-muted">…</em>
}
else if (outcome is null)
{
<em class="text-muted">—</em>
}
else if (outcome.Success)
{
@FormatValue(outcome.Value)
}
else
{
<span class="text-muted">—</span>
<div class="text-danger small">error: @outcome.ErrorMessage</div>
}
</td>
<td class="small">
@if (_loading || outcome is null)
{
<em class="text-muted">—</em>
}
else
{
<span class="badge @QualityBadge(outcome.Quality)">@outcome.Quality</span>
}
</td>
<td class="small text-muted">
@if (_loading || outcome is null)
{
@("—")
}
else
{
@outcome.Timestamp.ToLocalTime().ToString("HH:mm:ss")
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="Close">Close</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>
/// A single binding row to test — built by the page at click time from its
/// <c>_bindings</c> table and passed in via <see cref="ShowAsync"/>. Carries
/// the effective tag path (override ?? template default) so the dialog
/// doesn't need page-side knowledge to compute it.
/// </summary>
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<BindingRowToTest> _rows = new();
private readonly List<ConnectionBanner> _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();
/// <summary>
/// Opens the dialog and triggers an immediate one-shot read. Method-arg
/// pattern (mirroring <c>OpcUaBrowserDialog.ShowAsync</c>) — Razor
/// parameter binding would propagate on the next render and race the
/// LoadAsync below.
/// </summary>
/// <param name="siteId">Site identifier (machine name) used by <see cref="IBindingTester"/> for routing.</param>
/// <param name="rows">Rows to test (one per attribute with a connection + effective tag path).</param>
/// <param name="instanceLabel">Optional label rendered in the modal header (instance unique name).</param>
public async Task ShowAsync(
string siteId,
IReadOnlyList<BindingRowToTest> 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();
}
}
@@ -154,8 +154,18 @@
}
</tbody>
</table>
<div class="p-2">
<div class="p-2 d-flex gap-2">
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* 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. *@
<button class="btn btn-outline-primary btn-sm"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
</div>
}
</div>
@@ -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). *@
<TestBindingsDialog @ref="_testBindingsRef" />
}
</div>
@@ -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<TemplateAttribute> _overrideAttrs = new();
private Dictionary<string, string?> _overrideValues = new();
@@ -583,6 +602,48 @@
_browserAttrInEdit = null;
}
// ── Test Bindings (one-shot live read of bound tags) ────────────────────
/// <summary>
/// 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 <c>ReadTagValuesCommand</c> today).
/// </summary>
private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows()
{
var rows = new List<TestBindingsDialog.BindingRowToTest>();
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;
@@ -55,6 +55,13 @@ public static class ServiceCollectionExtensions
// transport failures into typed BrowseFailure results for the dialog.
services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>();
// 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<IBindingTester, BindingTester>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
@@ -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;
/// <summary>
/// Default <see cref="IBindingTester"/> implementation — a thin facade over
/// <see cref="CommunicationService.ReadTagValuesAsync"/> that enforces the
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport
/// exceptions into a typed <see cref="ReadTagValuesFailure"/> result. Mirrors
/// <see cref="OpcUaBrowseService"/>.
/// </summary>
public sealed class BindingTester : IBindingTester
{
private readonly CommunicationService _communication;
private readonly AuthenticationStateProvider _auth;
/// <summary>
/// Initializes a new instance of the <see cref="BindingTester"/>.
/// </summary>
/// <param name="communication">Central-side cluster communication service.</param>
/// <param name="auth">Authentication state provider used for the Design-role guard.</param>
public BindingTester(CommunicationService communication, AuthenticationStateProvider auth)
{
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
}
/// <inheritdoc/>
public async Task<ReadTagValuesResult> ReadAsync(
string siteId,
string connectionName,
IReadOnlyList<string> 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<TagReadOutcome>(),
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<TagReadOutcome>(),
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<TagReadOutcome>(),
new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ex.Message));
}
}
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// 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 <see cref="ReadAsync"/> per distinct
/// connection (grouped from the page's bindings table) and renders the
/// per-tag outcomes.
/// </summary>
/// <remarks>
/// The service is the trust boundary for the read capability: it enforces the
/// <c>Design</c> 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 <see cref="ReadTagValuesFailure"/> so the dialog can render an
/// inline banner without crashing — same shape as
/// <see cref="IOpcUaBrowseService"/>.
/// </remarks>
public interface IBindingTester
{
/// <summary>
/// Reads the current value of one or more tags on the live server backing
/// <paramref name="connectionName"/> at <paramref name="siteId"/>. The
/// caller is expected to group its bindings by connection name and issue
/// one call per group (in parallel — the dialog uses
/// <c>Task.WhenAll</c>).
/// </summary>
/// <param name="siteId">The target site identifier.</param>
/// <param name="connectionName">Name of the site-local data connection — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param>
/// <param name="tagPaths">Tag paths to read.</param>
/// <param name="ct">Cancellation token.</param>
Task<ReadTagValuesResult> ReadAsync(
string siteId,
string connectionName,
IReadOnlyList<string> tagPaths,
CancellationToken ct = default);
}
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Keyed by <see cref="ConnectionName"/> (not id) for the same reason as
/// <see cref="BrowseOpcUaNodeCommand"/>: the site-side
/// <c>DataConnectionManagerActor</c> indexes its children by connection name,
/// and the central UI already has the connection name in scope from the
/// bindings table. The central <c>DataConnections</c> table's id is not
/// exposed at the site.
/// </remarks>
/// <param name="ConnectionName">Name of the site-local data connection to read against.</param>
/// <param name="TagPaths">Tag paths to read (one batch per connection — caller groups by connection name).</param>
public record ReadTagValuesCommand(
string ConnectionName,
IReadOnlyList<string> TagPaths);
/// <summary>
/// Per-tag outcome of a <see cref="ReadTagValuesCommand"/>. The site returns
/// one outcome per requested tag path; a single failing tag never aborts the
/// batch (the underlying <c>IDataConnection.ReadBatchAsync</c> contract).
/// </summary>
/// <param name="TagPath">Tag path that was read — matches an entry in the request.</param>
/// <param name="Success">True when the read returned a value; false when the per-tag read failed.</param>
/// <param name="Value">Read value (may be null even on success); always null on failure.</param>
/// <param name="Quality">Quality code as a string (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>); always <c>Bad</c> on failure.</param>
/// <param name="Timestamp">Source timestamp on success; the central-noted UTC time of the failure otherwise.</param>
/// <param name="ErrorMessage">Per-tag error message on failure; null on success.</param>
public record TagReadOutcome(
string TagPath,
bool Success,
object? Value,
string Quality,
DateTimeOffset Timestamp,
string? ErrorMessage);
/// <summary>
/// Reply to a <see cref="ReadTagValuesCommand"/>. Either <see cref="Outcomes"/>
/// is populated (one entry per requested tag, in any order) and
/// <see cref="Failure"/> is null, or <see cref="Failure"/> is set and
/// <see cref="Outcomes"/> is empty — the latter is the connection-level
/// short-circuit (unknown connection, not connected, server error, etc.) where
/// no per-tag attempt was made.
/// </summary>
public record ReadTagValuesResult(
IReadOnlyList<TagReadOutcome> Outcomes,
ReadTagValuesFailure? Failure);
/// <summary>
/// Connection-level failure carried by <see cref="ReadTagValuesResult"/>. The
/// dialog maps each <see cref="ReadTagValuesFailureKind"/> to a friendly
/// banner; <see cref="Message"/> is surfaced verbatim for the
/// <see cref="ReadTagValuesFailureKind.ServerError"/> case.
/// </summary>
public record ReadTagValuesFailure(
ReadTagValuesFailureKind Kind,
string Message);
public enum ReadTagValuesFailureKind
{
ConnectionNotFound,
ConnectionNotConnected,
Timeout,
ServerError
}
@@ -154,6 +154,12 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// to its own /user/dcl-manager, which DOES have the connection.
Receive<BrowseOpcUaNodeCommand>(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<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 7: Remote Queries
Receive<EventLogQueryRequest>(msg =>
{
@@ -370,6 +370,30 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
// ── Test Bindings (one-shot live read of bound tags) ──
/// <summary>
/// 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
/// <see cref="CommunicationOptions.QueryTimeout"/> — same latency budget
/// as <see cref="BrowseOpcUaNodeAsync"/> (both are interactive one-shot
/// design-time queries).
/// </summary>
/// <param name="siteId">The target site identifier.</param>
/// <param name="command">The read-tag-values command (connection name + tag paths).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The read result — per-tag outcomes plus an optional connection-level failure.</returns>
public Task<ReadTagValuesResult> ReadTagValuesAsync(
string siteId,
ReadTagValuesCommand command,
CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return GetActor().Ask<ReadTagValuesResult>(
envelope, _options.QueryTimeout, cancellationToken);
}
// ── Pattern 8: Heartbeat (site→central, Tell) ──
// Heartbeats are received by central, not sent. No method needed here.
@@ -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) ──
/// <summary>
/// Handles a <see cref="ReadTagValuesCommand"/> forwarded by the
/// <see cref="DataConnectionManagerActor"/>. Short-circuits to a
/// <see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> 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 <c>_adapter.ReadBatchAsync</c> and maps the
/// resulting per-tag <see cref="ReadResult"/> map onto a list of
/// <see cref="TagReadOutcome"/> (preserving every requested tag — missing
/// adapter entries become failure outcomes).
///
/// Failure mapping mirrors <see cref="HandleBrowse"/>:
/// <list type="bullet">
/// <item><see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> — adapter status is not <see cref="ConnectionHealth.Connected"/>.</item>
/// <item><see cref="ReadTagValuesFailureKind.Timeout"/> — batch cancelled (<see cref="OperationCanceledException"/>).</item>
/// <item><see cref="ReadTagValuesFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
/// </list>
///
/// The reply is sent via <c>PipeTo(sender)</c> — same pattern as
/// <see cref="HandleWrite"/> and <see cref="HandleBrowse"/> — so the
/// captured <see cref="Sender"/> is safe to use from the continuation.
/// </summary>
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<TagReadOutcome>(),
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<TagReadOutcome>(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<TagReadOutcome>(),
new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "Read cancelled.")),
_ => new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
new ReadTagValuesFailure(
ReadTagValuesFailureKind.ServerError,
baseEx?.Message ?? "Unknown read error.")),
};
}).PipeTo(sender);
}
// ── Tag Resolution Retry (WP-12) ──
private void HandleRetryTagResolution()
@@ -47,6 +47,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -140,6 +141,33 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
/// <summary>
/// Routes a <see cref="ReadTagValuesCommand"/> from the CentralUI's Test
/// Bindings dialog to the child <see cref="DataConnectionActor"/> that
/// owns the named connection. Same split as <see cref="HandleBrowse"/> —
/// the manager owns
/// <see cref="ReadTagValuesFailureKind.ConnectionNotFound"/> 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.
/// </summary>
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<TagReadOutcome>(),
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))
@@ -157,6 +157,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<BrowseOpcUaNodeCommand>(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<ReadTagValuesCommand>(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
Receive<SharedScriptsLoaded>(HandleSharedScriptsLoaded);
@@ -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<IAuditService>()));
Services.AddSingleton(Substitute.For<IFlatteningPipeline>());
// The page renders <OpcUaBrowserDialog/> and <TestBindingsDialog/> at
// the bottom; their @inject directives need a registered service even
// though this test doesn't open either dialog.
Services.AddSingleton(Substitute.For<IOpcUaBrowseService>());
Services.AddSingleton(Substitute.For<IBindingTester>());
// Auth: a system-wide Deployment user so SiteScope grants everything.
var claims = new[]
{
@@ -0,0 +1,24 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Verifies that <see cref="ReadTagValuesCommand"/> is discovered by
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
/// boundary as a known command (resolvable by wire name and round-trippable
/// through <c>GetCommandName</c> / <c>Resolve</c>). Mirrors
/// <see cref="BrowseCommandsRegistryTests"/>.
/// </summary>
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));
}
}
@@ -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;
/// <summary>
/// Test Bindings (one-shot live read of bound tags): the site-side
/// <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve
/// <see cref="ReadTagValuesCommand"/> against the live adapter and surface
/// every outcome as either a per-tag <see cref="TagReadOutcome"/> or a typed
/// connection-level <see cref="ReadTagValuesFailure"/>. The split mirrors the
/// browse handler: the manager owns
/// <see cref="ReadTagValuesFailureKind.ConnectionNotFound"/> (only it knows
/// the per-site connection set); everything else lives in the child where the
/// adapter is held — <see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/>
/// from the pre-call status check, <see cref="ReadTagValuesFailureKind.Timeout"/>
/// / <see cref="ReadTagValuesFailureKind.ServerError"/> from the adapter call.
/// </summary>
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<IDataConnectionFactory>();
_healthCollector = Substitute.For<ISiteHealthCollector>();
_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<ReadTagValuesResult>();
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<IDataConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("simulated failure")));
adapter.Status.Returns(ConnectionHealth.Disconnected);
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
manager.Tell(new CreateConnectionCommand(
"conn-down", "OpcUa", new Dictionary<string, string>(), 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<ReadTagValuesResult>(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<IEnumerable<string>>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Success_path_maps_results_to_TagReadOutcomes()
{
var adapter = Substitute.For<IDataConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
adapter.Status.Returns(ConnectionHealth.Connected);
var ts = new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero);
var batch = new Dictionary<string, ReadResult>
{
["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<IEnumerable<string>>(), Arg.Any<CancellationToken>())
.Returns(batch);
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
manager.Tell(new CreateConnectionCommand(
"conn-ok", "OpcUa", new Dictionary<string, string>(), 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<ReadTagValuesResult>(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<IDataConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
adapter.Status.Returns(ConnectionHealth.Connected);
adapter.ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<IReadOnlyDictionary<string, ReadResult>>(
new OperationCanceledException("test cancel")));
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
manager.Tell(new CreateConnectionCommand(
"conn-slow", "OpcUa", new Dictionary<string, string>(), 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<ReadTagValuesResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(ReadTagValuesFailureKind.Timeout, reply.Failure!.Kind);
Assert.Empty(reply.Outcomes);
}
}