Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
T
Joseph Doherty cb0d17dabd refactor(browse): rename OPC-UA browse service + dialog to protocol-agnostic
IOpcUaBrowseService/OpcUaBrowseService -> IBrowseService/BrowseService,
OpcUaBrowserDialog -> NodeBrowserDialog, and neutralize 'Browse OPC UA' UI
strings to 'Browse'. Updates DI, InstanceConfigure, TestBindingsDialog, TreeRow,
BindingTester, and tests. 574 CentralUI tests green.
2026-05-29 07:59:56 -04:00

209 lines
7.8 KiB
Plaintext

@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IBrowseService 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 — @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();
}
}