@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) { } @code { [Parameter] public string SiteId { get; set; } = ""; /// /// 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 DataConnectionManagerActor indexes its children by /// connection name (no id-keyed lookup at the site). /// [Parameter] public string ConnectionName { get; set; } = ""; [Parameter] public string? InitialNodeId { get; set; } [Parameter] public EventCallback OnSelected { get; set; } [Parameter] public EventCallback OnCancelled { get; set; } /// Additive: receives the full selected (incl. /// DataType) on confirm, alongside the existing string-id . /// Optional — existing callers that only wire OnSelected are unaffected. [Parameter] public EventCallback OnNodeSelected { get; set; } /// 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. [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 _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 _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; } /// /// Friendly DataType name for Variable nodes; null when /// not a Variable or the type read failed. Rendered as a muted badge. /// public string? DataType { get; init; } /// The originating BrowseNode, retained so selection can emit the /// full node (incl. DataType) via OnNodeSelected. public BrowseNode? Source { get; init; } public List? Children { get; set; } // null = not loaded yet public bool Expanded { get; set; } public bool Loading { get; set; } public bool Truncated { get; set; } /// /// 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 /// BrowseChildrenAsync to fetch the next page. /// 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(); } }