diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs
index c22b012be..2a1eb77d3 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs
@@ -65,6 +65,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// capability. Additive, never a state change. Same by-reference value-equality caveat as
/// .
///
+///
+/// The ClusterNode.NodeId of the node whose driver actor produced this snapshot (lmxopcua#847), or
+/// null from a node that predates the field / a harness with no role info. Both nodes of a redundant pair
+/// run the same driver instance, so DriverInstanceId alone is NOT a unique key: keyed by it, a driver
+/// Faulted on central-1 and Healthy on central-2 flipped between the two on every publish. Consumers that keep
+/// a last-value cache key by (DriverInstanceId, ReportingNodeId). Additive — an old central ignores it.
+///
public sealed record DriverHealthChanged(
string ClusterId,
string DriverInstanceId,
@@ -77,7 +84,8 @@ public sealed record DriverHealthChanged(
string? RediscoveryReason = null,
IReadOnlyList? HostStatuses = null,
SkippedTagReport? SkippedTags = null,
- IReadOnlyDictionary? Diagnostics = null)
+ IReadOnlyDictionary? Diagnostics = null,
+ string? ReportingNodeId = null)
{
///
/// DPS topic name. Both the runtime AkkaDriverHealthPublisher and the AdminUI
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto
index e2fdd7258..99a07325f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto
@@ -88,6 +88,10 @@ message DriverHealth {
// flag carries that bit - the same shape as has_host_statuses.
bool has_diagnostics = 13;
map diagnostics = 14;
+ // The ClusterNode.NodeId of the reporting node (lmxopcua#847). Both nodes of a redundant pair run the same
+ // driver instance, so central keys its last-value cache by (driver_instance_id, reporting_node_id). Additive:
+ // absent from an old node (central then falls back to the driver id alone).
+ optional string reporting_node_id = 15;
}
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
index 119529cf2..4fdf06d08 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
@@ -1,8 +1,8 @@
@page "/hosts"
@* Two views, top to bottom. (1) Akka cluster topology: each member's NodeId (host:port), roles,
leader status — the cluster-membership view. (2) Driver instances: live driver-health grouped by
- cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
- are cluster-scoped (keyed per driver instance across the cluster, not per member). The section
+ cluster. Each health snapshot carries its reporting node (DriverHealthChanged.ReportingNodeId, #847), so the
+ rows are one per (driver instance, node) — a driver Faulted on one node and Healthy on its peer shows both. The section
reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@@ -152,6 +152,7 @@ else
- }
+
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
index 1f6cede61..eea3c3416 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
@@ -57,7 +57,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -154,8 +154,6 @@
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
index bfc641f4b..012243f61 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
@@ -121,7 +121,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -228,8 +228,6 @@
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
index 14fedd8ca..8ae418d34 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
@@ -42,7 +42,7 @@
Browser open
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -215,8 +215,6 @@
return $"{_tagName}.{_attributeName}";
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
index 02b79537b..823d9f864 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
@@ -40,7 +40,7 @@
Close
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -113,8 +113,6 @@
await CurrentAddressChanged.InvokeAsync(_built);
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
index 61b445f14..43264f4e1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
@@ -156,7 +156,7 @@ else
}
@* Same open-error chip the five sibling pickers use: abbreviated inline, full text on title. *@
- @if (_openError is not null) { @DisplayText.Abbreviate(_openError, 60) }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
index f5cab6a2d..50a1d1589 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
@@ -46,7 +46,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -133,8 +133,6 @@
await CurrentAddressChanged.InvokeAsync(_built);
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor
new file mode 100644
index 000000000..b0044f22e
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor
@@ -0,0 +1,20 @@
+@* A failed Test Connect / browse-open outcome (lmxopcua#847). The chip carries only a short status word; the
+ full refusal text is rendered READABLY under it — wrapping, normal case, selectable — instead of being cut
+ to a few characters inside the (uppercase, inline) chip with the real reason only in a hover tooltip. A
+ secret-reference refusal ("SEC…") was unreadable that way, and a tooltip cannot be copied from.
+ Renders as an inline column, so it drops into the existing d-flex rows beside the button unchanged. *@
+
+ @Label
+ @if (!string.IsNullOrWhiteSpace(Message))
+ {
+ @Message
+ }
+
+
+@code {
+ /// The short chip text (default Failed).
+ [Parameter] public string Label { get; set; } = "Failed";
+
+ /// The full refusal / failure message, shown untruncated under the chip.
+ [Parameter] public string? Message { get; set; }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor
new file mode 100644
index 000000000..f479efac4
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor
@@ -0,0 +1,13 @@
+@* The single save-error area under a driver/device form (lmxopcua#847) — see SaveRefusalNotices. *@
+@foreach (var message in SaveRefusalNotices.Messages(GateError, SaveError))
+{
+
@message
+}
+
+@code {
+ /// The live save-gate refusal for the current form state, or null.
+ [Parameter] public string? GateError { get; set; }
+
+ /// The last save attempt's error, or null.
+ [Parameter] public string? SaveError { get; set; }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs
new file mode 100644
index 000000000..54ec54b3c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs
@@ -0,0 +1,36 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+///
+/// The one error area a driver/device modal shows under its form (lmxopcua#847). The modals used to render the
+/// live refusal AND the last save's error as two independent boxes, and
+/// a Save that the gate refused copied the gate text into the save error — so a refused save (e.g. a Galaxy
+/// credential reference outside its namespace) stacked two identical boxes, and a server refusal from an
+/// earlier save could stay standing beside a newer one.
+///
+public static class SaveRefusalNotices
+{
+ /// The distinct messages to render, the live gate refusal first.
+ /// The live save-gate refusal for the current form state, or null.
+ /// The last save attempt's error, or null.
+ /// Zero, one or two messages — never the same text twice, and never a save error that merely
+ /// repeats (contains, or is contained by) the gate refusal already shown.
+ public static IReadOnlyList Messages(string? gateError, string? saveError)
+ {
+ var gate = string.IsNullOrWhiteSpace(gateError) ? null : gateError.Trim();
+ var save = string.IsNullOrWhiteSpace(saveError) ? null : saveError.Trim();
+
+ if (gate is null)
+ {
+ return save is null ? [] : [save];
+ }
+
+ if (save is null
+ || save.Contains(gate, StringComparison.Ordinal)
+ || gate.Contains(save, StringComparison.Ordinal))
+ {
+ return [gate];
+ }
+
+ return [gate, save];
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
index 90a3fedf1..b3ff3b55d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
@@ -8,9 +8,11 @@
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the
- deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@
+ deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value.
+ The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings
+ — read from the in-process health store — still render. See CredentialReferenceBannerModel. *@
-@if (_findings.Count > 0 || _error is not null)
+@if (_model.Visible)
{
@@ -22,61 +24,93 @@
- @if (_error is not null)
+ @if (_model.ScanError is not null)
{
-
@_error
+
+ @_model.ScanError Stored and deployed configs were not checked
+ @(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "")
+ — running-driver findings below are still live.
+
}
-
- @foreach (var f in _findings.Take(MaxRows))
- {
-
@if (row.Ok)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
index b553bce25..169377ad5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
@@ -113,9 +113,47 @@ public sealed class CredentialReferencePreflight(IDbContextFactory new CredentialReferenceFinding(
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
+ // Both nodes of a redundant pair run the same driver and report the same finding; the health store
+ // keeps one snapshot per (driver, node) since lmxopcua#847, so collapse the identical pair here.
+ .Distinct()
.ToList();
}
+///
+/// What the /raw credential banner renders (lmxopcua#847), composed from two INDEPENDENT sources: the
+/// ConfigDb scan (draft + deployed rows) and the live driver-health feed (running drivers' warn-only
+/// credential-compat:* resolutions). A failed ConfigDb scan must never hide the running findings — they
+/// come from the in-process health store and are exactly what is still true while the database is unreachable.
+///
+/// Draft / Deployed findings from the last successful scan (empty when it failed).
+/// Running-driver findings from the live health feed.
+/// The scan failure note, or null when the scan succeeded (or has not run).
+/// When the scan last ran.
+public sealed record CredentialReferenceBannerModel(
+ IReadOnlyList ScanFindings,
+ IReadOnlyList RunningFindings,
+ string? ScanError,
+ DateTime? ScannedAtUtc)
+{
+ /// Whether the banner renders at all.
+ public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null;
+
+ /// Composes the banner from the last scan result and the current driver-health snapshots.
+ /// The last preflight result.
+ /// The live driver-health snapshots.
+ /// The banner model.
+ public static CredentialReferenceBannerModel Compose(
+ CredentialReferencePreflightState.Snapshot scan,
+ IEnumerable healthSnapshots)
+ {
+ ArgumentNullException.ThrowIfNull(scan);
+ var running = CredentialReferencePreflight.RunningFindings(healthSnapshots ?? []);
+ // Running-source rows only ever come from the health feed; a scan result never carries them.
+ var scanned = scan.Findings.Where(f => f.Source != CredentialFindingSource.Running).ToList();
+ return new(scanned, running, scan.Error, scan.ScannedAtUtc);
+ }
+}
+
/// The last preflight result, shown by the AdminUI /raw credential banner.
public sealed class CredentialReferencePreflightState
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
index e659ae7d5..555ef0d73 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
@@ -58,6 +58,9 @@ public sealed record HostsDriverInstanceInfo(
/// when no instance matched. Fed to the row's expandable DriverStatusPanel. An
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
/// so telling the operator it is "not deployed" would contradict the evidence in front of them.
+/// The cluster node that reported this snapshot (lmxopcua#847); null from a node that predates
+/// the field. Both nodes of a redundant pair run the same driver instance, so the page shows one row per
+/// (driver, node) — a driver Faulted on one node and Healthy on the other used to flip between the two.
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
@@ -65,8 +68,12 @@ public sealed record HostsDriverRow(
IReadOnlyList? HostStatuses = null,
SkippedTagReport? SkippedTags = null,
bool Enabled = true,
- IReadOnlyDictionary? Diagnostics = null)
+ IReadOnlyDictionary? Diagnostics = null,
+ string? NodeId = null)
{
+ /// The row's render key: unique per (driver instance, reporting node).
+ public string RowKey => $"{DriverInstanceId}@{NodeId}";
+
/// Diagnostics as ordered key/value pairs for rendering; empty when there are none, in which
/// case the page renders nothing.
public IReadOnlyList> DiagnosticItems { get; } =
@@ -117,7 +124,8 @@ public sealed record HostsDriverRow(
///
/// Cluster identifier.
/// Configured nodes in this cluster, ordered by node id.
-/// Driver rows in this cluster, ordered by name then instance id.
+/// Driver rows in this cluster — one per (driver instance, reporting node) — ordered by
+/// name, then instance id, then node id.
public sealed record HostsClusterGroup(string ClusterId, IReadOnlyList Nodes, IReadOnlyList Drivers);
///
@@ -188,10 +196,12 @@ public static class HostsDriverView
s.HostStatuses,
s.SkippedTags,
instance?.Enabled ?? true,
- s.Diagnostics);
+ s.Diagnostics,
+ s.ReportingNodeId);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(d => d.NodeId ?? "", StringComparer.OrdinalIgnoreCase)
.ToList();
groups.Add(new HostsClusterGroup(clusterId, clusterNodes, clusterDrivers));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs
index 120c91e49..51918c5c5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs
@@ -15,14 +15,16 @@ public interface IDriverStatusSnapshotStore
/// The driver health snapshot to store.
void Upsert(DriverHealthChanged snapshot);
- /// Attempts to retrieve the last-known health snapshot for a driver instance.
+ /// Attempts to retrieve the most recently published health snapshot for a driver instance, across
+ /// every node reporting it (both nodes of a redundant pair run the same instance — lmxopcua#847; use
+ /// for the per-node view).
/// The driver instance's stable logical ID.
/// The stored snapshot, if found.
/// if a snapshot was found; otherwise .
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
- /// Returns a point-in-time snapshot of every driver instance's last-known health.
- /// A read-only collection of the last-known health snapshot for each driver instance.
+ /// Returns a point-in-time snapshot of every (driver instance, reporting node)'s last-known health.
+ /// One last-known snapshot per driver instance PER reporting node (lmxopcua#847).
IReadOnlyCollection GetAll();
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs
index 395fa6708..e6bba87d7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs
@@ -6,11 +6,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
///
/// Thread-safe in-memory implementation of .
-/// Keyed by ; last write wins.
+/// Keyed by (, );
+/// last write wins per key.
///
+///
+/// Keyed per reporting node since lmxopcua#847: both nodes of a redundant pair run the same driver instance,
+/// so a driver-id-only key made a driver Faulted on central-1 and Healthy on central-2 flip between the two on
+/// every publish. A snapshot from a node that predates ReportingNodeId keys under the empty node.
+///
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
{
- private readonly ConcurrentDictionary _byInstance = new();
+ private readonly ConcurrentDictionary<(string DriverInstanceId, string NodeId), DriverHealthChanged> _byInstance = new();
private readonly ILogger? _logger;
private readonly FailureLogThrottle _failureLog = new();
@@ -22,10 +28,12 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
/// Raised whenever a driver status snapshot is stored or updated.
public event Action? SnapshotChanged;
+ private static (string, string) KeyOf(DriverHealthChanged s) => (s.DriverInstanceId, s.ReportingNodeId ?? "");
+
///
public void Upsert(DriverHealthChanged snapshot)
{
- _byInstance[snapshot.DriverInstanceId] = snapshot;
+ _byInstance[KeyOf(snapshot)] = snapshot;
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one
// subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor).
SafeMulticast.Invoke(SnapshotChanged, snapshot,
@@ -36,11 +44,11 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
public int EvictUnconfiguredStale(IReadOnlySet configuredInstanceIds, DateTime olderThanUtc)
{
var removed = 0;
- foreach (var (id, snapshot) in _byInstance)
+ foreach (var (key, snapshot) in _byInstance)
{
- if (configuredInstanceIds.Contains(id) || snapshot.PublishedUtc >= olderThanUtc) continue;
+ if (configuredInstanceIds.Contains(key.DriverInstanceId) || snapshot.PublishedUtc >= olderThanUtc) continue;
// Remove only the exact snapshot inspected: a fresh publish that raced in is kept.
- if (_byInstance.TryRemove(new KeyValuePair(id, snapshot))) removed++;
+ if (_byInstance.TryRemove(new KeyValuePair<(string, string), DriverHealthChanged>(key, snapshot))) removed++;
}
return removed;
@@ -48,7 +56,17 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
///
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
- => _byInstance.TryGetValue(driverInstanceId, out snapshot!);
+ {
+ DriverHealthChanged? latest = null;
+ foreach (var (key, s) in _byInstance)
+ {
+ if (!string.Equals(key.DriverInstanceId, driverInstanceId, StringComparison.Ordinal)) continue;
+ if (latest is null || s.PublishedUtc > latest.PublishedUtc) latest = s;
+ }
+
+ snapshot = latest!;
+ return latest is not null;
+ }
///
public IReadOnlyCollection GetAll() => _byInstance.Values.ToArray();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs
index 8ddb93541..a4dc9acf9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs
@@ -128,19 +128,24 @@ public static class RawTagCsvMapper
var typedUsed = new List();
var overrodeFallback = new List();
+ // Echoed onto an INVALID row too (lmxopcua#847): the grid's Name/Group columns used to render blank for
+ // a rejected row, so the operator could not tell which tag failed. The raw cell is shown as typed.
+ var rawName = Cell("Name");
+ var rawGroup = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath")) ?? "";
+
try
{
// --- Common identity/access fields ---
var name = Cell("Name");
if (RawPaths.ValidateSegment(name) is { } nameErr)
{
- return Invalid(rowNumber, cells, $"Name: {nameErr}");
+ return Invalid(rowNumber, cells, rawName, rawGroup, $"Name: {nameErr}");
}
var dataType = Cell("DataType");
if (dataType.Length == 0)
{
- return Invalid(rowNumber, cells, "DataType is required.");
+ return Invalid(rowNumber, cells, rawName, rawGroup, "DataType is required.");
}
var accessRaw = Cell("AccessLevel");
@@ -161,7 +166,7 @@ public static class RawTagCsvMapper
var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid);
if (!baseValid)
{
- return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON.");
+ return Invalid(rowNumber, cells, rawName, rawGroup, "TagConfigJson is not well-formed JSON.");
}
if (map is not null)
@@ -219,7 +224,7 @@ public static class RawTagCsvMapper
var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw);
if (n < 0)
{
- return Invalid(rowNumber, cells, "ArrayLength must not be negative.");
+ return Invalid(rowNumber, cells, rawName, rawGroup, "ArrayLength must not be negative.");
}
arrayLength = (uint)n;
@@ -243,14 +248,14 @@ public static class RawTagCsvMapper
// claim the tag is already present.
if (TagConfigValidator.Validate(driverType, json) is { } configError)
{
- return Invalid(rowNumber, cells, $"TagConfig: {configError}");
+ return Invalid(rowNumber, cells, rawName, rawGroup, $"TagConfig: {configError}");
}
// The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit
// (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK.
if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError)
{
- return Invalid(rowNumber, cells, rowError);
+ return Invalid(rowNumber, cells, rawName, rawGroup, rowError);
}
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
@@ -271,13 +276,15 @@ public static class RawTagCsvMapper
}
catch (FormatException ex)
{
- return Invalid(rowNumber, cells, ex.Message);
+ return Invalid(rowNumber, cells, rawName, rawGroup, ex.Message);
}
}
- private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new()
+ private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string rawName, string rawGroup, string error) => new()
{
RowNumber = rowNumber,
+ Name = rawName,
+ EffectiveGroupPath = rawGroup,
Cells = cells,
Headers = Array.Empty(),
Error = error,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
index c975196c4..09afcf4b7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
@@ -131,7 +131,8 @@ public static class TelemetryProtoMapCentral
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
HostStatuses: ToHostStatuses(msg),
SkippedTags: ToSkippedTags(msg),
- Diagnostics: msg.HasDiagnostics ? new Dictionary(msg.Diagnostics) : null);
+ Diagnostics: msg.HasDiagnostics ? new Dictionary(msg.Diagnostics) : null,
+ ReportingNodeId: msg.HasReportingNodeId ? msg.ReportingNodeId : null);
}
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
index 772ea7ea0..a5d82f35e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
@@ -132,6 +132,8 @@ public static class TelemetryProtoMapNode
msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value);
if (e.RediscoveryReason is not null)
msg.RediscoveryReason = e.RediscoveryReason;
+ if (e.ReportingNodeId is not null)
+ msg.ReportingNodeId = e.ReportingNodeId;
// Presence flag first — an empty repeated field cannot say whether the driver has a probe at all.
if (e.HostStatuses is not null)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs
index bef91b671..188729f1f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs
@@ -1,5 +1,6 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
@@ -19,14 +20,19 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
private readonly ActorSystem _system;
private readonly ITelemetryLocalHub _hub;
+ private readonly string? _localNodeId;
/// Initializes a new instance of .
/// The Akka actor system used to resolve the DPS mediator.
/// The node-local live-telemetry hub each snapshot is also emitted into (Phase 5).
- public AkkaDriverHealthPublisher(ActorSystem system, ITelemetryLocalHub hub)
+ /// This node's role info; its LocalNode stamps every snapshot's
+ /// (lmxopcua#847). Optional for harnesses.
+ public AkkaDriverHealthPublisher(ActorSystem system, ITelemetryLocalHub hub, IClusterRoleInfo? roleInfo = null)
{
_system = system;
_hub = hub;
+ var local = roleInfo?.LocalNode.Value;
+ _localNodeId = string.IsNullOrWhiteSpace(local) ? null : local;
}
///
@@ -53,7 +59,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
rediscoveryReason,
hostStatuses,
skippedTags,
- diagnostics);
+ diagnostics,
+ _localNodeId);
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
index 553f9b17f..8eeb5e81e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
@@ -184,4 +184,48 @@ public sealed class CredentialReferencePreflightTests
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
+
+ private static DriverHealthChanged LegacyRunning(string node) =>
+ new("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
+ Diagnostics: new Dictionary
+ {
+ [LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated",
+ },
+ ReportingNodeId: node);
+
+ [Fact]
+ public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847()
+ {
+ // lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running.
+ var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB.");
+
+ var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]);
+
+ model.Visible.ShouldBeTrue();
+ model.ScanError.ShouldNotBeNull();
+ model.ScanFindings.ShouldBeEmpty();
+ model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old");
+ }
+
+ [Fact]
+ public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847()
+ {
+ CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions(
+ m => m.Visible.ShouldBeTrue(),
+ m => m.RunningFindings.ShouldBeEmpty());
+ CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847()
+ {
+ var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p");
+ var model = CredentialReferenceBannerModel.Compose(
+ new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]);
+
+ model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft);
+ // Both nodes run the driver and report the same thing — one line, not two.
+ model.RunningFindings.ShouldHaveSingleItem();
+ model.ScanError.ShouldBeNull();
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs
index 410c5e53a..3c7e740fa 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs
@@ -56,4 +56,39 @@ public sealed class DriverStatusSnapshotStoreTests
count.ShouldBe(1);
}
+
+ [Fact]
+ public void Snapshots_from_two_nodes_for_one_driver_are_kept_side_by_side_847()
+ {
+ var store = new InMemoryDriverStatusSnapshotStore();
+ var t0 = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc);
+ store.Upsert(new DriverHealthChanged("MAIN", "drv", "Faulted", null, "x", 1, t0, ReportingNodeId: "central-1"));
+ store.Upsert(new DriverHealthChanged("MAIN", "drv", "Healthy", null, null, 0, t0.AddSeconds(1), ReportingNodeId: "central-2"));
+ // A re-publish from central-1 replaces only central-1's entry.
+ store.Upsert(new DriverHealthChanged("MAIN", "drv", "Faulted", null, "y", 2, t0.AddSeconds(2), ReportingNodeId: "central-1"));
+
+ var all = store.GetAll();
+ all.Count.ShouldBe(2);
+ all.Single(s => s.ReportingNodeId == "central-1").LastError.ShouldBe("y");
+ all.Single(s => s.ReportingNodeId == "central-2").State.ShouldBe("Healthy");
+
+ // TryGet (the per-driver panel / hub) answers the most recently published across nodes.
+ store.TryGet("drv", out var latest).ShouldBeTrue();
+ latest.ReportingNodeId.ShouldBe("central-1");
+ latest.LastError.ShouldBe("y");
+ store.TryGet("other", out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Eviction_removes_every_node_entry_of_an_unconfigured_stale_driver_847()
+ {
+ var store = new InMemoryDriverStatusSnapshotStore();
+ var old = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ store.Upsert(new DriverHealthChanged("MAIN", "gone", "Healthy", null, null, 0, old, ReportingNodeId: "a"));
+ store.Upsert(new DriverHealthChanged("MAIN", "gone", "Healthy", null, null, 0, old, ReportingNodeId: "b"));
+ store.Upsert(new DriverHealthChanged("MAIN", "kept", "Healthy", null, null, 0, old, ReportingNodeId: "a"));
+
+ store.EvictUnconfiguredStale(new HashSet { "kept" }, old.AddMinutes(1)).ShouldBe(2);
+ store.GetAll().ShouldHaveSingleItem().DriverInstanceId.ShouldBe("kept");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs
new file mode 100644
index 000000000..202b6ba38
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs
@@ -0,0 +1,86 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
+
+///
+/// lmxopcua#847: a refused driver/device save stacked two copies of the same error box (the live gate box plus
+/// the gate text copied into the save error), and a resubmit left the previous refusal standing.
+///
+[Trait("Category", "Unit")]
+public sealed class SaveRefusalNoticesTests
+{
+ [Fact]
+ public void Nothing_to_show_renders_nothing()
+ => SaveRefusalNotices.Messages(null, " ").ShouldBeEmpty();
+
+ [Fact]
+ public void The_same_refusal_from_gate_and_save_renders_once()
+ => SaveRefusalNotices.Messages("Credential refused.", "Credential refused.").ShouldBe(["Credential refused."]);
+
+ [Fact]
+ public void A_save_error_that_wraps_the_gate_text_renders_once()
+ => SaveRefusalNotices.Messages("Credential refused.", "Save failed: Credential refused.")
+ .ShouldBe(["Credential refused."]);
+
+ [Fact]
+ public void Distinct_gate_and_server_errors_both_render_gate_first()
+ => SaveRefusalNotices.Messages("Credential refused.", "Row was modified by someone else.")
+ .ShouldBe(["Credential refused.", "Row was modified by someone else."]);
+
+ [Fact]
+ public void A_server_error_alone_renders()
+ => SaveRefusalNotices.Messages(null, "Duplicate name.").ShouldBe(["Duplicate name."]);
+
+ [Theory]
+ [InlineData("DriverConfigModal.razor", "_driverConfigJson")]
+ [InlineData("DeviceModal.razor", "_deviceConfigJson")]
+ public void Both_modals_render_one_shared_error_area_and_reset_it_on_resubmit_and_edit(string file, string jsonField)
+ {
+ var src = AdminUiSource.Read("Components", "Shared", "Drivers", file);
+
+ src.ShouldContain("@_saveError");
+ // The gate refusal is no longer COPIED into _saveError (the source of the duplicate box).
+ src.ShouldNotContain($"_saveError = DriverConfigSaveGate.Validate(_driverType, {jsonField});");
+ src.ShouldContain("_saveError = null;\n if (DriverConfigSaveGate.Validate(");
+ // An edit clears a stale server refusal.
+ src.ShouldContain($"v => {{ {jsonField} = v; _saveError = null; }}");
+ }
+}
+
+///
+/// lmxopcua#847: Test Connect / browse-open refusals were cut down inside an uppercase chip ("SEC…") with the
+/// real reason only in a hover tooltip. Every site now renders the shared RefusalChip, which prints the
+/// full message under a short chip.
+///
+[Trait("Category", "Unit")]
+public sealed class RefusalChipWiringTests
+{
+ [Theory]
+ [InlineData("Drivers/DriverTestConnectButton.razor")]
+ [InlineData("Raw/RawBrowseModal.razor")]
+ [InlineData("Drivers/Pickers/AbCipAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/FOCASAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/GalaxyAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/OpcUaClientAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/SqlAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/TwinCATAddressPickerBody.razor")]
+ public void Refusal_sites_render_the_full_message_via_RefusalChip(string relative)
+ {
+ var src = AdminUiSource.Read(["Components", "Shared", .. relative.Split('/')]);
+ src.ShouldContain("@Message");
+ src.ShouldNotContain("Abbreviate");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
index 9a37c04ba..c069336ed 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
@@ -356,4 +356,35 @@ public sealed class HostsDriverViewTests
row.HasSkippedTags.ShouldBeFalse();
row.SkippedTagsTitle().ShouldBeEmpty();
}
+
+ [Fact]
+ public void A_driver_reported_by_two_nodes_yields_one_row_per_node_847()
+ {
+ // lmxopcua#847: both nodes of a redundant pair run the same driver; keyed by driver id alone the row
+ // flipped between central-1's Faulted and central-2's Healthy on every publish.
+ var snaps = new[]
+ {
+ new DriverHealthChanged("c", "drv", "Healthy", null, null, 0, When, ReportingNodeId: "central-2"),
+ new DriverHealthChanged("c", "drv", "Faulted", null, "boom", 1, When, ReportingNodeId: "central-1"),
+ };
+
+ var rows = HostsDriverView.Build(snaps, null, [Instance("drv", "c", "Line 1", "Modbus")])
+ .ShouldHaveSingleItem().Drivers;
+
+ rows.Count.ShouldBe(2);
+ rows.Select(r => r.NodeId).ShouldBe(["central-1", "central-2"]);
+ rows.Select(r => r.State).ShouldBe(["Faulted", "Healthy"]);
+ rows.Select(r => r.RowKey).Distinct().Count().ShouldBe(2);
+ rows.ShouldAllBe(r => r.Name == "Line 1");
+ }
+
+ [Fact]
+ public void Hosts_page_renders_a_node_column_and_keys_rows_by_driver_and_node_847()
+ {
+ var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
+ src.ShouldContain("@key=\"@($\"row-{d.RowKey}\")\"");
+ src.ShouldNotContain("@key=\"@($\"row-{d.DriverInstanceId}\")\"");
+ src.ShouldContain(">Node");
+ src.ShouldContain("@d.NodeId");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs
index b5a48a91a..3c8766d99 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs
@@ -47,6 +47,30 @@ public sealed class HeidenhainAuthoringUxTests
result.CanCommit.ShouldBeFalse();
}
+ [Fact]
+ public void Csv_preview_invalid_rows_echo_the_raw_name_and_group_847()
+ {
+ // lmxopcua#847: an invalid row rendered a BLANK Name in the review grid, so the operator could not tell
+ // which tag failed. Every rejection path must carry the raw Name/group cell as typed.
+ const string header = "Name,DataType,AccessLevel,TagGroupPath,TagConfigJson";
+ var exec = Json("""{"address":"Status/ExecutionState"}""");
+ var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain,
+ header + "\n" + string.Join("\n",
+ $"rw,Int32,ReadWrite,Spindle,{exec}", // row rule refusal
+ $"nodt,,Read,,{exec}", // DataType required
+ $"badjson,Int32,Read,,{Json("{not json")}",
+ $"bad/name,Int32,Read,,{exec}", // name segment refusal
+ $"badacc,Int32,Bogus,,{exec}"), // FormatException path
+ groupPathPrefix: "Axes");
+
+ result.Rows.Count.ShouldBe(5);
+ result.Rows.ShouldAllBe(r => !r.Ok);
+ result.Rows.Select(r => r.Name).ShouldBe(new[] { "rw", "nodt", "badjson", "bad/name", "badacc" });
+ result.Rows[0].EffectiveGroupPath.ShouldBe("Axes/Spindle");
+ result.Rows[1].EffectiveGroupPath.ShouldBe("Axes");
+ result.Rows.Select(r => r.RowNumber).ShouldBe(new[] { 1, 2, 3, 4, 5 });
+ }
+
[Fact]
public async Task Csv_preview_and_ImportTags_agree_row_for_row()
{