diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index e546f315..f0afcb8f 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -172,11 +172,47 @@ during the window is invisible — so manual entry remains the escape hatch.
Bounds: 50 000 nodes (then `⚠ Observation truncated`), 1024-char topics / 256-char segments (then
`⚠ Some topics were too long`), 512-byte payload inspection. Sessions are reaped after 2 minutes idle.
-`SparkplugB` browse throws `NotSupportedException`.
+
+**Sparkplug B browse serves a different tree from the same passive window**: observed NBIRTH/DBIRTH
+certificates are decoded into `Group → EdgeNode → [Device] → Metric` (a birth is the only Sparkplug
+message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.
The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway
(`SupportsOnlineDiscovery` is `false`).
+### Browse-commit writes the address the factory reads
+
+A committed leaf's address is a **descriptor**, not a single reference string, so
+`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
+emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
+`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
+
+**It is never parsed out of the browse node id.** A Sparkplug node id is
+`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
+(`Node Control/Rebirth`), so splitting the id cannot recover the tuple. Which keys the session emits
+is also how the mapper learns the shape — the driver *type* reaching it is just `Mqtt`, and the mode
+lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is
+refused **at commit, in words**, rather than committed as a tag that deploys clean and then reports
+`BadNodeIdUnknown` forever.
+
+### Request rebirth (Sparkplug only)
+
+Births are never retained, so a healthy but quiet plant shows an **empty** picker tree until one
+lands. The `Request rebirth` affordance in the browse modal is the way to fill it on demand: it
+publishes a Sparkplug rebirth-request NCMD (`Node Control/Rebirth`) to the selected scope — the **one
+and only** browse action that publishes anything.
+
+- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
+ node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
+ beneath it and is refused **whole** past 32, publishing nothing.
+- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
+ its consequence; changing the selection disarms it.
+- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
+ (fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy,
+ over-wide group, unresolvable scope — is **shown**, not swallowed.
+- Offered only for a Sparkplug session (`IRebirthCapableBrowseSession.RebirthAvailable`); a Plain
+ MQTT window publishes nothing, ever.
+
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs
index c7d05e6e..44f20c91 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs
@@ -29,9 +29,31 @@ public enum BrowseNodeKind
/// AlarmExtension primitive). The picker pre-fills a default native-alarm alarm object
/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware
/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce.
+///
+/// The structured address this leaf binds by, as TagConfig key → value in the
+/// driver's own key vocabulary, for a driver whose address is a tuple rather than a single
+/// reference string. Null (the default) for every driver whose address IS the
+/// — nothing changes for them.
+///
+/// It exists because a tuple cannot be recovered from an id string in general. MQTT/Sparkplug
+/// is the case in point: a browse node id is
+/// {group}/{node}[/{device}]::{metric}, and a metric name may itself contain /
+/// (Node Control/Rebirth) — so the session keeps the decomposition it already has and
+/// states it here, rather than the AdminUI's browse-commit mapper re-deriving it by
+/// splitting the id and hoping. Same discipline as the v3 address space carrying
+/// AddressSpaceRealm explicitly instead of parsing it out of a NodeId.
+///
+///
+/// The producer states the binding shape too, by which keys it emits — so a consumer
+/// never has to infer a driver's sub-mode from the tree's geometry. Consumers must read the
+/// keys they know and ignore the rest; this is a description, not a config blob to splat into
+/// a persisted TagConfig.
+///
+///
public sealed record AttributeInfo(
string Name,
string DriverDataType,
bool IsArray,
string SecurityClass,
- bool IsAlarm = false);
+ bool IsAlarm = false,
+ IReadOnlyDictionary? AddressFields = null);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
index 258beeb3..d31ecc40 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
@@ -61,6 +61,25 @@ public interface IBrowseSession : IAsyncDisposable
///
public interface IRebirthCapableBrowseSession : IBrowseSession
{
+ ///
+ /// Whether this particular session can actually re-announce — the runtime half of the
+ /// type question, and the one a UI must ask before offering the affordance.
+ ///
+ ///
+ /// One session class may serve several protocol shapes: the MQTT browser opens the same session
+ /// type for Plain and for Sparkplug B, and a Plain MQTT window publishes nothing, ever —
+ /// there is no plain-MQTT re-announce to offer. Implementing the interface therefore means "this
+ /// session type may re-announce"; this property means "this instance will". A UI gating on the
+ /// type alone would draw a button that can only ever throw.
+ ///
+ /// This is not an authorization signal — see the interface remarks. False here hides
+ /// the affordance; the caller still authorizes before invoking
+ /// , and the implementation still refuses a call it cannot
+ /// serve.
+ ///
+ ///
+ bool RebirthAvailable { get; }
+
///
/// Asks the addressed remote peer(s) to re-announce themselves.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
index be3b909b..cb240b42 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
@@ -213,6 +213,14 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
///
internal MqttMode Mode { get; }
+ ///
+ ///
+ /// Sparkplug only. A plain-MQTT window has no re-announce action at all — see
+ /// , which refuses one — so the picker must not draw the
+ /// affordance for it.
+ ///
+ public bool RebirthAvailable => Mode == MqttMode.SparkplugB;
+
///
public Guid Token { get; } = Guid.NewGuid();
@@ -301,11 +309,26 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
if (node.Metric is { } metric)
{
- attrs = [DescribeMetric(metric)];
+ attrs = [DescribeMetric(metric, node.Scope)];
}
else if (node.Observed is { } observed)
{
- attrs = [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)];
+ attrs =
+ [
+ new AttributeInfo(
+ node.Segment,
+ observed.DataType.ToString(),
+ IsArray: false,
+ observed.Snippet,
+ // Plain mode's address IS the node id, but it is stated rather than left implicit
+ // for the same reason the Sparkplug tuple is: the commit mapper reads the address
+ // off the fields, never off the id, and "which keys are present" is how it learns
+ // which of the driver's two binding shapes this leaf is.
+ AddressFields: new Dictionary(StringComparer.Ordinal)
+ {
+ [MqttTagConfigKeys.Topic] = node.Path,
+ }),
+ ];
}
}
@@ -317,8 +340,10 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// Projects one observed Sparkplug birth metric into the picker's attribute row.
///
/// The metric as its birth certificate declared it.
+ /// The (group, edgeNode, device?) identity stamped on the metric's node.
/// The attribute row.
///
+ ///
/// returns for
/// the five v1-unsupported datatypes (Unknown, DataSet, Template,
/// PropertySet, PropertySetList), and that null is passed through as the Sparkplug
@@ -326,8 +351,18 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// , so the AdminUI's browse-commit mapper falls back to its
/// declared default instead of silently binding a metric this driver cannot serve — and the
/// descriptor line says so in words.
+ ///
+ ///
+ /// is what makes a browse-committed Sparkplug tag
+ /// bindable at all. The tag's address is the (group, edgeNode, device?, metric) tuple,
+ /// and that tuple is not recoverable from the node id: a metric name may contain /
+ /// (and, off an arbitrary broker, ::), which is exactly why 's
+ /// remarks say nothing re-parses it. So the decomposition this session already holds — stamped on
+ /// the node when the birth was decoded — is handed over verbatim, keyed by
+ /// , the same names MqttTagDefinitionFactory reads.
+ ///
///
- private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric)
+ private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric, SparkplugScope? scope)
{
var mapped = metric.DataType?.ToDriverDataType();
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
@@ -340,7 +375,36 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
metric.Name,
mapped?.ToString() ?? sparkplugName,
metric.DataType?.IsSparkplugArray() ?? false,
- descriptor.ToString());
+ descriptor.ToString(),
+ AddressFields: BuildMetricAddressFields(metric.Name, scope));
+ }
+
+ ///
+ /// Builds the Sparkplug binding tuple a browse-commit writes into the tag's TagConfig.
+ ///
+ /// The metric's birth-declared name.
+ /// The metric node's stamped identity, or .
+ ///
+ /// The address fields, or when the node carries no scope (which a
+ /// Sparkplug metric node never does — every one is created with the scope its birth parsed —
+ /// so a null here is "the producer cannot state the address", never "the address is empty").
+ ///
+ private static IReadOnlyDictionary? BuildMetricAddressFields(
+ string metricName, SparkplugScope? scope)
+ {
+ if (scope?.EdgeNodeId is not { } edgeNodeId) return null;
+
+ var fields = new Dictionary(StringComparer.Ordinal)
+ {
+ [MqttTagConfigKeys.GroupId] = scope.GroupId,
+ [MqttTagConfigKeys.EdgeNodeId] = edgeNodeId,
+ [MqttTagConfigKeys.MetricName] = metricName,
+ };
+
+ // Omitted, not blank, for a node-level metric: the factory normalises "" to absent anyway, but
+ // an emitted empty device id would read as "a device whose id is empty" to anything else looking.
+ if (scope.DeviceId is { } deviceId) fields[MqttTagConfigKeys.DeviceId] = deviceId;
+ return fields;
}
///
@@ -489,13 +553,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
return;
}
- var scope = new SparkplugScope(groupId, edgeNodeId);
+ // Two scopes, and the difference is load-bearing: the edge node's own scope has no device, and
+ // stamping the device one on it would make an NBIRTH metric claim a device it does not belong to.
+ var edgeScope = new SparkplugScope(groupId, edgeNodeId);
+ var ownerScope = deviceId is null ? edgeScope : edgeScope with { DeviceId = deviceId };
var group = GetOrCreate(_roots, groupId, groupId, BrowseNodeKind.Folder, new SparkplugScope(groupId, null));
if (group is null) return;
var edgePath = groupId + "/" + edgeNodeId;
- var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, scope);
+ var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, edgeScope);
if (edge is null) return;
var owner = edge;
@@ -503,7 +570,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
if (deviceId is not null)
{
ownerPath = edgePath + "/" + deviceId;
- owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, scope);
+ owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, ownerScope);
if (owner is null) return;
}
@@ -525,7 +592,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
continue;
}
- var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, scope);
+ var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, ownerScope);
if (node is null) return; // node cap tripped
node.RecordMetric(new SparkplugMetricInfo(name, metric.DataType, parsed.Type, metric.Alias));
@@ -956,7 +1023,13 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// The owning edge-node id, or on a group node (which addresses every
/// edge node beneath it).
///
- private sealed record SparkplugScope(string GroupId, string? EdgeNodeId);
+ ///
+ /// The owning device id, or when the node belongs to the edge node
+ /// itself. Carried for the address (a tag's binding tuple), not for rebirth — Sparkplug
+ /// has no device-scoped rebirth, so reads only the pair
+ /// above.
+ ///
+ private sealed record SparkplugScope(string GroupId, string? EdgeNodeId, string? DeviceId = null);
/// One metric, exactly as a birth certificate declared it. No payload bytes are retained.
/// The metric name — the tag's stable binding key across rebirths.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs
new file mode 100644
index 00000000..4a11784b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs
@@ -0,0 +1,40 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// The TagConfig JSON key names that make up an MQTT tag's address, in the two shapes
+/// the driver binds by: a single (Plain) or the
+/// ///
+/// tuple (Sparkplug B).
+///
+///
+///
+/// These exist because the address is produced in one place and consumed in another, and the
+/// two used to agree only by coincidence: the AdminUI browse-commit mapper writes the blob,
+/// reads it, and a key-name drift between them is not a
+/// compile error — it is a tag that deploys clean, resolves to nothing, and reports
+/// BadNodeIdUnknown at runtime with no authoring-time signal at all. Single-sourcing
+/// the names makes that drift impossible rather than merely unlikely.
+///
+///
+/// Address keys only. The behavioural keys (payloadFormat, jsonPath,
+/// dataType, qos, retainSeed) are deliberately not here — nothing produces
+/// them from a browse, so they carry no cross-component drift risk.
+///
+///
+public static class MqttTagConfigKeys
+{
+ /// The concrete MQTT topic a Plain-mode tag subscribes to.
+ public const string Topic = "topic";
+
+ /// The Sparkplug group id — the first segment of the tag's binding tuple.
+ public const string GroupId = "groupId";
+
+ /// The Sparkplug edge-node id.
+ public const string EdgeNodeId = "edgeNodeId";
+
+ /// The Sparkplug device id; absent for a metric published by the edge node itself.
+ public const string DeviceId = "deviceId";
+
+ /// The Sparkplug metric's stable name — the tag's binding key across rebirths.
+ public const string MetricName = "metricName";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
index 239d84ab..ecdcf121 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
@@ -77,7 +77,7 @@ public static class MqttTagDefinitionFactory
// topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
// to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
- var topic = ReadString(root, "topic");
+ var topic = ReadString(root, MqttTagConfigKeys.Topic);
if (string.IsNullOrWhiteSpace(topic)) return false;
// Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
@@ -154,9 +154,9 @@ public static class MqttTagDefinitionFactory
// The binding tuple. Without all three of these there is nothing a birth certificate could
// ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
// rather than a definition that can never resolve and reports nothing about why.
- var groupId = ReadString(root, "groupId");
- var edgeNodeId = ReadString(root, "edgeNodeId");
- var metricName = ReadString(root, "metricName");
+ var groupId = ReadString(root, MqttTagConfigKeys.GroupId);
+ var edgeNodeId = ReadString(root, MqttTagConfigKeys.EdgeNodeId);
+ var metricName = ReadString(root, MqttTagConfigKeys.MetricName);
if (string.IsNullOrWhiteSpace(groupId)
|| string.IsNullOrWhiteSpace(edgeNodeId)
|| string.IsNullOrWhiteSpace(metricName))
@@ -166,7 +166,7 @@ public static class MqttTagDefinitionFactory
// Optional: a node-level metric has no device segment. Blank normalises to absent so
// "deviceId":"" and an omitted key describe the same scope rather than two.
- var deviceId = ReadString(root, "deviceId");
+ var deviceId = ReadString(root, MqttTagConfigKeys.DeviceId);
if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;
// Strict, but only when present — see the remarks.
@@ -180,7 +180,7 @@ public static class MqttTagDefinitionFactory
Name: rawPath,
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
// them, and a retyped blob's leftovers must not change what the tag means.
- Topic: ReadString(root, "topic"),
+ Topic: ReadString(root, MqttTagConfigKeys.Topic),
PayloadFormat: MqttPayloadFormat.Json,
JsonPath: RootJsonPath,
DataType: dataType,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
index da01f21c..4af233fc 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
@@ -140,6 +140,10 @@ public sealed class BrowserSessionService(
return published;
}
+ ///
+ public bool CanRequestRebirth(Guid token) =>
+ registry.TryGet(token, out var session) && session is IRebirthCapableBrowseSession { RebirthAvailable: true };
+
///
public async Task CloseAsync(Guid token)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
index 0aa9dc65..0a70f888 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
@@ -71,6 +71,21 @@ public interface IBrowserSessionService
/// This session's driver has no re-announce action.
Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
+ ///
+ /// True when the open session behind can actually re-announce — i.e.
+ /// is worth offering. Cheap (no I/O); never throws; false for
+ /// an unknown/reaped token.
+ ///
+ /// Registry handle for the open browse session.
+ /// True when the picker should draw a Request-rebirth affordance.
+ ///
+ /// A capability probe, not an authorization check — the DriverOperator gate lives on
+ /// and fails closed there. The picker gates on both: this
+ /// decides whether the action exists at all (a plain-MQTT window has none), the policy decides
+ /// whether this operator may fire it.
+ ///
+ bool CanRequestRebirth(Guid token);
+
/// Removes the session from the registry and disposes it. No-op for unknown tokens.
/// Registry handle for the browse session to close.
/// A task that represents the asynchronous operation.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
index 5437eff1..bdaec81b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
@@ -34,6 +34,12 @@
/// Fired when the user clicks a leaf (or any node — caller decides what to do with it).
[Parameter] public EventCallback OnNodeSelected { get; set; }
+ /// The same click as , plus the node's captured ancestor-folder
+ /// path (root→parent) — which is how a caller learns a node's DEPTH, and therefore what kind of thing
+ /// it is in a driver whose tree levels mean different things (MQTT/Sparkplug: group / edge node /
+ /// device). Both callbacks fire; wiring only the first is the existing behaviour.
+ [Parameter] public EventCallback OnNodeSelectedWithPath { get; set; }
+
/// When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
/// false preserves the single-select address-picker behavior.
@@ -93,10 +99,12 @@
finally { item.Loading = false; StateHasChanged(); }
}
- private async Task SelectAsync(TreeItem item)
+ private async Task SelectAsync(TreeItem item, IReadOnlyList ancestorPath)
{
_selectedNodeIdLocal = item.Node.NodeId;
await OnNodeSelected.InvokeAsync(item.Node);
+ await OnNodeSelectedWithPath.InvokeAsync(
+ new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList ancestorPath)
@@ -142,7 +150,7 @@
}
else
{
- SelectAsync(item))" @onclick:preventDefault
+ SelectAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
index a3ba3e70..c0ec5ca4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
@@ -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)
{
@@ -77,7 +81,89 @@
+ 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. *@
+