Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
T
Joseph Doherty 0f1f8ad8fa fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver
deleted in /raw but not yet deployed - still running, still publishing -
vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set,
olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is
older than 60 s. Tests cover unconfigured+fresh kept (positive control),
unconfigured+stale evicted, configured+stale kept, and the deploy hand-off.

M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds
default (Galaxy 30, OpcUaClient 15, ...) when the key is absent.
M2: the budget never undercuts the config's root timeoutMs (+1 s).
M5: RawBrowseModal compares the open generation captured before the await,
so a close-then-reopen mid-open closes the stale session.
M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver
node may still be starting) instead of stopping for good.
M7: /scripts source fetch failure shows in the dialog, not the error boundary.
M8: the export modal maps only its preview rows; the endpoint writes via
Results.Text (no second byte[] copy).
M9: /hosts config queries are ordered, so SequenceEqual change detection holds.
N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults,
N5 equipment-import help text now describes RFC 4180 quoting.
H3: MonacoEditor documents that themes are page-global.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 18:21:46 -04:00

878 lines
44 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 Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@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
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@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&hellip;</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 &amp; 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">
<div class="d-flex align-items-center gap-2">
<span class="chip chip-ok">Browser open</span>
@* An observation window ACCUMULATES — the session keeps recording every
message after the tree was first rendered — but the tree itself loads
exactly once, when it is created. Without this the operator sees the
t=0 snapshot forever: on MQTT the first render is usually empty (a
topic that has not published yet is invisible) and on Sparkplug it is
almost always empty, because births are never retained. Re-keying the
tree re-runs its root load against the SAME session, so nothing
reconnects and nothing already observed is lost. *@
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Re-read what this session has observed since it opened"
@onclick="RefreshTree">
Refresh
</button>
</div>
<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 @key="_treeGeneration" SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync"
OnNodeSelectedWithPath="OnScopeNodeSelected" />
@if (_canRebirth && _canOperate)
{
@* Sparkplug only: births are never retained, so a quiet-but-healthy plant
shows an EMPTY tree until one lands. This is the only way to fill it on
demand — and the only browse action that publishes to the live broker,
hence the explicit confirm step. *@
<div class="border rounded p-2 mt-2 bg-body-tertiary">
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small">
<strong>Request rebirth</strong>
<span class="text-muted">
&mdash; asks edge nodes to republish their metric set, so this tree fills
without waiting for a natural birth.
</span>
</div>
@if (!_rebirthConfirming)
{
<button type="button" class="btn btn-outline-warning btn-sm text-nowrap"
disabled="@(_rebirthTarget is null || _rebirthBusy)"
@onclick="BeginRebirth">
Request rebirth&hellip;
</button>
}
</div>
@if (_rebirthTarget is null)
{
@* Gitea #514: the tree is BIRTH-driven, and births are never retained. On a
quiet plant — every edge node birthed long before this window opened and is
now publishing only NDATA/DDATA — the tree is empty, so a tree-selected
scope can never be armed and the one action that would fill the tree is
unreachable. Naming an edge node directly breaks that cycle. Deliberately
an EDGE NODE rather than a group broadcast: a group rebirth is a
self-inflicted broker storm on a large plant. *@
<div class="small text-muted mt-1">
Click a group, edge node, device or metric in the tree above to choose the
scope &mdash; or, if the tree is empty because every edge node birthed
before this window opened, name one directly:
</div>
<div class="input-group input-group-sm mt-1">
<span class="input-group-text">Edge node</span>
<input class="form-control mono" placeholder="group/edgeNode"
aria-label="Sparkplug edge-node scope"
value="@_manualScope"
@oninput="@(e => _manualScope = e.Value?.ToString() ?? "")" />
<button type="button" class="btn btn-outline-secondary"
disabled="@(ManualScopeError is not null)"
@onclick="ApplyManualScope">
Use this scope
</button>
</div>
@if (!string.IsNullOrWhiteSpace(_manualScope) && ManualScopeError is { } manualErr)
{
<div class="small text-danger mt-1">@manualErr</div>
}
}
else
{
<div class="small mt-1">
Scope: <strong>@_rebirthTarget.Kind</strong>
<code class="mono ms-1">@_rebirthTarget.Scope</code>
</div>
<div class="small text-muted">@_rebirthTarget.Detail</div>
}
@if (_rebirthConfirming && _rebirthTarget is not null)
{
<div class="alert alert-warning py-2 mt-2 mb-0">
<div class="small">
This <strong>publishes to the live broker</strong>: a Sparkplug
rebirth-request command addressed to @_rebirthTarget.Kind
(<code class="mono">@_rebirthTarget.Scope</code>).
@if (_rebirthTarget.IsGroup)
{
<text>
Every edge node observed in this group is addressed; a group with
more than @MqttGroupRebirthNodeCap observed edge nodes is refused
outright, and nothing is published.
</text>
}
</div>
<div class="btn-group btn-group-sm mt-2" role="group">
<button type="button" class="btn btn-warning btn-sm"
disabled="@_rebirthBusy" @onclick="ConfirmRebirthAsync">
@if (_rebirthBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Publish rebirth request
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
disabled="@_rebirthBusy" @onclick="CancelRebirth">
Cancel
</button>
</div>
</div>
}
@if (_rebirthOutcome is not null)
{
<div class="small text-success mt-1">@_rebirthOutcome</div>
}
@if (_rebirthError is not null)
{
<div class="alert alert-danger py-2 mt-2 mb-0 small">@_rebirthError</div>
}
</div>
}
else if (_rebirthExpired && _canOperate)
{
@* Gitea #510: the affordance is retired rather than left to fail on click. It is
drawn from a capability probed at browse-open, and sessions are reaped for
idleness — so a modal left open past the reap used to offer an action whose
token no longer resolved. Say what happened instead of silently vanishing. *@
<div class="alert alert-warning py-2 mt-2 mb-0 small">
This browse session expired while idle, so a rebirth can no longer be requested
through it. Close and re-open the browser to request one.
</div>
}
<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">
<div class="btn-group" role="group">
<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>
</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>Mirrors <c>MqttBrowseSession.MaxGroupRebirthNodes</c> for the confirm-step wording. The
/// session owns the rule and refuses whole past it; this is the operator-facing number, and a drift
/// only ever makes the warning less precise, never the refusal wrong.</summary>
private const int MqttGroupRebirthNodeCap = 32;
/// <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
/// <summary>
/// Identifies WHICH open a post-await write belongs to. <see cref="_open"/> is a bool and cannot
/// tell two opens apart, and Blazor does not serialize <c>OnParametersSetAsync</c> against its own
/// outstanding task — so a close-reopen onto another device overlaps the first open's resolve, and
/// last-completion-wins would point "Connect &amp; browse" at a controller nobody is looking at.
/// </summary>
private int _openGeneration;
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;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private string? _openError;
private bool _createGroups;
private bool _busy;
private List<string> _commitErrors = new();
/// <summary>
/// Bumped to force a fresh <c>DriverBrowseTree</c> against the same session — see the Refresh
/// button's remarks. Seeded from the session so re-opening a modal always starts a new generation.
/// </summary>
private int _treeGeneration;
// Request-rebirth affordance (MQTT/Sparkplug only). _canOperate is defence in depth — the real gate
// is server-side in BrowserSessionService.RequestRebirthAsync, which fails closed.
private bool _canOperate;
private bool _canRebirth;
private RebirthTarget? _rebirthTarget;
private bool _rebirthConfirming;
private bool _rebirthBusy;
private string? _rebirthOutcome;
private string? _rebirthError;
/// <summary>
/// Set when a re-probe finds the session's rebirth capability gone (#510), so the retired
/// affordance can be explained rather than silently disappearing.
/// </summary>
private bool _rebirthExpired;
/// <summary>
/// Operator-typed <c>{group}/{edgeNode}</c> rebirth scope, for the birth-driven-tree deadlock
/// (#514). Seeded with the driver's configured group so only the edge node has to be typed.
/// </summary>
private string _manualScope = "";
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = result.Succeeded;
}
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_open = false;
return;
}
if (_open) { return; }
_open = true;
await ResetAndResolveAsync();
}
private async Task ResetAndResolveAsync()
{
var generation = ++_openGeneration;
_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;
ResetRebirthState();
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
_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);
// A superseded open must never install its device under the displayed modal — see
// _openGeneration. Every post-await write below is behind this check.
if (IsStale(generation)) { return; }
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 (IsStale(generation)) { return; } // same race, second await: this one retargets the commit
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)
{
// A superseded open that FAILS is the same defect wearing the catch block: its diagnostic
// names a device the operator is no longer looking at, and it would disable browsing for the
// one they are.
if (!IsStale(generation))
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
}
}
finally
{
// The tail state belongs to an open too: `return`ing out of the try above still runs this, so
// an unguarded finally would drop the LIVE open's spinner mid-resolve and render the
// half-reset panel underneath it.
if (!IsStale(generation))
{
_resolving = false;
StateHasChanged();
}
}
}
/// <summary>
/// Whether the open that captured <paramref name="generation"/> has been superseded — by a later
/// open, or by the modal being closed outright (a close raises no new generation, so
/// <see cref="Visible"/> is the half that catches it).
/// </summary>
/// <param name="generation">The <see cref="_openGeneration"/> value captured when the open started.</param>
/// <returns><see langword="true"/> when no state may be written for that open.</returns>
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
private async Task OpenBrowseAsync()
{
_opening = true;
_openError = null;
// Captured BEFORE the await: a close-then-reopen while this open is in flight leaves Visible true
// again, so only the generation tells this open's result apart from the new modal's (review r0 M5).
var generation = _openGeneration;
StateHasChanged();
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
// Host disposed, modal closed, or modal re-opened mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)) { return; }
if (result.Ok)
{
_token = result.Token;
// Capability, not authorization: a plain-MQTT window (and every non-MQTT browser) has no
// re-announce action at all, so the affordance must not be drawn for it. Re-probed per
// click as well — see RebirthStillAvailable (#510).
_canRebirth = BrowserService.CanRequestRebirth(_token);
_rebirthExpired = false;
// Only the edge node should have to be typed (#514): the group is the driver's entire
// subscription scope, so it is already in the config this browser was opened with.
if (_canRebirth && _manualScope.Length == 0 && TryReadConfiguredGroupId() is { } gid)
{
_manualScope = gid + "/";
}
}
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;
IReadOnlyDictionary<string, string>? addressFields = null;
string? descriptor = 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;
// The STRUCTURED address, for a driver whose binding is a tuple (MQTT/Sparkplug). Never
// reconstructed from the node id: a metric name may contain '/', so the id cannot be
// split back into (group, edgeNode, device?, metric) — see RawBrowseCommitMapper.
addressFields = attrs[0].AddressFields;
// The descriptor line, so a commit refusal can say WHY a leaf is unbindable (G2-F2).
descriptor = attrs[0].SecurityClass;
}
}
catch
{
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
// For a tuple-addressed driver that also means "no address", which CommitAsync refuses in words.
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields, descriptor);
// A metric is also a legal rebirth scope (it resolves up to its owning edge node), so a toggle
// moves the scope the same way a folder click does.
SetRebirthTarget(sel.Leaf, sel.FolderPath);
}
/// <summary>Tracks the tree click that defines the Request-rebirth scope (folders and metrics alike).</summary>
private void OnScopeNodeSelected(BrowseLeafSelection sel) => SetRebirthTarget(sel.Leaf, sel.FolderPath);
/// <summary>
/// Re-derives the rebirth scope from the clicked node, and — because the scope changed — drops any
/// pending confirmation. A confirm step that survived a selection change would publish to a target
/// the operator is no longer looking at.
/// </summary>
private void SetRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
if (!_canRebirth) { return; }
var target = DescribeRebirthTarget(node, ancestorPath);
if (_rebirthTarget?.Scope == target.Scope) { return; }
_rebirthTarget = target;
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>
/// Names what a clicked node means as a rebirth scope. The DEPTH comes from the captured ancestor
/// path, not from splitting the node id — the id is the session's own key and the AdminUI must not
/// reverse-engineer its shape.
/// </summary>
/// <param name="node">The clicked browse node; its NodeId is passed to the session verbatim.</param>
/// <param name="ancestorPath">The node's ancestor display names, root→parent.</param>
/// <returns>The described target.</returns>
private static RebirthTarget DescribeRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
var depth = ancestorPath.Count;
var name = node.DisplayName;
// Sparkplug has no device- or metric-scoped rebirth: an NCMD addresses an EDGE NODE, so a
// deeper selection resolves upward. Say so, rather than letting the operator assume the
// narrower scope they clicked.
if (node.Kind == BrowseNodeKind.Leaf && depth >= 2)
{
return new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the metric '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false);
}
return depth switch
{
0 => new RebirthTarget(
node.NodeId,
$"group {name}",
$"Every edge node observed in group '{name}' is asked to republish.",
IsGroup: true),
1 => new RebirthTarget(
node.NodeId,
$"edge node {name}",
$"Edge node '{name}' in group '{ancestorPath[0]}' republishes its whole metric set.",
IsGroup: false),
2 => new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the device '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false),
_ => new RebirthTarget(node.NodeId, "the selected node", "", IsGroup: false),
};
}
/// <summary>Arms the confirm step. Publishing is deliberately two clicks — it reaches a live plant broker.</summary>
private void BeginRebirth()
{
_rebirthOutcome = null;
_rebirthError = null;
// #510 — the capability was probed once at browse-open; re-probe before offering to act on it.
if (!RebirthStillAvailable()) { return; }
_rebirthConfirming = true;
}
/// <summary>
/// Re-probes the open session's rebirth capability, retiring the affordance when it has gone
/// (Gitea #510).
/// </summary>
/// <returns><see langword="true"/> when a rebirth can still be requested through this session.</returns>
/// <remarks>
/// <c>CanRequestRebirth</c> is a cheap, non-throwing, no-I/O probe that is false for a reaped or
/// unknown token, so re-running it per click costs nothing. It is a <b>capability</b> probe, not
/// an authorization check — the <c>DriverOperator</c> gate stays server-side on
/// <c>RequestRebirthAsync</c> and fails closed there, and <c>_canOperate</c> remains the
/// defence-in-depth half of the render condition.
/// </remarks>
private bool RebirthStillAvailable()
{
if (BrowserService.CanRequestRebirth(_token)) { return true; }
_canRebirth = false;
_rebirthExpired = true;
ResetRebirthState();
return false;
}
/// <summary>
/// Describes why <see cref="_manualScope"/> is not a usable edge-node scope, or <c>null</c> when
/// it is. Mirrors the session's own <c>{group}/{edgeNode}</c> rule (Gitea #514).
/// </summary>
/// <remarks>
/// Advisory only: <c>MqttBrowseSession.ResolveRebirthTargets</c> re-validates and refuses an
/// unresolvable scope <b>before publishing anything</b>, so this exists to keep the operator from
/// arming a confirm step that cannot succeed — not as the gate. A group-only scope is rejected on
/// purpose: the session would fan out to every observed edge node, and on a tree that is empty
/// (the case this input exists for) there are none to fan to.
/// </remarks>
private string? ManualScopeError => SparkplugRebirthScope.Describe(_manualScope);
/// <summary>Adopts the typed <c>{group}/{edgeNode}</c> as the rebirth scope (Gitea #514).</summary>
private void ApplyManualScope()
{
if (!RebirthStillAvailable()) { return; }
if (!SparkplugRebirthScope.IsUsable(_manualScope)) { return; }
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(_manualScope);
_rebirthTarget = new RebirthTarget(
$"{groupId}/{edgeNodeId}",
$"edge node {edgeNodeId}",
$"Edge node '{edgeNodeId}' in group '{groupId}' republishes its whole metric set. "
+ "This node was named directly, so it need not have appeared in the tree.",
IsGroup: false);
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>Disarms the confirm step without publishing anything.</summary>
private void CancelRebirth() => _rebirthConfirming = false;
/// <summary>
/// The one browse action that publishes. Exceptions are SHOWN, not swallowed: a refusal (an
/// over-wide group scope, a missing DriverOperator grant, an unresolvable scope) is exactly what
/// the operator needs to read, and nothing was published in any of those cases.
/// </summary>
private async Task ConfirmRebirthAsync()
{
if (_rebirthTarget is null) { return; }
// #510 again, at the last moment before publishing: the reap can land between arming the confirm
// step and clicking it, and this is the one path that reaches a live broker.
if (!RebirthStillAvailable()) { return; }
_rebirthBusy = true;
_rebirthOutcome = null;
_rebirthError = null;
StateHasChanged();
var token = _token;
var target = _rebirthTarget;
try
{
var published = await BrowserService.RequestRebirthAsync(token, target.Scope, default);
// The session's RequestRebirthAsync returns only once the births it asked for have arrived and
// gone quiet (bounded by the window), so the tree can be re-read now instead of telling the
// operator to re-expand it later (G2-F3). Skipped if the session or modal went away meanwhile.
var refreshed = token == _token && Visible && !_disposed;
if (refreshed)
{
// Re-key the tree against the SAME session (as the Refresh button does). The armed scope is
// dropped — its node may not be in the rebuilt tree — but the outcome line is kept.
_treeGeneration++;
_rebirthTarget = null;
}
_rebirthOutcome =
$"Rebirth requested for {target.Kind} — {published} command"
+ (published == 1 ? "" : "s")
+ (refreshed
? " published. The tree was refreshed with the births that arrived; press Refresh if more appear later."
: " published.");
_rebirthConfirming = false;
}
catch (Exception ex)
{
_rebirthError = ex.Message;
}
finally
{
_rebirthBusy = false;
StateHasChanged();
}
}
/// <summary>
/// Re-reads the root of the open session's observed tree. Only the tree is rebuilt: the session,
/// its broker connection and everything it has recorded are untouched, so this is strictly a
/// re-render and never re-observes from scratch.
/// </summary>
/// <remarks>
/// The tag <b>selection</b> is deliberately kept — <c>_selectedIds</c> is keyed by browse node id
/// and a refresh does not renumber anything, so an operator who ticked leaves, waited for more
/// of the plant to appear, and refreshed does not silently lose the ticks. The rebirth scope IS
/// cleared, because the node it pointed at may no longer be in the rebuilt tree and a stale
/// armed scope is exactly the thing that panel's two-click confirm exists to prevent.
/// </remarks>
private void RefreshTree()
{
_treeGeneration++;
ResetRebirthState();
}
/// <summary>
/// Reads the driver's configured Sparkplug group id out of the merged config, or <c>null</c> when
/// it is absent or the blob is unreadable. Purely a convenience seed for <see cref="_manualScope"/>
/// — never a gate, so any failure is simply "no seed".
/// </summary>
/// <returns>The configured group id, or <c>null</c>.</returns>
private string? TryReadConfiguredGroupId()
{
try
{
// Case-insensitive: the merged blob is assembled from the driver + device configs and this
// is a display convenience, not a binding — it must not depend on which one won.
var root = System.Text.Json.Nodes.JsonNode.Parse(_mergedConfig)?.AsObject();
var sparkplug = root?
.FirstOrDefault(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase))
.Value?.AsObject();
var groupId = sparkplug?
.FirstOrDefault(p => string.Equals(p.Key, "GroupId", StringComparison.OrdinalIgnoreCase))
.Value?.GetValue<string>();
return string.IsNullOrWhiteSpace(groupId) ? null : groupId.Trim();
}
catch
{
return null;
}
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{
_rebirthTarget = null;
_rebirthConfirming = false;
_rebirthBusy = false;
_rebirthOutcome = null;
_rebirthError = null;
}
private async Task CommitAsync()
{
_busy = true;
_commitErrors = new();
StateHasChanged();
try
{
// Refuse a selection the driver could not bind, IN WORDS — for a tuple-addressed driver
// (MQTT/Sparkplug) a leaf whose attribute lookup returned nothing carries no address, and
// committing it would produce a tag that deploys clean and then reports BadNodeIdUnknown
// forever with nothing to point at.
_commitErrors = _selected.Values
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor))
.Where(e => e is not null)
.Select(e => e!)
.ToList();
if (_commitErrors.Count > 0) { return; }
var selected = _selected.Values.ToList();
var rows = selected
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
.ToList();
// A leaf name is coerced into a legal RawPath segment (#509), and that coercion is not
// injective — 'A/B' and 'A_B' both land on 'A_B'. Refuse the colliding SET by name here:
// letting it through would silently merge two distinct metrics into one tag bound to
// whichever address survived, which is worse than the outright refusal #509 replaced.
_commitErrors = RawBrowseCommitMapper
.DescribeNameCollisions(selected.Select(s => s.Name).ToList(), rows)
.ToList();
if (_commitErrors.Count > 0) { return; }
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;
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
ResetRebirthState();
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()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
/// <param name="AddressFields">The structured address the browse session stated for this leaf, for a
/// driver whose binding is a tuple rather than the NodeId itself; null for every other driver.</param>
private sealed record SelectedLeaf(
string NodeId,
string Name,
string? DriverDataType,
IReadOnlyList<string> FolderPath,
IReadOnlyDictionary<string, string>? AddressFields = null,
string? Descriptor = null);
/// <summary>A described Request-rebirth scope: what gets published, and to what.</summary>
/// <param name="Scope">The browse node id handed to the session verbatim.</param>
/// <param name="Kind">What the scope resolves to, in operator words (e.g. "edge node EdgeA").</param>
/// <param name="Detail">The one-line consequence, spelled out before the confirm step.</param>
/// <param name="IsGroup">True for a whole-group fan-out — the bounded, all-or-nothing case.</param>
private sealed record RebirthTarget(string Scope, string Kind, string Detail, bool IsGroup);
}