Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser layering exception. No runtime driver behaviour changes. #513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key unconditionally. Sharper than the issue states: PayloadFormat is the one Plain field with a NON-NULLABLE typed backing, so the write did not preserve a retyped tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never had one. Now Plain-only; preserve-unknown-keys is pinned unchanged. #509 slashed Sparkplug metric names. The mapper already carried metricName verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment, which refused the batch whole — including Node Control/Rebirth, mandatory on every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address untouched. Coercion is not injective, so DescribeNameCollisions refuses a colliding set BY NAME: silently merging two metrics into one tag would be worse than the refusal it replaces. Applies to every driver, not just MQTT — the argument is about what a Name is, not about who produced it. The sanitiser is pinned against RawPaths.ValidateSegment rather than a copied charset. #510 stale rebirth affordance. _canRebirth was probed once at browse-open and sessions are reaped for idleness, so a modal left open offered an action whose token no longer resolved. Re-probed on arm AND on confirm (the reap can land between the two, and confirm is the one path that publishes to a live broker); the affordance retires with an explanation instead of vanishing or erroring. #514 rebirth on a quiet plant. The tree is birth-driven and births are never retained, so a plant that birthed before the window opened showed an empty tree — and the scope came only from a tree selection, so the one action that would fill the tree could not be armed. The session already accepted a bare {group}/{edgeNode} ("the prime rebirth target"); this adds the affordance. Edge-node scope only, not a group broadcast. The rule is extracted to SparkplugRebirthScope so it is testable — this repo has no bUnit — and is advisory: the session re-resolves and refuses before publishing. #512 .Browser -> .Driver layering. TWO extractions were needed, not the one the issue anticipated: it costed this as "the client-options builder", but the browse session decodes births itself, so moving only that would have left the reference exactly where it was. - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had already prescribed, against the issue's suggestion. - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec, RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes. Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards the graph both ways. Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the SAME violation, undocumented — the old comment blamed this browser for Core and was wrong. Filed separately rather than fixed in passing. Full suite green apart from the 7 known pre-existing failures (4 AbLegacy integration, compose profiles not up; 3 Host.IntegrationTests). Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -126,9 +126,34 @@
|
||||
|
||||
@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.
|
||||
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
|
||||
{
|
||||
@@ -179,6 +204,17 @@
|
||||
}
|
||||
</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.
|
||||
@@ -276,6 +312,18 @@
|
||||
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);
|
||||
|
||||
@@ -316,6 +364,8 @@
|
||||
_disabledReason = null;
|
||||
ResetRebirthState();
|
||||
_canRebirth = false;
|
||||
_rebirthExpired = false;
|
||||
_manualScope = "";
|
||||
|
||||
_resolving = true;
|
||||
StateHasChanged();
|
||||
@@ -373,8 +423,16 @@
|
||||
{
|
||||
_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-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; }
|
||||
}
|
||||
@@ -502,9 +560,64 @@
|
||||
{
|
||||
_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;
|
||||
|
||||
@@ -516,6 +629,9 @@
|
||||
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;
|
||||
@@ -558,6 +674,34 @@
|
||||
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()
|
||||
{
|
||||
@@ -586,12 +730,22 @@
|
||||
.ToList();
|
||||
if (_commitErrors.Count > 0) { return; }
|
||||
|
||||
var rows = _selected.Values
|
||||
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)
|
||||
{
|
||||
@@ -613,6 +767,8 @@
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
_canRebirth = false;
|
||||
_rebirthExpired = false;
|
||||
_manualScope = "";
|
||||
ResetRebirthState();
|
||||
Visible = false;
|
||||
_open = false;
|
||||
|
||||
Reference in New Issue
Block a user