Merge branch 'feat/mqtt-sparkplug-driver'
# Conflicts: # src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj # src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
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
|
||||
@@ -18,6 +20,8 @@
|
||||
@inject IBrowserSessionService BrowserService
|
||||
@inject IRawTreeService Svc
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
@@ -66,7 +70,22 @@
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="chip chip-ok">Browser open</span>
|
||||
<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)" />
|
||||
@@ -76,8 +95,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
|
||||
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
|
||||
<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)
|
||||
{
|
||||
<div class="small text-muted mt-1">
|
||||
Click a group, edge node, device or metric in the tree above to choose the scope.
|
||||
</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="d-flex gap-2 mt-2">
|
||||
<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>
|
||||
}
|
||||
|
||||
<div class="mt-2 small">
|
||||
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
|
||||
@@ -116,6 +217,11 @@
|
||||
/// 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; }
|
||||
|
||||
@@ -154,9 +260,32 @@
|
||||
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;
|
||||
|
||||
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)
|
||||
@@ -185,6 +314,8 @@
|
||||
_groupPrefix = null;
|
||||
_canBrowse = false;
|
||||
_disabledReason = null;
|
||||
ResetRebirthState();
|
||||
_canRebirth = false;
|
||||
|
||||
_resolving = true;
|
||||
StateHasChanged();
|
||||
@@ -238,7 +369,13 @@
|
||||
try
|
||||
{
|
||||
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
||||
if (result.Ok) { _token = result.Token; }
|
||||
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.
|
||||
_canRebirth = BrowserService.CanRequestRebirth(_token);
|
||||
}
|
||||
else { _openError = result.Message; }
|
||||
}
|
||||
finally
|
||||
@@ -263,6 +400,7 @@
|
||||
// the display name + the default data type.
|
||||
string name = sel.Leaf.DisplayName;
|
||||
string? driverDataType = null;
|
||||
IReadOnlyDictionary<string, string>? addressFields = null;
|
||||
try
|
||||
{
|
||||
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
|
||||
@@ -270,15 +408,164 @@
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields);
|
||||
|
||||
// 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;
|
||||
_rebirthConfirming = true;
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
_rebirthBusy = true;
|
||||
_rebirthOutcome = null;
|
||||
_rebirthError = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var published = await BrowserService.RequestRebirthAsync(_token, _rebirthTarget.Scope, default);
|
||||
_rebirthOutcome =
|
||||
$"Rebirth requested for {_rebirthTarget.Kind} — {published} command"
|
||||
+ (published == 1 ? "" : "s")
|
||||
+ " published. Re-expand the tree in a few seconds to see the republished metrics.";
|
||||
_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>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()
|
||||
@@ -288,10 +575,21 @@
|
||||
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))
|
||||
.Where(e => e is not null)
|
||||
.Select(e => e!)
|
||||
.ToList();
|
||||
if (_commitErrors.Count > 0) { return; }
|
||||
|
||||
var rows = _selected.Values
|
||||
.Select(s => RawBrowseCommitMapper.MapLeaf(
|
||||
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
|
||||
_groupPrefix, s.FolderPath, _createGroups))
|
||||
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
|
||||
.ToList();
|
||||
|
||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||
@@ -314,6 +612,8 @@
|
||||
{
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
_canRebirth = false;
|
||||
ResetRebirthState();
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
@@ -335,5 +635,19 @@
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
("TwinCAT", DriverTypeNames.TwinCAT),
|
||||
("FOCAS", DriverTypeNames.FOCAS),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("MQTT", DriverTypeNames.Mqtt),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Sql", DriverTypeNames.Sql),
|
||||
("Calculation", "Calculation"),
|
||||
|
||||
Reference in New Issue
Block a user