From 80cda8f227311d4b4340c52deddd713121620539 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:41:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20G=20=E2=80=94?= =?UTF-8?q?=20AbCip/TwinCAT/FOCAS=20picker=20bodies=20gain=20universal=20b?= =?UTF-8?q?rowse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14/15/16: each picker body keeps its manual builder and appends a DriverOperator-gated browse affordance (mirrors OpcUaClientAddressPickerBody) — Browse/Refresh/Close, DriverBrowseTree over the universal DiscoveryDriverBrowser session, leaf-only commit (OnNodeSelected fires for folders too), snapshot-at-open label, fire-and-forget close on dispose. _canBrowse evaluated once in OnInitializedAsync (CanBrowse constructs a throwaway driver). FOCAS commits the leaf FullName directly (the group/id builder can't reconstruct it). Editors pass DriverType + GetConfigJson through. Razor-live-verified at Task 18. --- ...overy-browser-implementation.md.tasks.json | 6 +- .../Pickers/AbCipAddressPickerBody.razor | 112 ++++++++++++++++- .../Pickers/FOCASAddressPickerBody.razor | 115 +++++++++++++++++- .../Pickers/TwinCATAddressPickerBody.razor | 111 ++++++++++++++++- .../Uns/TagEditors/AbCipTagConfigEditor.razor | 4 +- .../Uns/TagEditors/FocasTagConfigEditor.razor | 4 +- .../TagEditors/TwinCATTagConfigEditor.razor | 4 +- 7 files changed, 341 insertions(+), 15 deletions(-) diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json index ce5d48fd..4c3f0a60 100644 --- a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json +++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json @@ -15,9 +15,9 @@ {"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "completed", "blockedBy": [8], "parallelWith": [9]}, {"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "completed", "blockedBy": [11], "parallelWith": [10]}, {"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 2]}, - {"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]}, - {"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]}, - {"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]}, + {"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]}, + {"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]}, + {"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]}, {"id": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "pending", "blockedBy": [14, 15, 16]}, {"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]} ], diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor index 0492921e..6ac0f423 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor @@ -1,4 +1,14 @@ -@* Static AB CIP address builder: tag name + optional element index → tag[idx] or tag *@ +@* AB CIP address picker: + 1. Static tag name + optional element index → tag[idx] or tag (always available). + 2. (DriverOperator-gated, when the driver supports online discovery) Browse the controller + symbol tree captured by the universal DiscoveryDriverBrowser. *@ +@implements IAsyncDisposable +@using Microsoft.AspNetCore.Authorization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@inject IBrowserSessionService BrowserService +@inject AuthenticationStateProvider AuthState +@inject IAuthorizationService AuthorizationService
@@ -22,6 +32,42 @@
+@if (_canBrowse) +{ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + + } + @if (_openError is not null) { @TruncatedError() } +
+ + @if (_token != Guid.Empty) + { +
+
Snapshot taken at open/refresh time — click Refresh to re-read the controller.
+ +
+ } +} +
Result: @_built @@ -30,16 +76,63 @@ @code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } + /// Driver type used to open a universal browse session (defaults to AbCip). + [Parameter] public string DriverType { get; set; } = "AbCip"; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetConfigJson { get; set; } = () => "{}"; private string _tagName = ""; private int _elementIndex = 0; private bool _useIndex = false; private string _built = ""; - protected override void OnInitialized() + private Guid _token = Guid.Empty; + private bool _opening; + private bool _canBrowse; + private string? _openError; + + protected override async Task OnInitializedAsync() { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); + // CanBrowse constructs a throwaway driver — evaluate once, not per render. + _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}"); _built = Build(); - _ = CurrentAddressChanged.InvokeAsync(_built); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; _openError = null; StateHasChanged(); + try + { + var json = GetConfigJson() ?? "{}"; + var result = await BrowserService.OpenAsync(DriverType, json, default); + if (result.Ok) _token = result.Token; + else _openError = result.Message; + } + finally { _opening = false; StateHasChanged(); } + } + + private async Task RefreshBrowseAsync() + { + await CloseBrowseAsync(); + await OpenBrowseAsync(); + } + + private async Task CloseBrowseAsync() + { + var t = _token; _token = Guid.Empty; StateHasChanged(); + if (t != Guid.Empty) await BrowserService.CloseAsync(t); + } + + private async Task OnTreeSelectAsync(BrowseNode node) + { + // Folders fire the same callback — commit only on a leaf (the captured NodeId is the CIP tag path). + if (node.Kind != BrowseNodeKind.Leaf) return; + _tagName = node.NodeId; + _useIndex = false; + await OnChangedAsync(); } private async Task OnChangedAsync() @@ -54,4 +147,17 @@ return ""; return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName; } + + private string TruncatedError() => + _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError); + + public ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + // Fire-and-forget — don't block circuit teardown on a slow capture. + _ = BrowserService.CloseAsync(_token); + } + return ValueTask.CompletedTask; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor index 81c64ff3..1f1cfe2d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor @@ -1,5 +1,17 @@ -@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@ +@* FOCAS address picker: + 1. Static parameter group + parameter ID → axis:5 (always available). + 2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree + device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few + seconds longer than other drivers — its FixedTree cache fills post-connect and the browser + runs an UntilStable settle loop; the _opening spinner covers the wait. *@ +@implements IAsyncDisposable +@using Microsoft.AspNetCore.Authorization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@inject IBrowserSessionService BrowserService +@inject AuthenticationStateProvider AuthState +@inject IAuthorizationService AuthorizationService
@@ -18,6 +30,42 @@
+@if (_canBrowse) +{ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + + } + @if (_openError is not null) { @TruncatedError() } +
+ + @if (_token != Guid.Empty) + { +
+
Snapshot taken at open/refresh time — click Refresh to re-read the machine.
+ +
+ } +} +
Result: @_built @@ -26,15 +74,63 @@ @code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } + /// Driver type used to open a universal browse session (defaults to FOCAS). + [Parameter] public string DriverType { get; set; } = "FOCAS"; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetConfigJson { get; set; } = () => "{}"; private string _group = "axis"; private int _parameterId = 0; private string _built = ""; - protected override void OnInitialized() + private Guid _token = Guid.Empty; + private bool _opening; + private bool _canBrowse; + private string? _openError; + + protected override async Task OnInitializedAsync() { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); + // CanBrowse constructs a throwaway driver — evaluate once, not per render. + _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}"); _built = FocasAddressBuilder.Build(_group, _parameterId); - _ = CurrentAddressChanged.InvokeAsync(_built); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; _openError = null; StateHasChanged(); + try + { + var json = GetConfigJson() ?? "{}"; + var result = await BrowserService.OpenAsync(DriverType, json, default); + if (result.Ok) _token = result.Token; + else _openError = result.Message; + } + finally { _opening = false; StateHasChanged(); } + } + + private async Task RefreshBrowseAsync() + { + await CloseBrowseAsync(); + await OpenBrowseAsync(); + } + + private async Task CloseBrowseAsync() + { + var t = _token; _token = Guid.Empty; StateHasChanged(); + if (t != Guid.Empty) await BrowserService.CloseAsync(t); + } + + private async Task OnTreeSelectAsync(BrowseNode node) + { + // Folders fire the same callback — commit only on a leaf. The captured NodeId is the + // driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so + // commit it directly rather than through OnChangedAsync. + if (node.Kind != BrowseNodeKind.Leaf) return; + _built = node.NodeId; + await CurrentAddressChanged.InvokeAsync(_built); } private async Task OnChangedAsync() @@ -42,4 +138,17 @@ _built = FocasAddressBuilder.Build(_group, _parameterId); await CurrentAddressChanged.InvokeAsync(_built); } + + private string TruncatedError() => + _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError); + + public ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + // Fire-and-forget — don't block circuit teardown on a slow capture. + _ = BrowserService.CloseAsync(_token); + } + return ValueTask.CompletedTask; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor index c2e0e187..5c4152c3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor @@ -1,4 +1,14 @@ -@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@ +@* TwinCAT address picker: + 1. Static ADS variable name (free text) → verbatim (always available). + 2. (DriverOperator-gated, when the driver supports online discovery) Browse the ADS symbol + tree captured by the universal DiscoveryDriverBrowser. *@ +@implements IAsyncDisposable +@using Microsoft.AspNetCore.Authorization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@inject IBrowserSessionService BrowserService +@inject AuthenticationStateProvider AuthState +@inject IAuthorizationService AuthorizationService
@@ -11,6 +21,42 @@
+@if (_canBrowse) +{ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + + } + @if (_openError is not null) { @TruncatedError() } +
+ + @if (_token != Guid.Empty) + { +
+
Snapshot taken at open/refresh time — click Refresh to re-read the controller.
+ +
+ } +} +
Result: @_built @@ -19,14 +65,60 @@ @code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } + /// Driver type used to open a universal browse session (defaults to TwinCAT). + [Parameter] public string DriverType { get; set; } = "TwinCAT"; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetConfigJson { get; set; } = () => "{}"; private string _varName = ""; private string _built = ""; - protected override void OnInitialized() + private Guid _token = Guid.Empty; + private bool _opening; + private bool _canBrowse; + private string? _openError; + + protected override async Task OnInitializedAsync() { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); + // CanBrowse constructs a throwaway driver — evaluate once, not per render. + _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}"); _built = _varName; - _ = CurrentAddressChanged.InvokeAsync(_built); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; _openError = null; StateHasChanged(); + try + { + var json = GetConfigJson() ?? "{}"; + var result = await BrowserService.OpenAsync(DriverType, json, default); + if (result.Ok) _token = result.Token; + else _openError = result.Message; + } + finally { _opening = false; StateHasChanged(); } + } + + private async Task RefreshBrowseAsync() + { + await CloseBrowseAsync(); + await OpenBrowseAsync(); + } + + private async Task CloseBrowseAsync() + { + var t = _token; _token = Guid.Empty; StateHasChanged(); + if (t != Guid.Empty) await BrowserService.CloseAsync(t); + } + + private async Task OnTreeSelectAsync(BrowseNode node) + { + // Folders fire the same callback — commit only on a leaf (the captured NodeId is the ADS symbol path). + if (node.Kind != BrowseNodeKind.Leaf) return; + _varName = node.NodeId; + await OnChangedAsync(); } private async Task OnChangedAsync() @@ -34,4 +126,17 @@ _built = _varName; await CurrentAddressChanged.InvokeAsync(_built); } + + private string TruncatedError() => + _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError); + + public ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + // Fire-and-forget — don't block circuit teardown on a slow capture. + _ = BrowserService.CloseAsync(_token); + } + return ValueTask.CompletedTask; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor index 8c400946..066a1030 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor @@ -34,7 +34,9 @@ CurrentAddress="@_pickerAddress" OnPickAddress="@OnAddressPicked"> + CurrentAddressChanged="@((s) => _pickerAddress = s)" + DriverType="@DriverType" + GetConfigJson="@GetDriverConfigJson" /> } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor index bc114cd1..8911b438 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor @@ -32,7 +32,9 @@ CurrentAddress="@_pickerAddress" OnPickAddress="@OnAddressPicked"> + CurrentAddressChanged="@((s) => _pickerAddress = s)" + DriverType="@DriverType" + GetConfigJson="@GetDriverConfigJson" /> } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor index cb369480..1334e55f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor @@ -34,7 +34,9 @@ CurrentAddress="@_pickerAddress" OnPickAddress="@OnAddressPicked"> + CurrentAddressChanged="@((s) => _pickerAddress = s)" + DriverType="@DriverType" + GetConfigJson="@GetDriverConfigJson" /> }