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);
}