Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
T

410 lines
17 KiB
Plaintext

@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IBrowseService BrowseService
@if (_isVisible)
{
<div class="modal show d-block sb-modal-backdrop" tabindex="-1" role="dialog">
<div class="modal-dialog modal-xl" 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>
}
@if (ShowSearch)
{
<div class="input-group mb-3">
<input class="form-control" data-test="node-search-input"
@bind="_searchQuery" @bind:event="oninput"
@onkeydown="SearchOnEnter"
placeholder="Search the address space by name or path…" />
<button class="btn btn-outline-primary" data-test="node-search-button" @onclick="SearchAsync">Search</button>
</div>
}
@if (ShowSearch && _searchActive)
{
<div class="node-browser-search mb-3">
@if (_searchResults.Count == 0)
{
<em class="text-muted">No matches.</em>
}
else
{
<ul class="list-unstyled mb-0">
@foreach (var match in _searchResults)
{
var node = match.Node;
<li data-test="node-search-result" class="py-1">
<a href="javascript:void(0)"
class="@(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</a>
<small class="text-muted ms-1">@match.Path</small>
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-light text-muted border ms-1">@node.DataType</span>
}
</li>
}
</ul>
}
@if (_searchCapReached)
{
<small class="text-muted">Showing first 100 matches — refine your search.</small>
}
</div>
<hr />
}
<div class="node-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" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" />
}
</ul>
}
</div>
<hr />
<div class="input-group">
<span class="input-group-text">Manual node id:</span>
<input class="form-control" @bind="_manualNodeId" @bind:event="oninput" placeholder="nsu=&lt;namespace-uri&gt;;s=..." />
<button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button>
</div>
@if (!OpcUaReferenceForm.IsDurable(_manualNodeId))
{
<small class="text-warning-emphasis d-block mt-1" data-test="ns-index-warning">
This is a bare namespace-<em>index</em> binding. Indexes can silently re-point after a
server namespace change — prefer selecting from the tree above (it stores the durable
<code>nsu=&lt;uri&gt;;…</code> form).
</small>
}
</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; }
/// <summary>Additive: receives the full selected <see cref="BrowseNode"/> (incl.
/// DataType) on confirm, alongside the existing string-id <see cref="OnSelected"/>.
/// Optional — existing callers that only wire OnSelected are unaffected.</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
/// <summary>When false, hides the address-space search box. Set false for protocols
/// that don't implement IAddressSpaceSearchable (e.g. MxGateway), where search would
/// always fail. Default true preserves OPC UA behaviour.</summary>
[Parameter] public bool ShowSearch { get; set; } = true;
private bool _isVisible;
private string? _selectedNodeId;
private BrowseNode? _selectedNode;
private string _manualNodeId = "";
private BrowseFailure? _failure;
private string _failureMessage = "";
private List<TreeNode> _rootNodes = new();
// Search state: _searchActive distinguishes "ran a search that found
// nothing" from "never searched"; a blank query clears the panel entirely.
private string _searchQuery = "";
private bool _searchActive;
private bool _searchCapReached;
private List<AddressSpaceMatch> _searchResults = 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; }
/// <summary>
/// Friendly DataType name for Variable nodes; null when
/// not a Variable or the type read failed. Rendered as a muted badge.
/// </summary>
public string? DataType { get; init; }
/// <summary>The originating BrowseNode, retained so selection can emit the
/// full node (incl. DataType) via OnNodeSelected.</summary>
public BrowseNode? Source { get; init; }
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; }
/// <summary>
/// Opaque adapter cursor from the most recent browse of this node's
/// children. Non-null when more children remain (drives the "Load more"
/// button); null once the browse is exhausted. Echoed back verbatim to
/// <c>BrowseChildrenAsync</c> to fetch the next page.
/// </summary>
public string? ContinuationToken { 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;
_selectedNode = null;
// Reset any prior search state so a reused dialog instance never renders
// stale results on re-open (and the search panel stays fully suppressed
// when ShowSearch is false).
_searchActive = false;
_searchQuery = "";
_searchResults = new();
_searchCapReached = false;
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(ToTreeNode).ToList();
StateHasChanged();
}
private static TreeNode ToTreeNode(BrowseNode c) =>
new(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren) { DataType = c.DataType, Source = c };
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();
BrowseNodeResult result;
try
{
result = await BrowseService.BrowseChildrenAsync(_runtimeSiteId, _runtimeConnectionName, node.NodeId);
}
finally
{
node.Loading = false;
}
if (result.Failure is not null)
{
SetFailure(result.Failure);
return;
}
node.Children = result.Children.Select(ToTreeNode).ToList();
node.Truncated = result.Truncated;
node.ContinuationToken = result.ContinuationToken;
}
node.Expanded = true;
}
// Load-more / BrowseNext: fetch the next page under a truncated node,
// APPEND the children (preserve what's already shown + expanded), and refresh
// the stored token (null → exhausted → the button disappears).
private async Task LoadMoreAsync(TreeNode node)
{
if (string.IsNullOrEmpty(node.ContinuationToken)) return;
node.Loading = true;
StateHasChanged();
BrowseNodeResult result;
try
{
result = await BrowseService.BrowseChildrenAsync(
_runtimeSiteId, _runtimeConnectionName, node.NodeId, node.ContinuationToken);
}
finally
{
node.Loading = false;
}
if (result.Failure is not null)
{
SetFailure(result.Failure);
return;
}
node.Children ??= new();
node.Children.AddRange(result.Children.Select(ToTreeNode));
node.Truncated = result.Truncated;
node.ContinuationToken = result.ContinuationToken;
StateHasChanged();
}
private Task SearchOnEnter(KeyboardEventArgs e) =>
e.Key == "Enter" ? SearchAsync() : Task.CompletedTask;
// Address-space search: a blank query clears the panel; otherwise run
// the bounded recursive search and render matches as a flat selectable list.
private async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(_searchQuery))
{
_searchActive = false;
_searchResults = new();
_searchCapReached = false;
_failure = null;
_failureMessage = "";
StateHasChanged();
return;
}
var result = await BrowseService.SearchAsync(
_runtimeSiteId, _runtimeConnectionName, _searchQuery.Trim(),
maxDepth: 6, maxResults: 100);
if (result.Failure is not null)
{
SetFailure(result.Failure);
return;
}
_failure = null;
_searchActive = true;
_searchResults = result.Matches.ToList();
_searchCapReached = result.CapReached;
StateHasChanged();
}
// Search result click → SAME selection mechanism the tree uses: set the
// selected node id (and mirror it into the manual field) so the footer
// Select button confirms and OnSelected fires with this node id.
private void SelectBrowseNode(BrowseNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node;
}
private void Select(TreeNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node.Source;
}
// Task 17: map each BrowseFailureKind to a friendly UI message. Messages are
// protocol-agnostic (the dialog serves every browsable protocol — OPC UA,
// MxGateway, …). The raw failure.Message is surfaced verbatim for ServerError
// (which carries the underlying protocol SDK's own error text), for
// NotBrowsable when the adapter supplied a reason (e.g. a gateway build that
// lacks the browse RPC), 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 => "Connection not connected — retry shortly or use manual entry.",
BrowseFailureKind.NotBrowsable => string.IsNullOrWhiteSpace(failure.Message)
? "This connection does not support browsing."
: failure.Message,
BrowseFailureKind.Timeout => "Browse timed out — the server may be slow. Try again or enter the node id manually.",
BrowseFailureKind.ServerError => $"Server error: {failure.Message}",
_ => failure.Message
};
StateHasChanged();
}
private Task RetryRootLoad() => LoadRootAsync();
private void UseManual()
{
_selectedNodeId = _manualNodeId.Trim();
_selectedNode = null;
}
private async Task Confirm()
{
_isVisible = false;
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
{
await OnSelected.InvokeAsync(_selectedNodeId!);
if (OnNodeSelected.HasDelegate)
await OnNodeSelected.InvokeAsync(
_selectedNode ?? new BrowseNode(_selectedNodeId!, _selectedNodeId!, BrowseNodeClass.Variable, HasChildren: false));
}
}
private async Task Cancel()
{
_isVisible = false;
await OnCancelled.InvokeAsync();
}
}