Merge feat/opcua-tag-browser: OPC UA tag browser + Test Bindings popup
- Per-instance DataSourceReferenceOverride on InstanceConnectionBinding - IBrowsableDataConnection capability + RealOpcUaClient.BrowseChildrenAsync - BrowseOpcUaNodeCommand routed via DeploymentManagerActor singleton (HA-safe) - <OpcUaBrowserDialog/> on InstanceConfigure with lazy-loaded tree + manual paste - Test Bindings popup: one-shot live read of all bound tags via ReadTagValuesCommand - ConfigurationDatabase migration AddInstanceConnectionBindingOverride - Doc updates: Component-DataConnectionLayer, Component-TemplateEngine, Component-CentralUI
This commit is contained in:
@@ -93,7 +93,9 @@ Central cluster only. Sites have no user interface.
|
||||
### Instance Management (Deployment Role)
|
||||
- Create instances from templates at a specific site.
|
||||
- Assign instances to areas.
|
||||
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once.
|
||||
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once. Each row also exposes:
|
||||
- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.
|
||||
- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error).
|
||||
- Set instance-level attribute overrides (non-locked attributes only).
|
||||
- Filter/search instances by site, area, template, or status.
|
||||
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
|
||||
|
||||
@@ -140,6 +140,14 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per-
|
||||
- The existing subscription picks up the confirmed new value from the device and delivers it back to the Instance Actor as a standard value update.
|
||||
- The Instance Actor's in-memory value is **not** updated until the device confirms the write.
|
||||
|
||||
## Browsing the address space
|
||||
|
||||
DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for protocols that support it, exposed via `IBrowsableDataConnection`. Only consumed by management/UI (the OPC UA tag picker on the instance configure page); Instance Actors never call it.
|
||||
|
||||
- `OpcUaDataConnection` implements `IBrowsableDataConnection`; custom protocols do not.
|
||||
- `DataConnectionManagerActor` handles `BrowseOpcUaNodeCommand` (fields: `DataConnectionId`, `ParentNodeId`) and replies with `BrowseOpcUaNodeResult` (children + `Truncated` + structured `BrowseFailure?`).
|
||||
- Browse runs against the live session; no caching at DCL.
|
||||
|
||||
## Value Update Message Format
|
||||
|
||||
Each value update delivered to an Instance Actor includes:
|
||||
|
||||
@@ -97,7 +97,7 @@ breadcrumb.
|
||||
|
||||
Override and lock rules apply per entity type at the following granularity:
|
||||
|
||||
- **Attributes**: Value and Description are overridable. Data Type and Data Source Reference are fixed by the defining level. Lock applies to the entire attribute (when locked, no fields can be overridden).
|
||||
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
|
||||
- **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates), Description, and On-Trigger Script reference are overridable. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm.
|
||||
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
|
||||
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
|
||||
@@ -166,6 +166,8 @@ Each flattened configuration output includes a **revision hash** (computed from
|
||||
- Staleness detection: comparing the deployed revision to the current template-derived revision without a full diff.
|
||||
- Diff correlation: ensuring diffs are computed against a consistent baseline.
|
||||
|
||||
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
|
||||
|
||||
### On-Demand Validation
|
||||
|
||||
The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@inject IOpcUaBrowseService BrowseService
|
||||
|
||||
@if (_isVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Browse OPC UA — @ConnectionName</h5>
|
||||
<button type="button" class="btn-close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_failure is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@_failureMessage
|
||||
<button class="btn btn-sm btn-outline-danger ms-2" @onclick="RetryRootLoad">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="opcua-browser-tree">
|
||||
@if (_rootNodes.Count == 0 && _failure is null)
|
||||
{
|
||||
<em class="text-muted">Loading…</em>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="list-unstyled mb-0">
|
||||
@foreach (var node in _rootNodes)
|
||||
{
|
||||
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" SelectedNodeId="@_selectedNodeId" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Manual node id:</span>
|
||||
<input class="form-control" @bind="_manualNodeId" placeholder="ns=2;s=..." />
|
||||
<button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
|
||||
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string SiteId { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Name of the site-local data connection. Serves both as the modal-header
|
||||
/// display label AND as the routing key for the browse round-trip — the
|
||||
/// site's <c>DataConnectionManagerActor</c> indexes its children by
|
||||
/// connection name (no id-keyed lookup at the site).
|
||||
/// </summary>
|
||||
[Parameter] public string ConnectionName { get; set; } = "";
|
||||
[Parameter] public string? InitialNodeId { get; set; }
|
||||
[Parameter] public EventCallback<string> OnSelected { get; set; }
|
||||
[Parameter] public EventCallback OnCancelled { get; set; }
|
||||
|
||||
private bool _isVisible;
|
||||
private string? _selectedNodeId;
|
||||
private string _manualNodeId = "";
|
||||
private BrowseFailure? _failure;
|
||||
private string _failureMessage = "";
|
||||
private List<TreeNode> _rootNodes = new();
|
||||
|
||||
public sealed class TreeNode
|
||||
{
|
||||
public TreeNode(string nodeId, string displayName, BrowseNodeClass nodeClass, bool hasChildren)
|
||||
{
|
||||
NodeId = nodeId;
|
||||
DisplayName = displayName;
|
||||
NodeClass = nodeClass;
|
||||
HasChildren = hasChildren;
|
||||
}
|
||||
|
||||
public string NodeId { get; }
|
||||
public string DisplayName { get; }
|
||||
public BrowseNodeClass NodeClass { get; }
|
||||
public bool HasChildren { get; }
|
||||
|
||||
public List<TreeNode>? Children { get; set; } // null = not loaded yet
|
||||
public bool Expanded { get; set; }
|
||||
public bool Loading { get; set; }
|
||||
public bool Truncated { get; set; }
|
||||
}
|
||||
|
||||
private string _runtimeSiteId = "";
|
||||
private string _runtimeConnectionName = "";
|
||||
|
||||
public async Task ShowAsync(string siteId, string connectionName, string? initialNodeId)
|
||||
{
|
||||
// Snapshot at click time. Razor parameter binding propagates on the next
|
||||
// render, which would race the immediate LoadRootAsync below.
|
||||
_runtimeSiteId = siteId;
|
||||
_runtimeConnectionName = connectionName;
|
||||
_isVisible = true;
|
||||
_manualNodeId = initialNodeId ?? "";
|
||||
_selectedNodeId = initialNodeId;
|
||||
await LoadRootAsync();
|
||||
}
|
||||
|
||||
private async Task LoadRootAsync()
|
||||
{
|
||||
_failure = null;
|
||||
_rootNodes = new();
|
||||
StateHasChanged();
|
||||
|
||||
var result = await BrowseService.BrowseChildrenAsync(_runtimeSiteId, _runtimeConnectionName, parentNodeId: null);
|
||||
if (result.Failure is not null)
|
||||
{
|
||||
SetFailure(result.Failure);
|
||||
return;
|
||||
}
|
||||
|
||||
_rootNodes = result.Children.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)).ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ToggleAsync(TreeNode node)
|
||||
{
|
||||
if (!node.HasChildren) return;
|
||||
|
||||
if (node.Expanded)
|
||||
{
|
||||
node.Expanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.Children is null)
|
||||
{
|
||||
node.Loading = true;
|
||||
StateHasChanged();
|
||||
var result = await BrowseService.BrowseChildrenAsync(_runtimeSiteId, _runtimeConnectionName, node.NodeId);
|
||||
node.Loading = false;
|
||||
|
||||
if (result.Failure is not null)
|
||||
{
|
||||
SetFailure(result.Failure);
|
||||
return;
|
||||
}
|
||||
|
||||
node.Children = result.Children
|
||||
.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
||||
.ToList();
|
||||
node.Truncated = result.Truncated;
|
||||
}
|
||||
|
||||
node.Expanded = true;
|
||||
}
|
||||
|
||||
private void Select(TreeNode node)
|
||||
{
|
||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
||||
_selectedNodeId = node.NodeId;
|
||||
_manualNodeId = node.NodeId;
|
||||
}
|
||||
|
||||
// Task 17: map each BrowseFailureKind to a friendly UI message. The raw
|
||||
// failure.Message is surfaced verbatim only for ServerError (which carries
|
||||
// the OPC UA SDK's own Bad_* text) and as the default fallback for any
|
||||
// future failure kind added without a UI mapping.
|
||||
private void SetFailure(BrowseFailure failure)
|
||||
{
|
||||
_failure = failure;
|
||||
_failureMessage = failure.Kind switch
|
||||
{
|
||||
BrowseFailureKind.ConnectionNotFound => "Connection no longer exists at the site.",
|
||||
BrowseFailureKind.ConnectionNotConnected => "OPC UA session not connected — retry shortly or use manual entry.",
|
||||
BrowseFailureKind.NotBrowsable => "This connection does not support browsing.",
|
||||
BrowseFailureKind.Timeout => "Browse timed out — the server may be slow. Try again or enter the node id manually.",
|
||||
BrowseFailureKind.ServerError => $"OPC UA server error: {failure.Message}",
|
||||
_ => failure.Message
|
||||
};
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private Task RetryRootLoad() => LoadRootAsync();
|
||||
|
||||
private void UseManual()
|
||||
{
|
||||
_selectedNodeId = _manualNodeId.Trim();
|
||||
}
|
||||
|
||||
private async Task Confirm()
|
||||
{
|
||||
_isVisible = false;
|
||||
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
|
||||
await OnSelected.InvokeAsync(_selectedNodeId!);
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
_isVisible = false;
|
||||
await OnCancelled.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
||||
<li>
|
||||
<span style="cursor: @(Node.HasChildren ? "pointer" : "default");" @onclick="() => OnToggle.InvokeAsync(Node)">
|
||||
@if (Node.HasChildren)
|
||||
{
|
||||
<span>@(Node.Expanded ? "▼" : "▶")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="display:inline-block; width:1em;"> </span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (Node.NodeClass == BrowseNodeClass.Variable)
|
||||
{
|
||||
<a href="javascript:void(0)"
|
||||
class="@(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
|
||||
@onclick="() => OnSelect.InvokeAsync(Node)"
|
||||
@ondblclick="() => OnSelect.InvokeAsync(Node)">
|
||||
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">@Node.DisplayName</span>
|
||||
}
|
||||
|
||||
@if (Node.Loading)
|
||||
{
|
||||
<em class="ms-2 text-muted">loading…</em>
|
||||
}
|
||||
|
||||
@if (Node.Expanded && Node.Children is not null)
|
||||
{
|
||||
<ul class="list-unstyled ms-4">
|
||||
@foreach (var child in Node.Children)
|
||||
{
|
||||
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" SelectedNodeId="@SelectedNodeId" />
|
||||
}
|
||||
@if (Node.Truncated)
|
||||
{
|
||||
<li><small class="text-warning">Results truncated — use manual entry if your tag isn't listed.</small></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public OpcUaBrowserDialog.TreeNode Node { get; set; } = default!;
|
||||
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnToggle { get; set; }
|
||||
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnSelect { get; set; }
|
||||
[Parameter] public string? SelectedNodeId { get; set; }
|
||||
}
|
||||
+182
-4
@@ -9,6 +9,7 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@@ -108,17 +109,22 @@
|
||||
<th>Attribute</th>
|
||||
<th>Tag Path</th>
|
||||
<th style="width: 280px;">Connection</th>
|
||||
<th>Override</th>
|
||||
<th style="width: 110px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
var connId = GetBindingConnectionId(attr.Name);
|
||||
var canBrowse = connId > 0;
|
||||
var isOpcUa = IsOpcUa(connId);
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetBindingConnectionId(attr.Name)"
|
||||
value="@connId"
|
||||
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
@@ -127,12 +133,39 @@
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm"
|
||||
value="@GetOverrideForAttr(attr.Name)"
|
||||
@onchange="(e) => OnOverrideForAttrChanged(attr.Name, e)"
|
||||
placeholder="@(attr.DataSourceReference ?? "(no default)")" />
|
||||
</td>
|
||||
<td>
|
||||
@if (isOpcUa)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
disabled="@(!canBrowse)"
|
||||
title="@(canBrowse ? "Browse OPC UA address space" : "Pick a connection first")"
|
||||
@onclick="() => OpenBrowser(attr.Name)">
|
||||
Browse…
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</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>
|
||||
@@ -331,6 +364,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser
|
||||
tracks which binding row's override input receives the picked node id. *@
|
||||
<OpcUaBrowserDialog @ref="_browserRef"
|
||||
SiteId="@_browserSiteIdentifier"
|
||||
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>
|
||||
|
||||
@@ -350,8 +396,28 @@
|
||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private Dictionary<string, int> _bindingSelections = new();
|
||||
/// <summary>
|
||||
/// Per-attribute <c>DataSourceReferenceOverride</c> values (Task 18). Mirrors
|
||||
/// <see cref="_bindingSelections"/> by attribute name. Loaded from the
|
||||
/// existing <see cref="InstanceConnectionBinding"/> rows on init; round-tripped
|
||||
/// through <see cref="ConnectionBinding"/> on <c>SaveBindings</c>.
|
||||
/// </summary>
|
||||
private Dictionary<string, string?> _bindingOverrides = new();
|
||||
private int _bulkConnectionId;
|
||||
|
||||
// OPC UA tag browser (Task 18) — single dialog rendered at page bottom;
|
||||
// _browserAttrInEdit tracks which row gets the picked node id on Select.
|
||||
private OpcUaBrowserDialog? _browserRef;
|
||||
private string? _browserAttrInEdit;
|
||||
private string _browserSiteIdentifier = "";
|
||||
private string _browserConnectionName = "";
|
||||
private string? _browserInitial;
|
||||
private string _siteIdentifier = "";
|
||||
|
||||
// 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();
|
||||
@@ -407,7 +473,11 @@
|
||||
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
|
||||
|
||||
var sites = await SiteRepository.GetAllSitesAsync();
|
||||
_siteName = sites.FirstOrDefault(s => s.Id == _instance.SiteId)?.Name ?? $"#{_instance.SiteId}";
|
||||
var site = sites.FirstOrDefault(s => s.Id == _instance.SiteId);
|
||||
_siteName = site?.Name ?? $"#{_instance.SiteId}";
|
||||
// Task 18: cache the site's machine identifier — the OPC UA browse
|
||||
// dialog routes by SiteIdentifier (string), not the numeric site id.
|
||||
_siteIdentifier = site?.SiteIdentifier ?? "";
|
||||
|
||||
// Areas
|
||||
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_instance.SiteId)).ToList();
|
||||
@@ -420,7 +490,11 @@
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
|
||||
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
|
||||
foreach (var b in existingBindings)
|
||||
{
|
||||
_bindingSelections[b.AttributeName] = b.DataConnectionId;
|
||||
if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride))
|
||||
_bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride;
|
||||
}
|
||||
|
||||
// Overrides
|
||||
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
|
||||
@@ -474,12 +548,116 @@
|
||||
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||
}
|
||||
|
||||
// ── Task 18: per-attribute override input + OPC UA tag browser ──────────
|
||||
|
||||
private string? GetOverrideForAttr(string attrName)
|
||||
=> _bindingOverrides.GetValueOrDefault(attrName);
|
||||
|
||||
private void OnOverrideForAttrChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = e.Value?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(val))
|
||||
_bindingOverrides.Remove(attrName);
|
||||
else
|
||||
_bindingOverrides[attrName] = val;
|
||||
}
|
||||
|
||||
/// <summary>Looks up the template default <c>DataSourceReference</c> for an attribute.</summary>
|
||||
private string? GetTemplateDefault(string attrName)
|
||||
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference;
|
||||
|
||||
/// <summary>True when the row's selected data connection is OPC UA.</summary>
|
||||
private bool IsOpcUa(int connectionId)
|
||||
=> connectionId > 0
|
||||
&& string.Equals(
|
||||
_siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol,
|
||||
"OpcUa",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the OPC UA tag browser dialog for the given attribute row. Remembers
|
||||
/// which attribute is being edited so <see cref="OnBrowserSelected"/> can
|
||||
/// write the picked node id back to the right override input.
|
||||
/// </summary>
|
||||
private async Task OpenBrowser(string attrName)
|
||||
{
|
||||
var connId = GetBindingConnectionId(attrName);
|
||||
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
|
||||
if (conn is null) return;
|
||||
|
||||
_browserAttrInEdit = attrName;
|
||||
_browserConnectionName = conn.Name;
|
||||
_browserSiteIdentifier = _siteIdentifier;
|
||||
_browserInitial = _bindingOverrides.GetValueOrDefault(attrName)
|
||||
?? GetTemplateDefault(attrName);
|
||||
|
||||
if (_browserRef is not null)
|
||||
await _browserRef.ShowAsync(_siteIdentifier, conn.Name, _browserInitial);
|
||||
}
|
||||
|
||||
private void OnBrowserSelected(string nodeId)
|
||||
{
|
||||
if (_browserAttrInEdit is null) return;
|
||||
_bindingOverrides[_browserAttrInEdit] = nodeId;
|
||||
_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;
|
||||
try
|
||||
{
|
||||
var bindings = _bindingSelections.Select(kv => new ConnectionBinding(kv.Key, kv.Value)).ToList();
|
||||
// Task 18: include the per-attribute DataSourceReferenceOverride on
|
||||
// the wire record so it round-trips through SetConnectionBindingsAsync
|
||||
// into the InstanceConnectionBinding entity.
|
||||
var bindings = _bindingSelections
|
||||
.Select(kv => new ConnectionBinding(
|
||||
kv.Key,
|
||||
kv.Value,
|
||||
_bindingOverrides.GetValueOrDefault(kv.Key)))
|
||||
.ToList();
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
|
||||
if (result.IsSuccess)
|
||||
|
||||
@@ -50,6 +50,18 @@ public static class ServiceCollectionExtensions
|
||||
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
|
||||
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
||||
|
||||
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync
|
||||
// that enforces the CentralUI-side Design-role trust boundary and translates
|
||||
// transport failures into typed BrowseFailure results for the dialog.
|
||||
services.AddScoped<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,37 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over the central-to-site OPC UA browse command. Backs the
|
||||
/// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls
|
||||
/// <see cref="BrowseChildrenAsync"/>, which forwards a
|
||||
/// <see cref="BrowseOpcUaNodeCommand"/> to the owning site via
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service is the trust boundary for the browse 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="BrowseFailure"/> so the dialog can render an inline error and
|
||||
/// remain usable (manual node-id paste still works).
|
||||
/// </remarks>
|
||||
public interface IOpcUaBrowseService
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerates the immediate children of an OPC UA node on the live server
|
||||
/// backing <paramref name="connectionName"/> at <paramref name="siteId"/>.
|
||||
/// Pass <c>null</c> for <paramref name="parentNodeId"/> to browse from the
|
||||
/// server root (ObjectsFolder).
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="connectionName">Name of the site-local data connection to browse against — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param>
|
||||
/// <param name="parentNodeId">Node to browse, or <c>null</c> to browse from the server root.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
|
||||
string siteId,
|
||||
string connectionName,
|
||||
string? parentNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IOpcUaBrowseService"/> implementation — a thin facade over
|
||||
/// <see cref="CommunicationService.BrowseOpcUaNodeAsync"/> that enforces the
|
||||
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport
|
||||
/// exceptions into a typed <see cref="BrowseFailure"/> result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Site-side actors (<c>SiteCommunicationActor</c> + <c>DeploymentManagerActor</c>)
|
||||
/// do not unwrap the central trust envelope, so the role check MUST run here —
|
||||
/// never on the site. Transport failures collapse into <c>Timeout</c> or
|
||||
/// <c>ServerError</c> so the dialog can show an inline banner while leaving the
|
||||
/// manual node-id paste field usable.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaBrowseService : IOpcUaBrowseService
|
||||
{
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly AuthenticationStateProvider _auth;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpcUaBrowseService"/>.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side cluster communication service.</param>
|
||||
/// <param name="auth">Authentication state provider used for the Design-role guard.</param>
|
||||
public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth)
|
||||
{
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
|
||||
string siteId,
|
||||
string connectionName,
|
||||
string? parentNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles,
|
||||
// so the Design check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
{
|
||||
return new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _communication.BrowseOpcUaNodeAsync(
|
||||
siteId,
|
||||
new BrowseOpcUaNodeCommand(connectionName, parentNodeId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
// Akka Ask timed out — the site (or its OPC UA session) didn't answer
|
||||
// within CommunicationOptions.QueryTimeout. Surface as a typed
|
||||
// Timeout failure so the dialog can render an inline banner.
|
||||
return new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller-initiated cancel — propagate so Blazor can drop the response
|
||||
// cleanly. Distinct from Timeout (which the dialog renders inline).
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any other transport / serialization failure: keep the dialog
|
||||
// alive and let the user fall back to manual node-id paste.
|
||||
return new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@ public class InstanceConnectionBinding
|
||||
/// <summary>Foreign key to the data connection that provides values for this attribute.</summary>
|
||||
public int DataConnectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-instance override of the OPC UA node identifier (or other
|
||||
/// protocol address) for this attribute. When non-null, this value replaces
|
||||
/// the template's <c>DataSourceReference</c> during flattening. When null,
|
||||
/// the template default is used.
|
||||
/// </summary>
|
||||
public string? DataSourceReferenceOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding for the specified attribute name.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Optional capability for an <see cref="IDataConnection"/> implementation
|
||||
/// that supports browsing the server's address space. Consumed only by
|
||||
/// management/UI flows (e.g. the OPC UA tag picker on the instance config
|
||||
/// page) — never by Instance Actors on the hot path.
|
||||
/// </summary>
|
||||
public interface IBrowsableDataConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
|
||||
/// the server's root-level nodes when null.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
|
||||
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <param name="Children">Child nodes returned by the server in browse order.</param>
|
||||
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
|
||||
public record BrowseChildrenResult(
|
||||
IReadOnlyList<BrowseNode> Children,
|
||||
bool Truncated);
|
||||
|
||||
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
|
||||
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
|
||||
/// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param>
|
||||
/// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param>
|
||||
public record BrowseNode(
|
||||
string NodeId,
|
||||
string DisplayName,
|
||||
BrowseNodeClass NodeClass,
|
||||
bool HasChildren);
|
||||
|
||||
public enum BrowseNodeClass { Object, Variable, Method, Other }
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/> when
|
||||
/// the underlying session is not currently connected. Translated to
|
||||
/// <c>BrowseFailureKind.ConnectionNotConnected</c> by the site-side handler.
|
||||
/// </summary>
|
||||
public sealed class ConnectionNotConnectedException : InvalidOperationException
|
||||
{
|
||||
public ConnectionNotConnectedException(string message) : base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Sent from CentralUI to a specific site to enumerate the immediate children
|
||||
/// of an OPC UA node on the live server backing the given data connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed by <see cref="ConnectionName"/> (not id) because the site-side
|
||||
/// <c>DataConnectionManagerActor</c> indexes its children by connection name —
|
||||
/// the central UI already has the connection name in scope (dropdown), so a
|
||||
/// string carries no extra plumbing across the trust boundary. The central
|
||||
/// <c>DataConnections</c> table's id is intentionally not exposed at the site.
|
||||
/// </remarks>
|
||||
/// <param name="ConnectionName">Name of the site-local data connection to browse against.</param>
|
||||
/// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param>
|
||||
public record BrowseOpcUaNodeCommand(
|
||||
string ConnectionName,
|
||||
string? ParentNodeId);
|
||||
|
||||
public record BrowseOpcUaNodeResult(
|
||||
IReadOnlyList<BrowseNode> Children,
|
||||
bool Truncated,
|
||||
BrowseFailure? Failure);
|
||||
|
||||
public record BrowseFailure(
|
||||
BrowseFailureKind Kind,
|
||||
string Message);
|
||||
|
||||
public enum BrowseFailureKind
|
||||
{
|
||||
ConnectionNotFound,
|
||||
ConnectionNotConnected,
|
||||
NotBrowsable,
|
||||
Timeout,
|
||||
ServerError
|
||||
}
|
||||
@@ -12,8 +12,17 @@ public record MgmtDeleteInstanceCommand(int InstanceId);
|
||||
/// <see cref="SetConnectionBindingsCommand"/>. This is a named record (not a
|
||||
/// <c>ValueTuple</c>) so it serializes with stable, named JSON properties and can
|
||||
/// evolve additively per REQ-COM-5a.
|
||||
/// <para>
|
||||
/// <c>DataSourceReferenceOverride</c> is an optional per-instance override of
|
||||
/// the OPC UA node id (or other protocol address) for the bound attribute.
|
||||
/// When non-null it replaces the template's <c>DataSourceReference</c> at
|
||||
/// flattening time; when null the template default is used.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public record ConnectionBinding(string AttributeName, int DataConnectionId);
|
||||
public record ConnectionBinding(
|
||||
string AttributeName,
|
||||
int DataConnectionId,
|
||||
string? DataSourceReferenceOverride = null);
|
||||
|
||||
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<ConnectionBinding> Bindings);
|
||||
public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary<string, string?> Overrides);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
|
||||
@@ -144,6 +145,21 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
Receive<RouteToGetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RouteToSetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA Tag Browser (interactive design-time query) — forward to the
|
||||
// Deployment Manager singleton, which always lands on the active site
|
||||
// node. Routing to the site-local /user/dcl-manager directly is wrong
|
||||
// because the standby node has a dcl-manager too, but its
|
||||
// DataConnectionActor children (which own the live OPC UA sessions)
|
||||
// only exist on the singleton's node. The singleton then re-forwards
|
||||
// to its own /user/dcl-manager, which DOES have the connection.
|
||||
Receive<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 =>
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
@@ -346,6 +347,53 @@ public class CommunicationService
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── OPC UA Tag Browser (interactive design-time query) ──
|
||||
|
||||
/// <summary>
|
||||
/// Asks a site to enumerate the immediate children of an OPC UA node on the
|
||||
/// live server backing the given data connection. Used by the CentralUI OPC UA
|
||||
/// Tag Browser dialog. The Ask is bounded by <see cref="CommunicationOptions.QueryTimeout"/>
|
||||
/// — interactive browse expansions are short, one-shot queries that share the
|
||||
/// same latency budget as other remote queries (event logs, parked messages).
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="command">The OPC UA browse command.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The browse result (children + truncation flag + structured failure).</returns>
|
||||
public Task<BrowseOpcUaNodeResult> BrowseOpcUaNodeAsync(
|
||||
string siteId,
|
||||
BrowseOpcUaNodeCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, command);
|
||||
return GetActor().Ask<BrowseOpcUaNodeResult>(
|
||||
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.
|
||||
|
||||
|
||||
+4
@@ -107,6 +107,10 @@ public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<I
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(b => b.DataSourceReferenceOverride)
|
||||
.HasMaxLength(512)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne<DataConnection>()
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.DataConnectionId)
|
||||
|
||||
+1667
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInstanceConnectionBindingOverride : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DataSourceReferenceOverride",
|
||||
table: "InstanceConnectionBindings",
|
||||
type: "nvarchar(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DataSourceReferenceOverride",
|
||||
table: "InstanceConnectionBindings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -769,6 +769,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.Property<int>("DataConnectionId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DataSourceReferenceOverride")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
@@ -233,6 +234,19 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// apply it so its state survives into the next ReSubscribeAll.
|
||||
HandleSubscribeCompleted(sc);
|
||||
break;
|
||||
case BrowseOpcUaNodeCommand browse:
|
||||
// Browse is an interactive design-time query; never stash. The
|
||||
// adapter has no session yet in this state, so reply with a
|
||||
// typed ConnectionNotConnected failure so the dialog can render
|
||||
// an inline banner.
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case 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;
|
||||
@@ -293,6 +307,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
case RetryTagResolution:
|
||||
HandleRetryTagResolution();
|
||||
break;
|
||||
case BrowseOpcUaNodeCommand browse:
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case ReadTagValuesCommand read:
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -412,6 +432,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// apply it so its state survives into the next ReSubscribeAll.
|
||||
HandleSubscribeCompleted(sc);
|
||||
break;
|
||||
case BrowseOpcUaNodeCommand browse:
|
||||
// Browse is design-time and never stashed. While reconnecting
|
||||
// the adapter has no live session, so the adapter call will
|
||||
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case 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;
|
||||
@@ -947,6 +979,166 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
}).PipeTo(sender);
|
||||
}
|
||||
|
||||
// ── OPC UA Tag Browser (interactive design-time query) ──
|
||||
|
||||
/// <summary>
|
||||
/// Handles a <see cref="BrowseOpcUaNodeCommand"/> forwarded by the
|
||||
/// <see cref="DataConnectionManagerActor"/>. The capability check (does
|
||||
/// this adapter support browsing?) and all browse-failure mapping live
|
||||
/// here because the adapter is held by this actor, not the manager.
|
||||
///
|
||||
/// Failure mapping:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BrowseFailureKind.NotBrowsable"/> — adapter is not <see cref="IBrowsableDataConnection"/>.</item>
|
||||
/// <item><see cref="BrowseFailureKind.ConnectionNotConnected"/> — adapter threw <see cref="ConnectionNotConnectedException"/>.</item>
|
||||
/// <item><see cref="BrowseFailureKind.Timeout"/> — adapter threw <see cref="OperationCanceledException"/>.</item>
|
||||
/// <item><see cref="BrowseFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The reply is sent via <c>PipeTo(sender)</c> — the same pattern used by
|
||||
/// <see cref="HandleWrite"/> — so the captured <see cref="Sender"/> is
|
||||
/// safe to use from the continuation (which runs off the actor thread).
|
||||
/// </summary>
|
||||
private void HandleBrowse(BrowseOpcUaNodeCommand command)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
if (_adapter is not IBrowsableDataConnection browsable)
|
||||
{
|
||||
_log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName);
|
||||
sender.Tell(new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(
|
||||
BrowseFailureKind.NotBrowsable,
|
||||
$"Connection '{_connectionName}' does not support browsing.")));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Debug("[{0}] Browsing OPC UA children of {1}", _connectionName, command.ParentNodeId ?? "(root)");
|
||||
|
||||
browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
|
||||
}
|
||||
|
||||
var baseEx = t.Exception?.GetBaseException();
|
||||
return baseEx switch
|
||||
{
|
||||
ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
|
||||
OperationCanceledException => new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
|
||||
_ => new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(
|
||||
BrowseFailureKind.ServerError,
|
||||
baseEx?.Message ?? "Unknown browse error.")),
|
||||
};
|
||||
}).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()
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
|
||||
@@ -45,6 +46,8 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
Receive<WriteTagRequest>(HandleRouteWrite);
|
||||
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
|
||||
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
|
||||
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
|
||||
Receive<ReadTagValuesCommand>(HandleReadTagValues);
|
||||
}
|
||||
|
||||
private void HandleCreateConnection(CreateConnectionCommand command)
|
||||
@@ -111,6 +114,60 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a <see cref="BrowseOpcUaNodeCommand"/> from the central UI's OPC UA
|
||||
/// Tag Browser to the child <see cref="DataConnectionActor"/> that owns the
|
||||
/// named connection. The manager is the only actor that knows whether a
|
||||
/// connection exists at this site — so it owns the
|
||||
/// <see cref="BrowseFailureKind.ConnectionNotFound"/> failure. Everything
|
||||
/// else (capability check, session state, server errors) lives inside the
|
||||
/// child where the adapter is held.
|
||||
/// </summary>
|
||||
private void HandleBrowse(BrowseOpcUaNodeCommand command)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
|
||||
{
|
||||
actor.Forward(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Warning("No connection actor for {0} during browse", command.ConnectionName);
|
||||
Sender.Tell(new BrowseOpcUaNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
Truncated: false,
|
||||
new BrowseFailure(
|
||||
BrowseFailureKind.ConnectionNotFound,
|
||||
$"No data connection named '{command.ConnectionName}' at this site.")));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
@@ -102,6 +104,19 @@ public interface IOpcUaClient : IAsyncDisposable
|
||||
/// becomes unreachable. The adapter layer uses this to trigger reconnection.
|
||||
/// </summary>
|
||||
event Action? ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
|
||||
/// (or the server's ObjectsFolder when null). Throws
|
||||
/// <see cref="ConnectionNotConnectedException"/> when the session is not
|
||||
/// currently up.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task that completes with the immediate children of the requested node.</returns>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,6 +195,11 @@ internal class StubOpcUaClient : IOpcUaClient
|
||||
return Task.FromResult<uint>(0); // Good status
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
/// - Read/Write → Read/Write service calls
|
||||
/// - Quality → OPC UA StatusCode mapping
|
||||
/// </summary>
|
||||
public class OpcUaDataConnection : IDataConnection
|
||||
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
|
||||
{
|
||||
private readonly IOpcUaClientFactory _clientFactory;
|
||||
private readonly ILogger<OpcUaDataConnection> _logger;
|
||||
@@ -274,6 +274,12 @@ public class OpcUaDataConnection : IDataConnection
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, object?> values, string flagPath, object? flagValue,
|
||||
|
||||
@@ -325,6 +325,72 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
|
||||
// reference once, fail fast with a typed exception if the link is
|
||||
// down, then call the SDK's async API directly (no Task.Run wrap —
|
||||
// the OPC Foundation SDK already provides true async I/O).
|
||||
var session = _session;
|
||||
if (session is null || !session.Connected)
|
||||
{
|
||||
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
|
||||
"OPC UA session is not connected.");
|
||||
}
|
||||
|
||||
// ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty
|
||||
// / null input means "browse the root"; anything else is parsed as
|
||||
// an absolute NodeId expression.
|
||||
var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(parentNodeId);
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
|
||||
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeToBrowse,
|
||||
1000u,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var refs = references ?? new ReferenceDescriptionCollection();
|
||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
children.Add(new Commons.Interfaces.Protocol.BrowseNode(
|
||||
NodeId: r.NodeId.ToString(),
|
||||
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
|
||||
NodeClass: MapNodeClass(r.NodeClass),
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
}
|
||||
|
||||
// A non-empty continuation point means the server had more refs than
|
||||
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
|
||||
// children, type the node id manually" hint rather than auto-paging;
|
||||
// BrowseNext is not invoked here. Discarding the continuation point
|
||||
// is acceptable because the server expires it on session close.
|
||||
var truncated = continuationPoint != null && continuationPoint.Length > 0;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
|
||||
}
|
||||
|
||||
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
|
||||
{
|
||||
NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object,
|
||||
NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable,
|
||||
NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method,
|
||||
_ => Commons.Interfaces.Protocol.BrowseNodeClass.Other
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
@@ -147,6 +148,22 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
Receive<RouteToGetAttributesRequest>(RouteInboundApiGetAttributes);
|
||||
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
|
||||
|
||||
// OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager.
|
||||
// BrowseOpcUaNodeCommand is routed to this singleton (active node) by
|
||||
// SiteCommunicationActor so the dcl-manager we forward to is guaranteed
|
||||
// to be the one holding the live DataConnectionActor children. ActorSelection
|
||||
// has no Forward() extension in this Akka.NET version, so we Tell with the
|
||||
// original Sender preserved (semantically identical to Forward).
|
||||
Receive<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);
|
||||
|
||||
@@ -365,7 +365,8 @@ public class FlatteningService
|
||||
{
|
||||
BoundDataConnectionId = connection.Id,
|
||||
BoundDataConnectionName = connection.Name,
|
||||
BoundDataConnectionProtocol = connection.Protocol
|
||||
BoundDataConnectionProtocol = connection.Protocol,
|
||||
DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,20 +330,22 @@ public class InstanceService
|
||||
|
||||
var results = new List<InstanceConnectionBinding>();
|
||||
|
||||
foreach (var (attrName, connId) in bindings)
|
||||
foreach (var b in bindings)
|
||||
{
|
||||
if (existingMap.TryGetValue(attrName, out var existing))
|
||||
if (existingMap.TryGetValue(b.AttributeName, out var existing))
|
||||
{
|
||||
existing.DataConnectionId = connId;
|
||||
existing.DataConnectionId = b.DataConnectionId;
|
||||
existing.DataSourceReferenceOverride = b.DataSourceReferenceOverride;
|
||||
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
||||
results.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var binding = new InstanceConnectionBinding(attrName)
|
||||
var binding = new InstanceConnectionBinding(b.AttributeName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DataConnectionId = connId
|
||||
DataConnectionId = b.DataConnectionId,
|
||||
DataSourceReferenceOverride = b.DataSourceReferenceOverride
|
||||
};
|
||||
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
||||
results.Add(binding);
|
||||
|
||||
+7
@@ -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,23 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="BrowseOpcUaNodeCommand"/> 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>).
|
||||
/// </summary>
|
||||
public class BrowseCommandsRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_discovers_BrowseOpcUaNodeCommand()
|
||||
{
|
||||
// GetCommandName throws ArgumentException for any type the registry
|
||||
// does not contain, so a successful call here is proof of discovery.
|
||||
var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseOpcUaNodeCommand));
|
||||
|
||||
Assert.Equal("BrowseOpcUaNode", name);
|
||||
Assert.Equal(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name));
|
||||
}
|
||||
}
|
||||
+28
@@ -51,4 +51,32 @@ public class ConnectionBindingSerializationTests
|
||||
// ConnectionBinding is a record: each element compares by value.
|
||||
Assert.Equal(original.Bindings, deserialized.Bindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_preserves_override_when_set()
|
||||
{
|
||||
var original = new ConnectionBinding("Speed", 7, "ns=2;s=Pump1.Speed");
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var roundtripped = JsonSerializer.Deserialize<ConnectionBinding>(json);
|
||||
|
||||
Assert.NotNull(roundtripped);
|
||||
Assert.Equal(original, roundtripped);
|
||||
Assert.Equal("ns=2;s=Pump1.Speed", roundtripped!.DataSourceReferenceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_defaults_override_to_null_when_absent()
|
||||
{
|
||||
// Older site builds will not emit the new field — deserialization
|
||||
// must produce a null override and equal an explicit-null instance.
|
||||
const string legacyJson = """{"AttributeName":"Speed","DataConnectionId":7}""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ConnectionBinding>(legacyJson);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("Speed", deserialized!.AttributeName);
|
||||
Assert.Equal(7, deserialized.DataConnectionId);
|
||||
Assert.Null(deserialized.DataSourceReferenceOverride);
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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));
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 10 (opcua-tag-browser): the site-side
|
||||
/// <see cref="DataConnectionManagerActor"/> + child
|
||||
/// <see cref="DataConnectionActor"/> together resolve
|
||||
/// <see cref="BrowseOpcUaNodeCommand"/> against the live adapter and surface
|
||||
/// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is:
|
||||
/// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it
|
||||
/// knows the per-site connection set); everything else lives in the child where
|
||||
/// the adapter is held — <see cref="BrowseFailureKind.NotBrowsable"/> from the
|
||||
/// capability check, <see cref="BrowseFailureKind.ConnectionNotConnected"/> /
|
||||
/// <see cref="BrowseFailureKind.Timeout"/> / <see cref="BrowseFailureKind.ServerError"/>
|
||||
/// from the adapter call. These tests guard that split.
|
||||
/// </summary>
|
||||
public class DataConnectionManagerBrowseHandlerTests : TestKit
|
||||
{
|
||||
private readonly IDataConnectionFactory _factory;
|
||||
private readonly ISiteHealthCollector _healthCollector;
|
||||
private readonly DataConnectionOptions _options;
|
||||
|
||||
public DataConnectionManagerBrowseHandlerTests()
|
||||
: base(@"akka.loglevel = WARNING")
|
||||
{
|
||||
_factory = Substitute.For<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 a
|
||||
// browse against any name must be rejected with ConnectionNotFound
|
||||
// (the manager is the only actor with site-level visibility).
|
||||
manager.Tell(new BrowseOpcUaNodeCommand("unknown-connection", ParentNodeId: null));
|
||||
|
||||
var reply = ExpectMsg<BrowseOpcUaNodeResult>();
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_browsable_adapter_returns_NotBrowsable()
|
||||
{
|
||||
// Bare IDataConnection — no IBrowsableDataConnection. The child actor's
|
||||
// capability check must surface this as NotBrowsable.
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
adapter.Status.Returns(ConnectionHealth.Connected);
|
||||
_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-bare", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
// Give the manager a moment to spawn the child actor. We do not need to
|
||||
// wait for Connected — the browse handler runs in all states.
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null));
|
||||
|
||||
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_path_returns_mapped_children()
|
||||
{
|
||||
// Adapter implementing both IDataConnection (so DataConnectionActor can
|
||||
// run its lifecycle) AND IBrowsableDataConnection (so the browse handler
|
||||
// takes the success path).
|
||||
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
||||
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
var children = new[]
|
||||
{
|
||||
new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, HasChildren: false),
|
||||
new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true),
|
||||
};
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
||||
.Returns(new BrowseChildrenResult(children, Truncated: false));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns((IDataConnection)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 BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null));
|
||||
|
||||
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.Null(reply.Failure);
|
||||
Assert.Equal(2, reply.Children.Count);
|
||||
Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
|
||||
Assert.Equal("ns=2;s=B", reply.Children[1].NodeId);
|
||||
Assert.False(reply.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
||||
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<BrowseChildrenResult>(
|
||||
new ConnectionNotConnectedException("OPC UA session is not connected.")));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns((IDataConnection)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));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null));
|
||||
|
||||
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Children);
|
||||
}
|
||||
}
|
||||
+174
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 (opcua-tag-browser): <see cref="OpcUaDataConnection"/> implements
|
||||
/// <see cref="IBrowsableDataConnection"/> as a one-line forwarder onto the
|
||||
/// underlying <see cref="IOpcUaClient"/>. This guards that wiring — the
|
||||
/// adapter must not add its own browse semantics; all browse behaviour lives
|
||||
/// in the client (and is covered by RealOpcUaClient's own tests).
|
||||
/// </summary>
|
||||
public class OpcUaDataConnectionBrowseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BrowseChildrenAsync_ForwardsToUnderlyingClient()
|
||||
{
|
||||
var client = Substitute.For<IOpcUaClient>();
|
||||
var factory = Substitute.For<IOpcUaClientFactory>();
|
||||
factory.Create().Returns(client);
|
||||
client.IsConnected.Returns(true);
|
||||
|
||||
var expected = new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
|
||||
Truncated: false);
|
||||
client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
|
||||
// ConnectAsync installs the IOpcUaClient instance on the adapter; the
|
||||
// forwarder dereferences that field directly.
|
||||
await adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent");
|
||||
|
||||
Assert.Same(expected, actual);
|
||||
await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RealOpcUaClient.BrowseChildrenAsync"/>.
|
||||
///
|
||||
/// Two shapes here:
|
||||
///
|
||||
/// 1. A live round-trip against the infra OPC UA server
|
||||
/// (<c>opc.tcp://localhost:50000</c> — see <c>infra/docker-compose.yml</c>,
|
||||
/// started via <c>cd infra && docker compose up -d opcua</c>).
|
||||
/// Marked <c>[SkippableFact]</c> so it reports Skipped — not failed — on
|
||||
/// machines that don't have the infra stack running. The live test asserts
|
||||
/// that the server root browse returns the standard "Server" node, which
|
||||
/// proves we targeted ObjectsFolder (<c>ns=0;i=85</c>) and that the
|
||||
/// response mapping survived the round trip.
|
||||
///
|
||||
/// 2. A pure unit test that exercises the not-connected guard — no infra
|
||||
/// needed, runs in every build.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresOpcUa")]
|
||||
public class RealOpcUaClientBrowseTests
|
||||
{
|
||||
// The infra/docker-compose.yml opcua container maps the OPC PLC simulator
|
||||
// on host port 50000 (not the OPC UA default 4840). Matches what the
|
||||
// existing docker/ and docker-env2/ topologies dial into.
|
||||
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node()
|
||||
{
|
||||
await using var client = new RealOpcUaClient();
|
||||
|
||||
// Probe the endpoint before asserting anything. If the infra OPC UA
|
||||
// server isn't up, ConnectAsync surfaces a socket/timeout error from
|
||||
// deep inside the OPC Foundation SDK — we treat that as "infra not
|
||||
// available" and skip rather than fail, mirroring the SkippableFact
|
||||
// pattern already used in ConfigurationDatabase/AuditLog tests.
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(
|
||||
EndpointUrl,
|
||||
new OpcUaConnectionOptions(AutoAcceptUntrustedCerts: true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}");
|
||||
return; // Skip.If throws; this is unreachable but keeps the compiler happy.
|
||||
}
|
||||
|
||||
var result = await client.BrowseChildrenAsync(parentNodeId: null);
|
||||
|
||||
// Under ObjectsFolder (ns=0;i=85) every OPC UA-compliant server
|
||||
// exposes a 'Server' object at ns=0;i=2253 — its presence confirms
|
||||
// we hit the right root and that DisplayName mapping survives the
|
||||
// round trip.
|
||||
Assert.NotEmpty(result.Children);
|
||||
Assert.Contains(result.Children, n => n.DisplayName == "Server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null()
|
||||
{
|
||||
// No ConnectAsync — _session is still null, so the typed guard at the
|
||||
// top of BrowseChildrenAsync should fire before any SDK call.
|
||||
await using var client = new RealOpcUaClient();
|
||||
|
||||
await Assert.ThrowsAsync<ConnectionNotConnectedException>(
|
||||
() => client.BrowseChildrenAsync(parentNodeId: null));
|
||||
}
|
||||
}
|
||||
+7
@@ -15,6 +15,13 @@
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
Xunit.SkippableFact lets the live-OPC-UA browse test report Skipped when
|
||||
the infra OPC UA server (opc.tcp://localhost:4840) is unreachable, so a
|
||||
CI run without the infra stack stays green. The not-connected unit test
|
||||
uses a plain [Fact] — it never needs the server.
|
||||
-->
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class ConnectionBindingOverrideTests
|
||||
{
|
||||
private readonly FlatteningService _sut = new();
|
||||
|
||||
private static Instance CreateInstance(string name = "TestInstance", int templateId = 1, int siteId = 1) =>
|
||||
new(name) { Id = 1, TemplateId = templateId, SiteId = siteId };
|
||||
|
||||
private static Template CreateTemplate(int id, string name)
|
||||
{
|
||||
return new Template(name) { Id = id };
|
||||
}
|
||||
|
||||
private static Template CreateTemplateWithDataSourcedAttribute(string attributeName, string dataSourceReference)
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute(attributeName)
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
DataSourceReference = dataSourceReference
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
private static Dictionary<int, DataConnection> SingleConnection(int id = 1) =>
|
||||
new()
|
||||
{
|
||||
[id] = new("OPC-Server1", "OpcUa", 1) { Id = id, PrimaryConfiguration = "opc.tcp://localhost:4840" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Override_replaces_template_DataSourceReference_when_set()
|
||||
{
|
||||
var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
|
||||
var instance = CreateInstance();
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
|
||||
{
|
||||
DataConnectionId = 1,
|
||||
DataSourceReferenceOverride = "ns=2;s=Pump1.Speed"
|
||||
});
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
SingleConnection(id: 1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var attr = result.Value.Attributes.Single(a => a.CanonicalName == "Speed");
|
||||
Assert.Equal("ns=2;s=Pump1.Speed", attr.DataSourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_override_falls_back_to_template_default()
|
||||
{
|
||||
var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
|
||||
var instance = CreateInstance();
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
|
||||
{
|
||||
DataConnectionId = 1,
|
||||
DataSourceReferenceOverride = null
|
||||
});
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
SingleConnection(id: 1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var attr = result.Value.Attributes.Single(a => a.CanonicalName == "Speed");
|
||||
Assert.Equal("TemplateDefault", attr.DataSourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Override_change_changes_revision_hash()
|
||||
{
|
||||
var template = CreateTemplateWithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
|
||||
|
||||
var instance1 = CreateInstance();
|
||||
instance1.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
|
||||
{
|
||||
DataConnectionId = 1,
|
||||
DataSourceReferenceOverride = "ns=2;s=Pump1.Speed"
|
||||
});
|
||||
|
||||
var instance2 = CreateInstance();
|
||||
instance2.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
|
||||
{
|
||||
DataConnectionId = 1,
|
||||
DataSourceReferenceOverride = "ns=2;s=Pump2.Speed"
|
||||
});
|
||||
|
||||
var connections = SingleConnection(id: 1);
|
||||
var compositionMap = new Dictionary<int, IReadOnlyList<TemplateComposition>>();
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>();
|
||||
|
||||
var result1 = _sut.Flatten(instance1, [template], compositionMap, composedChains, connections);
|
||||
var result2 = _sut.Flatten(instance2, [template], compositionMap, composedChains, connections);
|
||||
|
||||
Assert.True(result1.IsSuccess);
|
||||
Assert.True(result2.IsSuccess);
|
||||
|
||||
var hasher = new RevisionHashService();
|
||||
var hash1 = hasher.ComputeHash(result1.Value);
|
||||
var hash2 = hasher.ComputeHash(result2.Value);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user