0f1f8ad8fa
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
878 lines
44 KiB
Plaintext
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…</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">
|
|
<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">
|
|
— 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…
|
|
</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 — 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 & 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);
|
|
}
|