1f43449942
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal against the merged Driver+Device config and commits selected browse leaves as raw Tag rows under the target, via IRawTreeService.ImportTagsAsync. - RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser → universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled), merged config from LoadMergedProbeConfigAsync, multi-select over DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror, commit via ImportTagsAsync. - RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→ OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/ attributeRef), target-group + mirror path combine. Unit-tested (39 tests). - DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path callback), default off preserves single-select pickers. - RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
340 lines
14 KiB
Plaintext
340 lines
14 KiB
Plaintext
@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the
|
|
merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in
|
|
multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via
|
|
IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed
|
|
TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the
|
|
captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit
|
|
raises OnSaved so the host refreshes the tree.
|
|
|
|
Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy)
|
|
wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports
|
|
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
|
|
with a tooltip. *@
|
|
@implements IAsyncDisposable
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
|
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
|
@inject IBrowserSessionService BrowserService
|
|
@inject IRawTreeService Svc
|
|
@inject RawTagCsvExportReader ExportReader
|
|
|
|
@if (Visible)
|
|
{
|
|
<div class="modal-backdrop fade show" style="display:block"></div>
|
|
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
|
<div class="modal-dialog modal-lg" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
Browse device <span class="text-muted small">(@_driverType)</span>
|
|
</h5>
|
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
@if (_resolving)
|
|
{
|
|
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading device config…</div>
|
|
}
|
|
else if (!_canBrowse)
|
|
{
|
|
<div class="alert alert-secondary mb-0" title="@_disabledReason">
|
|
<strong>Browsing unavailable.</strong>
|
|
<div class="small">@_disabledReason</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-2 small text-muted">
|
|
Committing under: <code class="mono">@TargetLabel()</code>
|
|
</div>
|
|
|
|
@if (_token == Guid.Empty)
|
|
{
|
|
<div class="d-flex align-items-center gap-2">
|
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
|
disabled="@_opening" @onclick="OpenBrowseAsync">
|
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Connect & browse
|
|
</button>
|
|
@if (_openError is not null)
|
|
{
|
|
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<span class="chip chip-ok">Browser open</span>
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
|
|
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
|
|
<label class="form-check-label small" for="raw-browse-mirror">
|
|
Create matching tag-groups (mirror browse folders)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
|
|
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
|
|
|
|
<div class="mt-2 small">
|
|
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
|
|
</div>
|
|
}
|
|
|
|
@if (_commitErrors.Count > 0)
|
|
{
|
|
<div class="alert alert-warning py-2 mt-2 mb-0">
|
|
<div class="small mb-1">Nothing was committed — fix these and retry:</div>
|
|
<ul class="mb-0 small">
|
|
@foreach (var err in _commitErrors)
|
|
{
|
|
<li>@err</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
|
<button type="button" class="btn btn-primary" @onclick="CommitAsync"
|
|
disabled="@(_busy || _selected.Count == 0)">
|
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Add @_selected.Count tag@(_selected.Count == 1 ? "" : "s")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
/// <summary>Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a
|
|
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
|
|
private const string DefaultDataType = "Double";
|
|
|
|
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
|
|
[Parameter] public bool Visible { get; set; }
|
|
|
|
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
|
|
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
|
|
|
/// <summary>The device the browsed tags are committed under.</summary>
|
|
[Parameter] public string DeviceId { get; set; } = "";
|
|
|
|
/// <summary>The target tag group (its device-relative path is prepended to every committed tag), or null
|
|
/// to commit directly under the device.</summary>
|
|
[Parameter] public string? TagGroupId { get; set; }
|
|
|
|
/// <summary>The owning device's driver type (informational header; the live gate + config come from the
|
|
/// merged probe config).</summary>
|
|
[Parameter] public string DriverType { get; set; } = "";
|
|
|
|
/// <summary>Raised after a successful commit so the host can refresh the affected subtree.</summary>
|
|
[Parameter] public EventCallback OnSaved { get; set; }
|
|
|
|
private bool _open; // guards re-seeding on unrelated re-renders
|
|
private bool _resolving;
|
|
private bool _canBrowse;
|
|
private string? _disabledReason;
|
|
|
|
private string _driverType = "";
|
|
private string _mergedConfig = "{}";
|
|
private string _effectiveDeviceId = "";
|
|
private string? _groupPrefix;
|
|
|
|
private Guid _token = Guid.Empty;
|
|
private bool _opening;
|
|
private string? _openError;
|
|
|
|
private bool _createGroups;
|
|
private bool _busy;
|
|
private List<string> _commitErrors = new();
|
|
|
|
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (!Visible)
|
|
{
|
|
_open = false;
|
|
return;
|
|
}
|
|
if (_open) { return; }
|
|
_open = true;
|
|
await ResetAndResolveAsync();
|
|
}
|
|
|
|
private async Task ResetAndResolveAsync()
|
|
{
|
|
_token = Guid.Empty;
|
|
_opening = false;
|
|
_openError = null;
|
|
_createGroups = false;
|
|
_busy = false;
|
|
_commitErrors = new();
|
|
_selected.Clear();
|
|
_selectedIds.Clear();
|
|
_driverType = DriverType;
|
|
_mergedConfig = "{}";
|
|
_effectiveDeviceId = DeviceId;
|
|
_groupPrefix = null;
|
|
_canBrowse = false;
|
|
_disabledReason = null;
|
|
|
|
_resolving = true;
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
|
|
// the merged blob (same merge the driver-probe/test-connect path uses).
|
|
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
|
|
if (merged is { } m)
|
|
{
|
|
_driverType = m.DriverType;
|
|
_mergedConfig = m.MergedConfigJson;
|
|
}
|
|
|
|
// Resolve the target group's device-relative path prefix (prepended to every committed tag).
|
|
if (!string.IsNullOrEmpty(TagGroupId))
|
|
{
|
|
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
|
if (ctx is { } c)
|
|
{
|
|
_groupPrefix = c.GroupPath;
|
|
if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; }
|
|
}
|
|
}
|
|
|
|
// Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config.
|
|
_canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig);
|
|
if (!_canBrowse)
|
|
{
|
|
_disabledReason =
|
|
$"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry).";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_canBrowse = false;
|
|
_disabledReason = $"Could not load device config: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_resolving = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task OpenBrowseAsync()
|
|
{
|
|
_opening = true;
|
|
_openError = null;
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
|
if (result.Ok) { _token = result.Token; }
|
|
else { _openError = result.Message; }
|
|
}
|
|
finally
|
|
{
|
|
_opening = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task OnLeafToggledAsync(BrowseLeafSelection sel)
|
|
{
|
|
var nodeId = sel.Leaf.NodeId;
|
|
if (_selectedIds.Contains(nodeId))
|
|
{
|
|
_selectedIds.Remove(nodeId);
|
|
_selected.Remove(nodeId);
|
|
return;
|
|
}
|
|
|
|
// Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal
|
|
// (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to
|
|
// the display name + the default data type.
|
|
string name = sel.Leaf.DisplayName;
|
|
string? driverDataType = null;
|
|
try
|
|
{
|
|
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
|
|
if (attrs.Count > 0)
|
|
{
|
|
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
|
|
driverDataType = attrs[0].DriverDataType;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
|
|
}
|
|
|
|
_selectedIds.Add(nodeId);
|
|
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
|
|
}
|
|
|
|
private async Task CommitAsync()
|
|
{
|
|
_busy = true;
|
|
_commitErrors = new();
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
var rows = _selected.Values
|
|
.Select(s => RawBrowseCommitMapper.MapLeaf(
|
|
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
|
|
_groupPrefix, s.FolderPath, _createGroups))
|
|
.ToList();
|
|
|
|
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
|
if (outcome.Errors.Count > 0)
|
|
{
|
|
_commitErrors = outcome.Errors.ToList();
|
|
return;
|
|
}
|
|
|
|
await OnSaved.InvokeAsync();
|
|
await CloseAsync();
|
|
}
|
|
finally
|
|
{
|
|
_busy = false;
|
|
}
|
|
}
|
|
|
|
private async Task CloseAsync()
|
|
{
|
|
var t = _token;
|
|
_token = Guid.Empty;
|
|
Visible = false;
|
|
_open = false;
|
|
await VisibleChanged.InvokeAsync(false);
|
|
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
|
|
}
|
|
|
|
private string TargetLabel()
|
|
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
|
|
|
|
private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
if (_token != Guid.Empty)
|
|
{
|
|
// Fire-and-forget — don't block circuit teardown on a slow remote.
|
|
_ = BrowserService.CloseAsync(_token);
|
|
}
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
|
|
}
|