fix(adminui): say why a Sparkplug array/unsupported metric cannot be committed (G2-F2)

Since R06:M-5 the Sparkplug browser states no address for a metric the
ingestor will not read and labels its descriptor 'not bindable', but the commit
refusal still said the attribute lookup returned nothing. RawBrowseModal keeps
the attribute's descriptor on the selection, and DescribeUncommittableLeaf
reports a 'cannot be bound in this version' message echoing it. A lookup that
really returned nothing keeps the old text. A test pins the marker to the
browser's own string.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 18:01:44 -04:00
parent fc3178cca8
commit 15bd409cdd
3 changed files with 88 additions and 6 deletions
@@ -503,6 +503,7 @@
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);
@@ -514,6 +515,8 @@
// 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
@@ -523,7 +526,7 @@
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields);
_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.
@@ -768,7 +771,7 @@
// 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))
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor))
.Where(e => e is not null)
.Select(e => e!)
.ToList();
@@ -843,7 +846,8 @@
string Name,
string? DriverDataType,
IReadOnlyList<string> FolderPath,
IReadOnlyDictionary<string, string>? AddressFields = null);
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>
@@ -361,6 +361,10 @@ public static class RawBrowseCommitMapper
TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName);
}
/// <summary>The phrase the MQTT/Sparkplug browser puts on the descriptor of a metric it refuses to
/// bind (<c>MqttBrowseSession.DescribeMetric</c>); matched case-insensitively.</summary>
public const string NotBindableMarker = "not bindable";
/// <summary>
/// Describes why a selected browse leaf cannot be committed as a working tag, or <c>null</c> when it
/// can. The AdminUI calls this before mapping so an unbindable selection fails <b>at commit, in
@@ -369,6 +373,9 @@ public static class RawBrowseCommitMapper
/// <param name="driverType">The owning device's driver type.</param>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <param name="descriptor">The leaf attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>),
/// when the lookup returned one. MQTT uses it to tell a deliberately unbindable Sparkplug metric
/// (<see cref="NotBindableMarker"/>) from a lookup that returned nothing.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks>
/// <para>
@@ -394,9 +401,10 @@ public static class RawBrowseCommitMapper
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary<string, string>? addressFields)
IReadOnlyDictionary<string, string>? addressFields,
string? descriptor = null)
{
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields, descriptor);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
@@ -404,13 +412,25 @@ public static class RawBrowseCommitMapper
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <param name="descriptor">The attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>), if the
/// lookup returned one.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
string browseName, IReadOnlyDictionary<string, string>? addressFields, string? descriptor)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
// G2-F2: the Sparkplug browser deliberately states no address for a metric the ingestor will not
// read (an *Array type, or a type with no driver mapping — R06:M-5), and says so on its descriptor
// line. That is a "not bindable in this version" refusal, not a failed lookup, so say which.
if (descriptor is not null && descriptor.Contains(NotBindableMarker, StringComparison.OrdinalIgnoreCase))
{
return $"'{browseName}' cannot be bound in this version: the Sparkplug browser reports it as "
+ $"\"{descriptor}\". Array metrics and metrics of an unsupported type are not read yet; "
+ "remove it from the selection.";
}
return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";