gateRejections)
+ {
+ _skippedTags.BeginRebuild(_options.RawTags.Count);
+ foreach (var skip in _blobSkips) _skippedTags.Skip(skip.RawPath, skip.Reason);
+ foreach (var skip in gateRejections) _skippedTags.Skip(skip.RawPath, skip.Reason);
+ _skippedTags.Commit();
+ }
+
///
/// The driver-type string this driver registers under — , the single
/// source of truth the deploy pipeline stores in DriverInstance.DriverType and every dispatch
@@ -699,6 +732,7 @@ public sealed class SqlDriver
private void BuildTagTable()
{
var table = new Dictionary(_options.RawTags.Count, StringComparer.Ordinal);
+ var blobSkips = new List();
foreach (var entry in _options.RawTags)
{
try
@@ -706,9 +740,12 @@ public sealed class SqlDriver
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
table[entry.RawPath] = definition;
else
+ {
+ blobSkips.Add(new SkippedTag(entry.RawPath, "TagConfig did not map to a definition"));
_logger.LogWarning(
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
+ }
}
catch (Exception ex)
{
@@ -717,12 +754,19 @@ public sealed class SqlDriver
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
// branch above follows — rather than fault the driver over a single entry.
+ blobSkips.Add(new SkippedTag(entry.RawPath, $"TagConfig threw while parsing: {ex.GetType().Name}"));
_logger.LogError(
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
+ // The gate has not run yet, so the report covers blob-level skips only; ApplyCatalogGateAsync
+ // republishes it with its own rejections folded in. Publishing here rather than waiting means a
+ // driver whose gate never runs (nothing authored, or the gate threw) still reports what it knows.
+ _blobSkips = blobSkips;
+ PublishSkipLedger([]);
+
Volatile.Write(ref _tagsByRawPath, table);
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
@@ -775,6 +819,11 @@ public sealed class SqlDriver
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
+ // Gitea #541: a gate rejection leaves the tag unpolled and publishing BadNodeIdUnknown with the
+ // driver Healthy — the same invisible outcome as a blob that did not map, so it is the same signal.
+ PublishSkipLedger(result.Rejected.Select(r =>
+ new SkippedTag(r.RawPath, $"rejected by the catalog gate: {r.Field} — {r.Reason}")));
+
foreach (var rejection in result.Rejected)
{
_logger.LogWarning(
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index a2aaaf34..210cd29a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// resolver land in PRs 2 and 3.
///
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
- IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
+ IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
+ /// Authored tags this driver could not build into its poll table (Gitea #541).
+ private readonly SkippedTagLedger _skippedTags = new();
+
+ ///
+ public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
+
// Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds
// it; InitializeAsync re-parses driverConfigJson over the top of it.
private TwinCATDriverOptions _options;
@@ -127,6 +133,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
+ _skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
@@ -136,10 +143,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
};
else
+ {
+ _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"TwinCAT tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
+ }
}
+ _skippedTags.Commit();
if (_options.Probe.Enabled)
{
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 75fbe44f..0957c267 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
@@ -169,6 +169,7 @@ else
| Type |
Status |
Hosts |
+ Tags |
Last read |
Errors/5 min |
Last error |
@@ -178,7 +179,7 @@ else
@if (g.Drivers.Count == 0)
{
- | No drivers. |
+ No drivers. |
}
else
@@ -227,6 +228,30 @@ else
}
+
+ @* Authored tags the driver is NOT polling (Gitea #541). This column
+ exists because a skip is not an error: the Status chip stays
+ Healthy, Errors/5 min stays 0, Last error stays empty, and Last
+ read keeps advancing off the tags that DID map. On the Makino in
+ #540 that read as a perfectly healthy driver ignoring 20 of its 22
+ tags. "—" means the driver cannot report skips, which is distinct
+ from reporting none. *@
+ @if (d.SkippedTags is null)
+ {
+ —
+ }
+ else if (!d.HasSkippedTags)
+ {
+ @d.SkippedTags.AuthoredCount @(d.SkippedTags.AuthoredCount == 1 ? "tag" : "tags")
+ }
+ else
+ {
+
+ @d.SkippedTags.SkippedCount / @d.SkippedTags.AuthoredCount skipped
+
+ }
+ |
@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") |
@d.ErrorCount5Min |
@(d.LastError ?? "—") |
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 54717bd1..0cadfb89 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs
@@ -44,12 +44,41 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// Per-host connectivity for a multi-device driver, ordered by host name; null
/// when the driver reports no per-host detail. Lets one unreachable device show even while the driver
/// row itself is Healthy.
+/// Authored tags the driver could not build into its poll table; null when the
+/// driver cannot report them. Lets "ignoring 20 of 22 tags" show even while every other column on the
+/// row — state, last read, error count, last error — reads perfectly healthy (Gitea #541).
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
- IReadOnlyList? HostStatuses = null)
+ IReadOnlyList? HostStatuses = null,
+ SkippedTagReport? SkippedTags = null)
{
+ /// Whether this driver is ignoring at least one authored tag — the condition the whole
+ /// column exists to make visible.
+ public bool HasSkippedTags => SkippedTags is { SkippedCount: > 0 };
+
+ ///
+ /// Operator-facing detail for the skip chip: the sample of skipped RawPaths with reasons, plus an
+ /// explicit note when the sample is shorter than the count. The truncation note is not optional —
+ /// without it a capped list reads as the complete one, and an operator who fixes those twenty
+ /// believes they are done.
+ ///
+ /// The tooltip text; empty when nothing was skipped.
+ public string SkippedTagsTitle()
+ {
+ if (SkippedTags is not { SkippedCount: > 0 } r) return "";
+
+ var lead = r.SkippedEverything
+ ? $"This driver is polling NONE of its {r.AuthoredCount} authored tag(s)."
+ : $"{r.SkippedCount} of {r.AuthoredCount} authored tag(s) are not being polled.";
+ var detail = string.Join(" · ", r.Sample.Select(s => $"{s.RawPath}: {s.Reason}"));
+ var more = r.SkippedCount > r.Sample.Count
+ ? $" (+{r.SkippedCount - r.Sample.Count} more not shown)"
+ : "";
+ return $"{lead} They report BadNodeIdUnknown and the driver's state does not reflect them. {detail}{more}";
+ }
+
/// Hosts that are not , ordered by name — the ones worth an
/// operator's attention. Empty when every host is fine or none are reported.
/// counts as degraded on purpose: a probe that has not yet
@@ -136,7 +165,8 @@ public static class HostsDriverView
s.PublishedUtc,
s.RediscoveryNeededUtc,
s.RediscoveryReason,
- s.HostStatuses);
+ s.HostStatuses,
+ s.SkippedTags);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
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 26b03111..b070cc53 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
@@ -7,6 +7,10 @@ using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
// with the proto DriverHealth this file maps.
using HostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus;
using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState;
+// Same reason: the proto declares SkippedTag / SkippedTags alongside the domain records of near-identical
+// name, so the domain side is named explicitly and the proto side reached through the wholesale import.
+using SkippedTag = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTag;
+using SkippedTagReport = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
@@ -125,7 +129,26 @@ public static class TelemetryProtoMapCentral
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"),
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
- HostStatuses: ToHostStatuses(msg));
+ HostStatuses: ToHostStatuses(msg),
+ SkippedTags: ToSkippedTags(msg));
+ }
+
+ ///
+ /// Projects the skipped-tag report, honouring the field's own presence: null when the node said
+ /// the driver cannot report skips, a report when it can — including an all-zero one, which is the
+ /// positive statement "this driver skipped nothing" and must not be flattened back to null.
+ ///
+ private static SkippedTagReport? ToSkippedTags(DriverHealth msg)
+ {
+ if (msg.SkippedTags is not { } s) return null;
+
+ var sample = new List(s.Sample.Count);
+ foreach (var entry in s.Sample)
+ {
+ sample.Add(new SkippedTag(entry.RawPath, entry.Reason));
+ }
+
+ return new SkippedTagReport(s.AuthoredCount, s.SkippedCount, sample);
}
///
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 c8480ce4..904d84a7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
@@ -148,6 +148,22 @@ public static class TelemetryProtoMapNode
}
}
+ // Gitea #541. The field's own presence carries "driver cannot report skips" vs "reports none",
+ // so leaving it unset is meaningful — do not replace this with an always-set empty message.
+ if (e.SkippedTags is { } skipped)
+ {
+ var payload = new SkippedTags
+ {
+ AuthoredCount = skipped.AuthoredCount,
+ SkippedCount = skipped.SkippedCount,
+ };
+ foreach (var s in skipped.Sample)
+ {
+ payload.Sample.Add(new SkippedTag { RawPath = s.RawPath ?? "", Reason = s.Reason ?? "" });
+ }
+ msg.SkippedTags = payload;
+ }
+
return msg;
}
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 854152ba..fb2955a5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs
@@ -37,7 +37,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
- IReadOnlyList? hostStatuses = null)
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null)
{
var msg = new DriverHealthChanged(
clusterId,
@@ -49,7 +50,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
DateTime.UtcNow,
rediscoveryNeededUtc,
rediscoveryReason,
- hostStatuses);
+ hostStatuses,
+ skippedTags);
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/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index a55ac0f3..c96b70c0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -1001,6 +1001,27 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
+ /// What the driver skipped when it last built its tag table, or null when it is not an
+ /// . Pulled fresh for the same reason as
+ /// — the driver owns the answer, and a cached copy here would be a
+ /// second source of truth to keep in sync across every reinitialise.
+ /// Called UNGUARDED (not through ): the contract is a pure in-memory
+ /// snapshot with no I/O. The try/catch keeps a misbehaving implementation from taking down the health
+ /// publish — losing the skip detail is strictly better than losing the whole snapshot.
+ private SkippedTagReport? CurrentSkippedTags()
+ {
+ if (_driver is not ISkippedTagReporter reporter) return null;
+ try
+ {
+ return reporter.GetSkippedTags();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "DriverInstance {Id}: GetSkippedTags threw during health publish; omitting skip detail", _driverInstanceId);
+ return null;
+ }
+ }
+
/// Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -1151,6 +1172,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
observed = health;
var errorCount = ErrorCount5Min();
var hostStatuses = CurrentHostStatuses();
+ var skippedTags = CurrentSkippedTags();
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
// identical, so without it the dedup below would swallow the very publish that carries the
@@ -1162,14 +1184,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// the whole point of this channel — would be deduped away. It is a flattened STRING because a
// tuple holding IReadOnlyList compares by reference, which would never match and so would
// defeat the dedup in the opposite direction (re-publishing every 30 s heartbeat).
+ //
+ // The skip digest is in the fingerprint for the third instance of that same argument, and
+ // here it is the WHOLE point of the channel: a driver that skipped tags is by construction
+ // one whose state, error count and last-error are all unremarkable, so every other component
+ // is identical to a clean driver's and the publish that carries the signal is exactly the one
+ // dedup would swallow. Flattened to a STRING for the same reason as the host digest — the
+ // report holds an IReadOnlyList, which a tuple compares by reference.
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount,
- _rediscoveryNeededUtc, HostStatusDigest(hostStatuses));
+ _rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags));
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
return observed;
_lastPublishedFingerprint = fingerprint;
_healthPublisher.Publish(
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason,
- hostStatuses);
+ hostStatuses, skippedTags);
}
catch (Exception ex)
{
@@ -1191,9 +1220,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
.OrderBy(s => s.HostName, StringComparer.Ordinal)
.Select(s => $"{s.HostName}={s.State}@{s.LastChangedUtc:O}"));
+ /// Value digest of a skip report for the publish fingerprint. Null in, null out, so "driver
+ /// cannot report skips" and "driver reports none" stay distinguishable rather than both collapsing to
+ /// the same string.
+ /// The counts alone would be enough to catch a change in HOW MANY tags are skipped, but not a
+ /// change in WHICH — a redeploy that fixes one tag and breaks another leaves both counts identical,
+ /// and the operator would keep reading the stale sample. So the sample is in the digest too.
+ private static string? SkippedTagDigest(SkippedTagReport? report) =>
+ report is null
+ ? null
+ : $"{report.AuthoredCount}/{report.SkippedCount}:" +
+ string.Join('|', report.Sample.Select(s => $"{s.RawPath}={s.Reason}"));
+
/// Fingerprint of the last call; null until first publish.
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount,
- DateTime? RediscoveryNeededUtc, string? HostStatusDigest)? _lastPublishedFingerprint;
+ DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest)? _lastPublishedFingerprint;
///
protected override void PostStop()
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
index dcaefcc8..23971b1a 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
@@ -65,6 +65,7 @@ public sealed class InterfaceIndependenceTests
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
+ [InlineData(typeof(ISkippedTagReporter))]
[InlineData(typeof(IDriverConfigEditor))]
[InlineData(typeof(IAddressSpaceBuilder))]
public void EveryCapabilityInterface_IsPublic(Type type)
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagLedgerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagLedgerTests.cs
new file mode 100644
index 00000000..eb19b7d9
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagLedgerTests.cs
@@ -0,0 +1,210 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
+
+///
+/// Gitea #541. The rules a driver relies on when it accumulates skips into a
+/// — one implementation of them, rather than twelve.
+///
+public sealed class SkippedTagLedgerTests
+{
+ [Fact]
+ public void A_fresh_ledger_reports_nothing()
+ {
+ var report = new SkippedTagLedger().Snapshot();
+
+ report.AuthoredCount.ShouldBe(0);
+ report.SkippedCount.ShouldBe(0);
+ report.Sample.ShouldBeEmpty();
+ report.HasSkips.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void A_committed_build_reports_its_skips_with_the_authored_denominator()
+ {
+ var ledger = new SkippedTagLedger();
+
+ ledger.BeginRebuild(22);
+ ledger.Skip("Line1/Makino/Identity/SeriesNumber", "invalid Address");
+ ledger.Skip("Line1/Makino/Identity/Version", "invalid Address");
+ ledger.Commit();
+
+ var report = ledger.Snapshot();
+ report.AuthoredCount.ShouldBe(22);
+ report.SkippedCount.ShouldBe(2);
+ report.Sample.Select(s => s.RawPath)
+ .ShouldBe(["Line1/Makino/Identity/SeriesNumber", "Line1/Makino/Identity/Version"]);
+ report.HasSkips.ShouldBeTrue();
+ report.SkippedEverything.ShouldBeFalse();
+ }
+
+ ///
+ /// The Makino case from Gitea #540 — the one the whole issue is about. A driver skipping every
+ /// tag it has is the MOST likely to look healthy, because it has no failing reads to report.
+ ///
+ [Fact]
+ public void Skipping_every_authored_tag_is_distinguishable_from_skipping_some()
+ {
+ var ledger = new SkippedTagLedger();
+
+ ledger.BeginRebuild(3);
+ ledger.Skip("a", "r");
+ ledger.Skip("b", "r");
+ ledger.Skip("c", "r");
+ ledger.Commit();
+
+ ledger.Snapshot().SkippedEverything.ShouldBeTrue();
+ }
+
+ ///
+ /// A driver that authored nothing has not "skipped everything" — there was nothing to skip. The
+ /// distinction matters because the UI paints SkippedEverything as the loudest state, and an
+ /// empty driver is not an alarm.
+ ///
+ [Fact]
+ public void An_empty_deployment_has_not_skipped_everything()
+ {
+ var ledger = new SkippedTagLedger();
+
+ ledger.BeginRebuild(0);
+ ledger.Commit();
+
+ ledger.Snapshot().SkippedEverything.ShouldBeFalse();
+ }
+
+ ///
+ /// The count is exact; only the detail list is capped. A misconfigured deployment can skip
+ /// thousands of tags, and this report is published on every driver state change.
+ ///
+ [Fact]
+ public void The_sample_is_capped_but_the_count_is_not()
+ {
+ var ledger = new SkippedTagLedger();
+
+ ledger.BeginRebuild(500);
+ for (var i = 0; i < 500; i++) ledger.Skip($"tag-{i}", "TagConfig did not map to a definition");
+ ledger.Commit();
+
+ var report = ledger.Snapshot();
+ report.SkippedCount.ShouldBe(500);
+ report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
+ report.Sample[0].RawPath.ShouldBe("tag-0", "the sample keeps the FIRST skips, in encounter order");
+ }
+
+ ///
+ /// The reason the ledger has a commit step at all. A health publish landing part-way through a
+ /// rebuild must read the previous complete answer, not a half-built one — a truncated snapshot
+ /// would read as "we now skip fewer tags", which is the opposite of the truth.
+ ///
+ [Fact]
+ public void An_in_progress_rebuild_is_invisible_until_it_commits()
+ {
+ var ledger = new SkippedTagLedger();
+ ledger.BeginRebuild(10);
+ ledger.Skip("old", "r");
+ ledger.Commit();
+
+ ledger.BeginRebuild(10);
+ ledger.Skip("new-1", "r");
+
+ var midRebuild = ledger.Snapshot();
+ midRebuild.SkippedCount.ShouldBe(1);
+ midRebuild.Sample.Single().RawPath.ShouldBe("old");
+
+ ledger.Skip("new-2", "r");
+ ledger.Commit();
+ ledger.Snapshot().Sample.Select(s => s.RawPath).ShouldBe(["new-1", "new-2"]);
+ }
+
+ ///
+ /// An abandoned rebuild — a driver init that threw part-way, which several drivers do on an
+ /// unknown device — leaves the last good snapshot standing rather than publishing a partial one.
+ ///
+ [Fact]
+ public void A_rebuild_that_never_commits_leaves_the_previous_snapshot_standing()
+ {
+ var ledger = new SkippedTagLedger();
+ ledger.BeginRebuild(5);
+ ledger.Skip("known-bad", "invalid Address");
+ ledger.Commit();
+
+ ledger.BeginRebuild(5);
+ ledger.Skip("would-have-been-reported", "invalid Address");
+ // …driver throws here; no Commit.
+
+ ledger.Snapshot().Sample.Single().RawPath.ShouldBe("known-bad");
+ }
+
+ ///
+ /// A redeploy that fixes every tag must clear the report. Accumulating across rebuilds would
+ /// leave a permanent false alarm on a driver that is now correct.
+ ///
+ [Fact]
+ public void A_clean_rebuild_clears_a_previous_report()
+ {
+ var ledger = new SkippedTagLedger();
+ ledger.BeginRebuild(2);
+ ledger.Skip("bad", "invalid Address");
+ ledger.Commit();
+
+ ledger.BeginRebuild(2);
+ ledger.Commit();
+
+ var report = ledger.Snapshot();
+ report.HasSkips.ShouldBeFalse();
+ report.SkippedCount.ShouldBe(0);
+ report.AuthoredCount.ShouldBe(2);
+ }
+
+ ///
+ /// A stray Skip outside a rebuild is dropped rather than thrown. This runs inside a
+ /// driver's initialise: a bookkeeping exception would fail the very init the ledger describes.
+ ///
+ [Fact]
+ public void A_skip_outside_a_rebuild_is_ignored_rather_than_thrown()
+ {
+ var ledger = new SkippedTagLedger();
+
+ Should.NotThrow(() => ledger.Skip("orphan", "r"));
+ ledger.Snapshot().SkippedCount.ShouldBe(0);
+ }
+
+ ///
+ /// Concurrent reads during a build must not tear. The read is on the actor's health-publish path
+ /// while the build runs on the driver's init path, so this is the real production interleaving.
+ ///
+ [Fact]
+ public async Task Reading_while_a_build_runs_never_observes_a_torn_report()
+ {
+ var ledger = new SkippedTagLedger();
+ ledger.BeginRebuild(1000);
+ ledger.Commit();
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+
+ var writer = Task.Run(() =>
+ {
+ for (var round = 0; round < 50 && !cts.IsCancellationRequested; round++)
+ {
+ ledger.BeginRebuild(1000);
+ for (var i = 0; i < 100; i++) ledger.Skip($"t{i}", "r");
+ ledger.Commit();
+ }
+ }, cts.Token);
+
+ var reader = Task.Run(() =>
+ {
+ while (!writer.IsCompleted && !cts.IsCancellationRequested)
+ {
+ var r = ledger.Snapshot();
+ // Every committed snapshot is internally consistent: the sample never exceeds either the
+ // cap or the true count it is drawn from.
+ r.Sample.Count.ShouldBeLessThanOrEqualTo(SkippedTagLedger.MaxSample);
+ r.Sample.Count.ShouldBeLessThanOrEqualTo(r.SkippedCount);
+ }
+ }, cts.Token);
+
+ await Task.WhenAll(writer, reader);
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagReporterFleetTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagReporterFleetTests.cs
new file mode 100644
index 00000000..29db14a9
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/SkippedTagReporterFleetTests.cs
@@ -0,0 +1,92 @@
+using System.Reflection;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
+
+///
+/// Gitea #541. Pins the fleet-wide invariant: every driver reports what it skipped.
+///
+///
+///
+/// The defect this guards was never one driver's bug — it was one missing signal repeated across
+/// every driver in the fleet, which is exactly the shape that a per-driver test cannot catch and a
+/// thirteenth driver would silently re-introduce. So discovery here is reflective, not a
+/// hand-copied list: it scans the driver assemblies deployed to bin for concrete
+/// implementations, the same convention
+/// DriverTypeNamesGuardTests uses to find factories.
+///
+///
+/// The original issue named eight drivers. The real number is twelve — every driver in
+/// has an authored-tag skip path, and OpcUaClient's was
+/// the quietest of them (a bare continue in NamespaceMap, with not even a log line).
+/// A hand-maintained list would have shipped the four the issue missed still invisible.
+///
+///
+public sealed class SkippedTagReporterFleetTests
+{
+ /// Driver-adjacent assemblies that ship no runtime driver (contracts/addressing/CLI helpers,
+ /// browsers, transports, and the historian client packages).
+ private static readonly string[] NonDriverAssemblySuffixes =
+ [".Contracts", ".Addressing", ".Cli", ".Browser", ".Transport", ".Dialect", ".Client"];
+
+ ///
+ /// Every concrete implementation deployed to the test output directory.
+ ///
+ /// The discovered driver types.
+ private static IReadOnlyList DiscoverDriverTypes()
+ {
+ var found = new List();
+ var binDir = Path.GetDirectoryName(typeof(SkippedTagReporterFleetTests).Assembly.Location)!;
+
+ foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll"))
+ {
+ var name = Path.GetFileNameWithoutExtension(dll);
+ if (NonDriverAssemblySuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal)))
+ continue;
+
+ Assembly asm;
+ try { asm = Assembly.LoadFrom(dll); }
+ catch { continue; }
+
+ Type[] types;
+ try { types = asm.GetTypes(); }
+ catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
+
+ found.AddRange(types.Where(t =>
+ t is { IsClass: true, IsAbstract: false } && typeof(IDriver).IsAssignableFrom(t)));
+ }
+
+ return found;
+ }
+
+ ///
+ /// Every driver in the fleet implements .
+ ///
+ ///
+ /// A driver that does not is one whose Healthy is unfalsifiable: it can ignore every tag it
+ /// was authored and no surface anywhere will say so.
+ ///
+ [Fact]
+ public void Every_driver_reports_the_tags_it_skipped()
+ {
+ var drivers = DiscoverDriverTypes();
+
+ // A zero here means the bin scan broke, not that the fleet has no drivers — fail loudly rather
+ // than pass a vacuously-empty check, which is how an invariant test quietly stops testing.
+ drivers.Count.ShouldBeGreaterThanOrEqualTo(DriverTypeNames.All.Count,
+ "fewer IDriver implementations were discovered than there are registered driver types — the " +
+ "driver assemblies did not deploy to bin, or a driver moved out of a ZB.MOM.WW.OtOpcUa.Driver.* assembly");
+
+ var missing = drivers
+ .Where(t => !typeof(ISkippedTagReporter).IsAssignableFrom(t))
+ .Select(t => t.FullName)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ missing.ShouldBeEmpty(
+ "these drivers do not report skipped tags, so each can ignore its whole configuration while " +
+ "reporting Healthy with 0 errors (Gitea #541). Accumulate skips into a SkippedTagLedger during " +
+ "the tag-table build and expose it via ISkippedTagReporter: " + string.Join(", ", missing));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasSkippedTagReportingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasSkippedTagReportingTests.cs
new file mode 100644
index 00000000..35919234
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasSkippedTagReportingTests.cs
@@ -0,0 +1,170 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Gitea #541, proved against the driver where the defect was actually found. #540 fixed the
+/// addressing that produced the 20 unreadable tags on the wonder Makino; this covers the half that
+/// made them invisible — the driver reported Healthy with 0 errors and a live-advancing
+/// last read while ignoring 20 of its 22 tags, and nothing anywhere said so.
+/// FOCAS is the useful driver to prove it on because it has all three skip shapes: an unmappable
+/// TagConfig, an unparseable address, and a capability-matrix rejection.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasSkippedTagReportingTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private static FocasDriver NewDriver(params FocasTagDefinition[] tags) =>
+ new(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ Probe = new FocasProbeOptions { Enabled = false },
+ FixedTree = new FocasFixedTreeOptions { Enabled = false },
+ RawTags = FocasRawTags.Entries(tags),
+ }, "drv-1", new FakeFocasClientFactory());
+
+ /// The driver advertises the capability at all — the fleet guard asserts this reflectively,
+ /// but a driver-local pin fails with a message that names FOCAS.
+ [Fact]
+ public void The_driver_reports_skipped_tags()
+ => NewDriver().ShouldBeAssignableTo();
+
+ /// A driver whose tags all map reports zero skips against the real denominator.
+ [Fact]
+ public async Task A_clean_deployment_reports_no_skips()
+ {
+ var drv = NewDriver(
+ new FocasTagDefinition("raw/A", Host, "R100", FocasDataType.Byte),
+ new FocasTagDefinition("raw/B", Host, "MACRO:500", FocasDataType.Float64));
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var report = drv.GetSkippedTags();
+ report.AuthoredCount.ShouldBe(2);
+ report.SkippedCount.ShouldBe(0);
+ report.HasSkips.ShouldBeFalse();
+ report.SkippedEverything.ShouldBeFalse();
+ }
+
+ ///
+ /// The wonder Makino, reproduced. Twenty tags carrying the exact address shape #540
+ /// authored — a browse full reference with the device host in it — plus two good ones. The driver
+ /// is Healthy with no error either way; the skip report is the only thing that differs.
+ ///
+ [Fact]
+ public async Task The_makino_case_a_healthy_driver_that_is_ignoring_most_of_its_tags()
+ {
+ FocasTagDefinition[] broken =
+ [
+ .. Enumerable.Range(0, 20).Select(i => new FocasTagDefinition(
+ $"raw/Broken{i}", Host, $"10.0.0.5:8193/Identity/Field{i}", FocasDataType.String, Writable: false)),
+ ];
+ var drv = NewDriver([
+ .. broken,
+ new FocasTagDefinition("raw/Good1", Host, "MACRO:500", FocasDataType.Float64),
+ new FocasTagDefinition("raw/Good2", Host, "MACRO:501", FocasDataType.Float64),
+ ]);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Exactly the reading an operator got on /hosts, and why it was believed for weeks.
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ drv.GetHealth().LastError.ShouldBeNull();
+
+ // …and the fact that contradicts it.
+ var report = drv.GetSkippedTags();
+ report.AuthoredCount.ShouldBe(22);
+ report.SkippedCount.ShouldBe(20);
+ report.SkippedEverything.ShouldBeFalse("two tags do poll — this driver is partly working");
+ report.Sample.ShouldAllBe(s => s.Reason.Contains("invalid Address"));
+ report.Sample[0].RawPath.ShouldBe("raw/Broken0");
+ }
+
+ ///
+ /// The sample is capped but the count is not. Twenty-two skips against a cap of twenty: a UI that
+ /// rendered the sample as the whole story would under-report by two.
+ ///
+ [Fact]
+ public async Task The_count_is_exact_even_when_the_sample_is_capped()
+ {
+ var drv = NewDriver([
+ .. Enumerable.Range(0, 25).Select(i => new FocasTagDefinition(
+ $"raw/Broken{i}", Host, "not-an-address", FocasDataType.String, Writable: false)),
+ ]);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var report = drv.GetSkippedTags();
+ report.SkippedCount.ShouldBe(25);
+ report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
+ report.SkippedEverything.ShouldBeTrue("this driver polls nothing at all");
+ }
+
+ ///
+ /// Re-initialising replaces the report rather than accumulating onto it. Config edits respawn the
+ /// driver (#516) and Sql/FOCAS re-parse in place, so a ledger that accumulated would leave a
+ /// permanent false alarm on a driver whose deployment has since been fixed.
+ ///
+ [Fact]
+ public async Task Re_initialising_replaces_the_report_rather_than_accumulating()
+ {
+ var drv = NewDriver(
+ new FocasTagDefinition("raw/Bad", Host, "not-an-address", FocasDataType.String, Writable: false),
+ new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64));
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.GetSkippedTags().SkippedCount.ShouldBe(1);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var report = drv.GetSkippedTags();
+ report.SkippedCount.ShouldBe(1, "the same one bad tag — not two");
+ report.AuthoredCount.ShouldBe(2);
+ }
+
+ ///
+ /// A tag routed to an unknown device is a driver-level config error that throws, and an init that
+ /// throws must leave the previous report standing rather than publish the half-built one it got to.
+ ///
+ [Fact]
+ public async Task An_init_that_throws_leaves_the_previous_report_standing()
+ {
+ var good = new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ Probe = new FocasProbeOptions { Enabled = false },
+ FixedTree = new FocasFixedTreeOptions { Enabled = false },
+ RawTags = FocasRawTags.Entries(
+ [
+ new FocasTagDefinition("raw/Bad", Host, "not-an-address", FocasDataType.String, Writable: false),
+ new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64),
+ ]),
+ };
+ var drv = new FocasDriver(good, "drv-1", new FakeFocasClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.GetSkippedTags().Sample.Single().RawPath.ShouldBe("raw/Bad");
+
+ // A second driver over an artifact whose tag names a device that is not configured: the loop skips
+ // the first entry, then throws on the second.
+ var bad = new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ Probe = new FocasProbeOptions { Enabled = false },
+ FixedTree = new FocasFixedTreeOptions { Enabled = false },
+ RawTags = FocasRawTags.Entries(
+ [
+ new FocasTagDefinition("raw/AlsoBad", Host, "not-an-address", FocasDataType.String, Writable: false),
+ new FocasTagDefinition("raw/Orphan", "focas://10.9.9.9:8193", "MACRO:500", FocasDataType.Float64),
+ ]),
+ };
+ var drv2 = new FocasDriver(bad, "drv-2", new FakeFocasClientFactory());
+ await Should.ThrowAsync(
+ () => drv2.InitializeAsync("{}", CancellationToken.None));
+
+ drv2.GetSkippedTags().SkippedCount.ShouldBe(0,
+ "an abandoned build must not publish; a partial report would read as 'we skip fewer tags now'");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs
index afeb8ad1..a7243fb0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs
@@ -22,6 +22,53 @@ public sealed class SqlCatalogGateDriverTests
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
+ ///
+ /// Gitea #541. Sql drops a tag at TWO points — a blob that does not map, and a rejection against
+ /// the live catalog — and both leave the tag unpolled and publishing BadNodeIdUnknown with
+ /// the driver Healthy. The skip report must be the union.
+ /// This is the case that makes Sql different from the other eleven drivers, and the one worth
+ /// a live-catalog test rather than a unit one: a gate rejection is precisely what a schema drift
+ /// (renamed column, dropped view) produces months after a deployment sealed green. Reporting only
+ /// the blob-level skips would have this driver claim "0 skipped" while polling half its tags —
+ /// worse than reporting nothing, because the column would be actively reassuring.
+ ///
+ [Fact]
+ public async Task A_catalog_gate_rejection_is_reported_as_a_skipped_tag()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(
+ fixture,
+ KvEntry("Speed", SqlitePollFixture.PresentKey),
+ KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
+
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ // The reading an operator gets from every other column on /hosts.
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.GetHealth().LastError.ShouldBeNull();
+
+ // …and the one that contradicts it.
+ var report = ((ISkippedTagReporter)driver).GetSkippedTags();
+ report.AuthoredCount.ShouldBe(2);
+ report.SkippedCount.ShouldBe(1, "the gate rejected one tag; the blob mapped both");
+ report.Sample.Single().RawPath.ShouldBe("Bogus");
+ report.Sample.Single().Reason.ShouldContain("catalog gate");
+ }
+
+ /// A driver whose tags all map and all pass the gate reports zero skips.
+ [Fact]
+ public async Task A_driver_whose_tags_all_pass_the_gate_reports_no_skips()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ var report = ((ISkippedTagReporter)driver).GetSkippedTags();
+ report.AuthoredCount.ShouldBe(1);
+ report.HasSkips.ShouldBeFalse();
+ }
+
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
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 acc38c31..ea6d5e89 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
@@ -213,4 +213,96 @@ public sealed class HostsDriverViewTests
row.HostStatuses.ShouldBeNull();
row.DegradedHosts.ShouldBeEmpty();
}
+
+ // ---- skipped tags (Gitea #541) ----
+
+ ///
+ /// The Makino case from Gitea #540, as the row renders it: every driver-level field reads healthy
+ /// while 20 of 22 tags are not being polled. The skip field is the only thing on the row that
+ /// contradicts it.
+ ///
+ [Fact]
+ public void A_healthy_row_still_reports_the_tags_its_driver_is_ignoring()
+ {
+ var snapshot = Snap("MAIN", "focas-z-34184") with
+ {
+ SkippedTags = new SkippedTagReport(22, 20,
+ [new SkippedTag("Makino/Identity/SeriesNumber", "invalid Address")]),
+ };
+
+ var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
+
+ row.State.ShouldBe("Healthy");
+ row.ErrorCount5Min.ShouldBe(0);
+ row.LastError.ShouldBeNull();
+ row.HasSkippedTags.ShouldBeTrue("the row must contradict its own healthy-looking columns");
+ row.SkippedTags!.SkippedCount.ShouldBe(20);
+ }
+
+ ///
+ /// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
+ /// list, and an operator who fixes the twenty named tags believes the job is done.
+ ///
+ [Fact]
+ public void The_skip_tooltip_admits_when_the_sample_is_shorter_than_the_count()
+ {
+ var snapshot = Snap("MAIN", "drv-a") with
+ {
+ SkippedTags = new SkippedTagReport(500, 137, [new SkippedTag("tag-0", "TagConfig did not map")]),
+ };
+
+ var title = HostsDriverView.Build([snapshot], nodes: null, instances: null)
+ .Single().Drivers.Single().SkippedTagsTitle();
+
+ title.ShouldContain("137 of 500");
+ title.ShouldContain("+136 more not shown");
+ }
+
+ /// A complete sample carries no truncation note.
+ [Fact]
+ public void The_skip_tooltip_omits_the_truncation_note_when_the_sample_is_complete()
+ {
+ var snapshot = Snap("MAIN", "drv-a") with
+ {
+ SkippedTags = new SkippedTagReport(5, 1, [new SkippedTag("tag-0", "TagConfig did not map")]),
+ };
+
+ HostsDriverView.Build([snapshot], nodes: null, instances: null)
+ .Single().Drivers.Single().SkippedTagsTitle()
+ .ShouldNotContain("more not shown");
+ }
+
+ ///
+ /// A driver polling NONE of its tags gets its own wording — it is the most likely of all to look
+ /// healthy, because it has no failing reads to report.
+ ///
+ [Fact]
+ public void A_driver_polling_nothing_is_called_out_as_such()
+ {
+ var snapshot = Snap("MAIN", "drv-a") with
+ {
+ SkippedTags = new SkippedTagReport(3, 3, [new SkippedTag("a", "r")]),
+ };
+
+ var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
+
+ row.SkippedTags!.SkippedEverything.ShouldBeTrue();
+ row.SkippedTagsTitle().ShouldContain("NONE");
+ }
+
+ ///
+ /// A driver that cannot report skips keeps null — distinct from one reporting zero. The column
+ /// renders "—" for the former and "N tags" for the latter, and collapsing them would claim an
+ /// unreporting driver is positively known to be polling everything.
+ ///
+ [Fact]
+ public void A_driver_without_a_skip_report_keeps_null_and_claims_nothing()
+ {
+ var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null)
+ .Single().Drivers.Single();
+
+ row.SkippedTags.ShouldBeNull();
+ row.HasSkippedTags.ShouldBeFalse();
+ row.SkippedTagsTitle().ShouldBeEmpty();
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs
index 1b4c9c62..a5a5468b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs
@@ -220,6 +220,62 @@ public sealed class TelemetryProtoMapCentralTests
TelemetryProtoMapCentral.ToHealth(absent).HostStatuses.ShouldBeNull();
}
+ ///
+ /// The skipped-tag report (Gitea #541) survives the wire with the same tri-state discipline:
+ /// null (the driver cannot report skips) must stay distinguishable from a zero report
+ /// (it can, and skipped nothing). Unlike the repeated host_statuses field this needs no companion
+ /// presence flag — a singular message field carries real presence in proto3 — but the distinction it
+ /// protects is the same one, and collapsing it would render an unreporting driver as positively
+ /// known-clean.
+ ///
+ [Fact]
+ public void ToHealth_skipped_tags_round_trip_with_the_null_vs_zero_distinction_intact()
+ {
+ var populated = new DriverHealth
+ {
+ ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
+ PublishedUtc = Timestamp.FromDateTime(SampleUtc),
+ SkippedTags = new SkippedTags
+ {
+ AuthoredCount = 22,
+ // Deliberately larger than the sample: the cap is on the detail, never on the count.
+ SkippedCount = 20,
+ Sample =
+ {
+ new SkippedTag { RawPath = "Makino/Identity/SeriesNumber", Reason = "invalid Address" },
+ },
+ },
+ };
+
+ var mapped = TelemetryProtoMapCentral.ToHealth(populated).SkippedTags;
+ mapped.ShouldNotBeNull();
+ mapped!.AuthoredCount.ShouldBe(22);
+ mapped.SkippedCount.ShouldBe(20);
+ mapped.Sample.Count.ShouldBe(1, "a truncated sample must arrive truncated, not padded or dropped");
+ mapped.Sample[0].RawPath.ShouldBe("Makino/Identity/SeriesNumber");
+ mapped.Sample[0].Reason.ShouldBe("invalid Address");
+
+ // A driver that reports, and skipped nothing: a zero report, NOT null.
+ var clean = new DriverHealth
+ {
+ ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
+ PublishedUtc = Timestamp.FromDateTime(SampleUtc),
+ SkippedTags = new SkippedTags { AuthoredCount = 12, SkippedCount = 0 },
+ };
+ var mappedClean = TelemetryProtoMapCentral.ToHealth(clean).SkippedTags;
+ mappedClean.ShouldNotBeNull();
+ mappedClean!.HasSkips.ShouldBeFalse();
+ mappedClean.AuthoredCount.ShouldBe(12);
+
+ // A driver that cannot report at all: null, NOT a zero report.
+ var absentSkips = new DriverHealth
+ {
+ ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
+ PublishedUtc = Timestamp.FromDateTime(SampleUtc),
+ };
+ TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.ShouldBeNull();
+ }
+
///
/// An unparseable host state degrades to rather than throwing. A node
/// running a newer build that added an enum member must not be able to kill central's telemetry
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
index e4b77e51..f32d7cca 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
@@ -210,7 +210,8 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
- IReadOnlyList? hostStatuses = null)
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null)
{
lock (_published) _published.Add(new HealthPublish(health, hostStatuses));
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
index 0154d205..cf39eb2b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
@@ -186,7 +186,8 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
- IReadOnlyList? hostStatuses = null)
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null)
{
lock (_published)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSkippedTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSkippedTagTests.cs
new file mode 100644
index 00000000..8491de2d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSkippedTagTests.cs
@@ -0,0 +1,269 @@
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Covers the consumer (Gitea #541) — the signal that contradicts a
+/// driver reporting Healthy, 0 errors and a live-advancing last read while ignoring most of the
+/// tags it was authored.
+/// Why it does not go to the DB — the same reason the #521 host statuses do not: a
+/// driver-only node has no ConfigDb connection since per-cluster mesh Phase 4, so the data rides the
+/// driver-health snapshot and the Phase 5 telemetry stream instead.
+///
+[Trait("Category", "Unit")]
+public sealed class DriverInstanceActorSkippedTagTests : RuntimeActorTestBase
+{
+ /// The base case: a reporting driver's skips reach the health publisher.
+ [Fact]
+ public void Skipped_tags_reach_the_health_publisher()
+ {
+ var driver = new SkipStubDriver();
+ driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber", "Makino/Identity/Version"]);
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(driver, publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+
+ AwaitAssert(
+ () =>
+ {
+ var latest = publisher.Published.LastOrDefault();
+ latest.ShouldNotBeNull();
+ latest!.SkippedTags.ShouldNotBeNull();
+ latest.SkippedTags!.AuthoredCount.ShouldBe(22);
+ latest.SkippedTags.SkippedCount.ShouldBe(2);
+ latest.SkippedTags.Sample.Select(s => s.RawPath)
+ .ShouldBe(["Makino/Identity/SeriesNumber", "Makino/Identity/Version"]);
+ },
+ TimeSpan.FromSeconds(3));
+ }
+
+ ///
+ /// The load-bearing case, and the entire reason this channel exists. The Makino in Gitea #540:
+ /// a driver ignoring 20 of its 22 tags while every driver-level field reads perfectly healthy.
+ /// This is the same dedup trap that already bit the rediscovery signal and the host statuses.
+ /// PublishHealthSnapshot suppresses a publish whose fingerprint repeats, and a skipped tag by
+ /// construction leaves (state, lastSuccessfulRead, lastError, errorCount) ALL unchanged — a skip is
+ /// not an error and happens before any read. Unless the skip digest is part of the fingerprint, the
+ /// dedup swallows exactly the publish that carries the news.
+ /// Falsifiability: the assertion is that the publish count STRICTLY INCREASES across the
+ /// change. An "eventually reports skips" assertion would be satisfied by the warm-up publish alone
+ /// and would prove nothing. Drop SkippedTagDigest from the fingerprint tuple in
+ /// DriverInstanceActor and this test must go red.
+ ///
+ [Fact]
+ public void A_redeploy_that_starts_skipping_tags_is_not_swallowed_by_the_unchanged_health_dedup()
+ {
+ var driver = new SkipStubDriver();
+ driver.SetReport(authored: 22, skipped: []);
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(driver, publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
+
+ // Settle, so the baseline is a quiet actor: from here only the skip report changes. The driver's
+ // own health stays Healthy throughout — that is the point.
+ ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ var before = publisher.Published.Count;
+
+ driver.SetReport(authored: 22, skipped: [.. Enumerable.Range(0, 20).Select(i => $"Makino/Tag{i}")]);
+
+ AwaitAssert(
+ () => publisher.Published.Count.ShouldBeGreaterThan(before),
+ TimeSpan.FromSeconds(3));
+
+ var latest = publisher.Published[^1];
+ latest.Health.State.ShouldBe(DriverState.Healthy,
+ "the driver never faulted — a skip is a config problem, not an error, and must not latch state");
+ latest.SkippedTags!.SkippedCount.ShouldBe(20);
+ latest.SkippedTags.AuthoredCount.ShouldBe(22);
+ }
+
+ ///
+ /// A redeploy that swaps WHICH tags are broken without changing HOW MANY must still publish.
+ /// Digesting the counts alone would leave an operator staring at a stale sample naming tags that
+ /// are now fine, while the ones actually broken went unmentioned.
+ ///
+ [Fact]
+ public void Swapping_which_tags_are_skipped_republishes_even_though_the_count_is_unchanged()
+ {
+ var driver = new SkipStubDriver();
+ driver.SetReport(authored: 5, skipped: ["old-broken"]);
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(driver, publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
+ ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ var before = publisher.Published.Count;
+
+ driver.SetReport(authored: 5, skipped: ["newly-broken"]);
+
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3));
+ publisher.Published[^1].SkippedTags!.Sample.Single().RawPath.ShouldBe("newly-broken");
+ }
+
+ ///
+ /// The other half of the dedup contract: when nothing changes, the digest must NOT churn. A digest
+ /// comparing the sample list by reference would differ on every call and re-publish on every 30 s
+ /// heartbeat forever — turning the dedup off without anyone noticing.
+ ///
+ [Fact]
+ public void An_unchanged_skip_report_does_not_defeat_the_dedup()
+ {
+ var driver = new SkipStubDriver();
+ driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber"]);
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(driver, publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
+ ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ var before = publisher.Published.Count;
+
+ // A fresh-but-equal report, rebuilt exactly as a redeploy that changed nothing would.
+ driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber"]);
+
+ // Long enough for many heartbeats to fire and be deduped away.
+ ExpectNoMsg(TimeSpan.FromMilliseconds(600));
+ publisher.Published.Count.ShouldBe(before,
+ "an equal-but-distinct report must be deduped, not re-published — the digest must compare by VALUE");
+ }
+
+ ///
+ /// A driver that cannot report skips publishes null — NOT an empty report. The two mean different
+ /// things at the UI ("no skip detail available" vs "this driver skipped nothing"), and collapsing
+ /// them would render an unreporting driver as one that is positively known to be clean.
+ ///
+ [Fact]
+ public void A_driver_that_cannot_report_publishes_null_rather_than_an_empty_report()
+ {
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(new StubDriver(), publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
+
+ publisher.Published.ShouldAllBe(p => p.SkippedTags == null);
+ }
+
+ ///
+ /// A reporter that throws must not take the health publish down with it. Losing the skip detail is
+ /// strictly better than losing the whole health channel.
+ ///
+ [Fact]
+ public void A_throwing_reporter_degrades_to_null_without_killing_the_health_publish()
+ {
+ var driver = new SkipStubDriver { ThrowOnGetSkippedTags = true };
+ var publisher = new RecordingHealthPublisher();
+ var actor = SpawnDriverActor(driver, publisher);
+
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+
+ AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
+ publisher.Published[^1].Health.State.ShouldBe(DriverState.Healthy);
+ publisher.Published[^1].SkippedTags.ShouldBeNull();
+ }
+
+ ///
+ /// Spawns the actor with a fast health heartbeat. The heartbeat is the production path by which a
+ /// changed skip report reaches the publisher — unlike host connectivity, a skip raises no event,
+ /// because it is discovered during the tag-table build and never afterwards. Driving the tests
+ /// through it rather than through a synthetic poke means the dedup assertions are made against the
+ /// mechanism that actually runs in production.
+ ///
+ private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher)
+ {
+ var parent = CreateTestProbe();
+ parent.IgnoreMessages(_ => true);
+ return parent.ChildActorOf(DriverInstanceActor.Props(
+ driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(50)));
+ }
+
+ /// Captures every health publish so a test can assert on the skipped-tag field.
+ private sealed record HealthPublish(DriverHealth Health, SkippedTagReport? SkippedTags);
+
+ private sealed class RecordingHealthPublisher : IDriverHealthPublisher
+ {
+ private readonly List _published = [];
+
+ /// Thread-safe snapshot — Publish runs on the actor thread while the test asserts
+ /// from its own.
+ public IReadOnlyList Published
+ {
+ get { lock (_published) return _published.ToArray(); }
+ }
+
+ ///
+ public void Publish(
+ string clusterId,
+ string driverInstanceId,
+ DriverHealth health,
+ int errorCount5Min,
+ DateTime? rediscoveryNeededUtc = null,
+ string? rediscoveryReason = null,
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null)
+ {
+ lock (_published) _published.Add(new HealthPublish(health, skippedTags));
+ }
+ }
+
+ ///
+ /// A stub driver exposing , backed by a real
+ /// so the test exercises the production accumulator rather than a
+ /// parallel fake of it.
+ /// Its own health is deliberately STABLE (a fixed last-read timestamp). The shared
+ /// StubDriver returns DateTime.UtcNow from GetHealth(), so its fingerprint
+ /// differs on every call, the dedup never engages, and every dedup assertion here would pass
+ /// vacuously whether or not the fix is present.
+ ///
+ private sealed class SkipStubDriver : IDriver, ISkippedTagReporter
+ {
+ private static readonly DateTime FixedLastRead = new(2026, 8, 5, 12, 0, 0, DateTimeKind.Utc);
+
+ private readonly SkippedTagLedger _ledger = new();
+
+ /// When set, throws — the misbehaving-reporter case.
+ public bool ThrowOnGetSkippedTags { get; init; }
+
+ ///
+ public string DriverInstanceId => "skip-stub-1";
+
+ ///
+ public string DriverType => "Stub";
+
+ /// Rebuilds the ledger as a real driver's tag-table build does.
+ public void SetReport(int authored, IReadOnlyList skipped)
+ {
+ _ledger.BeginRebuild(authored);
+ foreach (var rawPath in skipped) _ledger.Skip(rawPath, "TagConfig did not map to a definition");
+ _ledger.Commit();
+ }
+
+ ///
+ public SkippedTagReport GetSkippedTags()
+ {
+ if (ThrowOnGetSkippedTags) throw new InvalidOperationException("reporter is broken");
+ return _ledger.Snapshot();
+ }
+
+ ///
+ public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs
index 568e672e..f63e8765 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs
@@ -123,7 +123,8 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
- IReadOnlyList? hostStatuses = null)
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null)
=> Interlocked.Increment(ref _count);
}
}