410 lines
17 KiB
Plaintext
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=<namespace-uri>;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=<uri>;…</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();
|
|
}
|
|
}
|