39db75083d
A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.
New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.
The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.
Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.
Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.
Three details that are load-bearing rather than incidental:
- The skip digest is part of the publish fingerprint. A skipped tag leaves
every other fingerprint component identical to a clean driver's, so without
it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
signal -- the trap that already bit the rediscovery and host-status
channels. The digest covers the sample, not just the counts: a redeploy
that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
it is showing fewer than it counted. A truncated list that looked complete
would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
previous report standing. A partial snapshot would read as "we now skip
fewer tags", the opposite of the truth.
The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.
Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
309 lines
12 KiB
C#
309 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
|
|
|
|
/// <summary>
|
|
/// Covers <see cref="HostsDriverView.Build"/>: the pure, DB-agnostic view-model builder that
|
|
/// groups driver-health snapshots by cluster, attaches each cluster's nodes, and enriches each
|
|
/// driver row with the configured name + type. The builder is null-safe and stable-ordered so
|
|
/// the (later) <c>/hosts</c> Razor section renders deterministically.
|
|
/// </summary>
|
|
public sealed class HostsDriverViewTests
|
|
{
|
|
private static readonly DateTime When = new(2026, 6, 18, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static DriverHealthChanged Snap(string cluster, string instance, string state = "Healthy") =>
|
|
new(cluster, instance, state, null, null, 0, When);
|
|
|
|
private static HostsNodeInfo Node(string cluster, string node, string host = "h", int port = 4840) =>
|
|
new(cluster, node, host, port);
|
|
|
|
private static HostsDriverInstanceInfo Instance(string instance, string cluster, string name, string type) =>
|
|
new(instance, cluster, name, type);
|
|
|
|
[Fact]
|
|
public void Build_groups_two_clusters_with_their_nodes_and_snapshots()
|
|
{
|
|
var snaps = new[]
|
|
{
|
|
Snap("MAIN", "drv-a"),
|
|
Snap("EDGE", "drv-b"),
|
|
};
|
|
var nodes = new[]
|
|
{
|
|
Node("MAIN", "MAIN-1"),
|
|
Node("EDGE", "EDGE-1"),
|
|
};
|
|
|
|
var groups = HostsDriverView.Build(snaps, nodes, instances: null);
|
|
|
|
groups.Count.ShouldBe(2);
|
|
var main = groups.Single(g => g.ClusterId == "MAIN");
|
|
var edge = groups.Single(g => g.ClusterId == "EDGE");
|
|
|
|
main.Nodes.Select(n => n.NodeId).ShouldBe(new[] { "MAIN-1" });
|
|
main.Drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-a" });
|
|
edge.Nodes.Select(n => n.NodeId).ShouldBe(new[] { "EDGE-1" });
|
|
edge.Drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-b" });
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_enriches_driver_row_from_matching_instance()
|
|
{
|
|
var groups = HostsDriverView.Build(
|
|
new[] { Snap("MAIN", "drv-a", "Faulted") },
|
|
nodes: null,
|
|
new[] { Instance("drv-a", "MAIN", "Pump House Modbus", "Modbus") });
|
|
|
|
var row = groups.Single().Drivers.Single();
|
|
row.DriverInstanceId.ShouldBe("drv-a");
|
|
row.Name.ShouldBe("Pump House Modbus");
|
|
row.DriverType.ShouldBe("Modbus");
|
|
row.State.ShouldBe("Faulted");
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_leaves_name_and_type_null_for_unknown_driver()
|
|
{
|
|
var groups = HostsDriverView.Build(
|
|
new[] { Snap("MAIN", "orphan") },
|
|
nodes: null,
|
|
new[] { Instance("drv-a", "MAIN", "Known", "Modbus") });
|
|
|
|
var row = groups.Single().Drivers.Single(d => d.DriverInstanceId == "orphan");
|
|
row.Name.ShouldBeNull();
|
|
row.DriverType.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_includes_cluster_with_nodes_but_no_snapshots()
|
|
{
|
|
var groups = HostsDriverView.Build(
|
|
snapshots: null,
|
|
new[] { Node("MAIN", "MAIN-1") },
|
|
instances: null);
|
|
|
|
var group = groups.Single();
|
|
group.ClusterId.ShouldBe("MAIN");
|
|
group.Nodes.Count.ShouldBe(1);
|
|
group.Drivers.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_with_empty_inputs_returns_empty_list()
|
|
{
|
|
HostsDriverView.Build(
|
|
Array.Empty<DriverHealthChanged>(),
|
|
Array.Empty<HostsNodeInfo>(),
|
|
Array.Empty<HostsDriverInstanceInfo>()).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_with_all_null_inputs_returns_empty_list_without_throwing()
|
|
{
|
|
HostsDriverView.Build(snapshots: null, nodes: null, instances: null).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_orders_drivers_by_name_then_instance_id()
|
|
{
|
|
var snaps = new[]
|
|
{
|
|
Snap("MAIN", "drv-z"), // no instance -> null name -> sorts by id "drv-z"
|
|
Snap("MAIN", "drv-a"), // no instance -> null name -> sorts by id "drv-a"
|
|
Snap("MAIN", "drv-m"), // named "Alpha"
|
|
};
|
|
var instances = new[]
|
|
{
|
|
Instance("drv-m", "MAIN", "Alpha", "Modbus"),
|
|
};
|
|
|
|
var drivers = HostsDriverView.Build(snaps, nodes: null, instances).Single().Drivers;
|
|
|
|
// Sort key is (Name ?? DriverInstanceId): "Alpha"(drv-m), "drv-a", "drv-z".
|
|
drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-m", "drv-a", "drv-z" });
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_takes_first_when_instance_id_appears_twice()
|
|
{
|
|
var groups = HostsDriverView.Build(
|
|
new[] { Snap("MAIN", "drv-a") },
|
|
nodes: null,
|
|
new[]
|
|
{
|
|
Instance("drv-a", "MAIN", "First", "Modbus"),
|
|
Instance("drv-a", "MAIN", "Second", "S7"),
|
|
});
|
|
|
|
var row = groups.Single().Drivers.Single();
|
|
row.Name.ShouldBe("First");
|
|
row.DriverType.ShouldBe("Modbus");
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_orders_clusters_case_insensitively()
|
|
{
|
|
var groups = HostsDriverView.Build(
|
|
new[] { Snap("zeta", "d1"), Snap("Alpha", "d2") },
|
|
new[] { Node("Beta", "b1") },
|
|
instances: null);
|
|
|
|
groups.Select(g => g.ClusterId).ShouldBe(new[] { "Alpha", "Beta", "zeta" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-host connectivity flows through to the row (Gitea #521), and <c>DegradedHosts</c> picks out
|
|
/// exactly the hosts an operator needs to look at. This is the case the driver-level Status chip
|
|
/// cannot express: the driver is Healthy and one of its devices is not.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_carries_host_statuses_and_flags_only_the_degraded_ones()
|
|
{
|
|
var snapshot = Snap("MAIN", "drv-a") with
|
|
{
|
|
HostStatuses =
|
|
[
|
|
new HostConnectivityStatus("plc-a", HostState.Running, When),
|
|
new HostConnectivityStatus("plc-b", HostState.Stopped, When),
|
|
new HostConnectivityStatus("plc-c", HostState.Faulted, When),
|
|
],
|
|
};
|
|
|
|
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
|
|
|
|
row.State.ShouldBe("Healthy");
|
|
row.HostStatuses!.Count.ShouldBe(3);
|
|
row.DegradedHosts.Select(h => h.HostName).ShouldBe(["plc-b", "plc-c"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="HostState.Unknown"/> counts as degraded. A probe that has not completed its first tick
|
|
/// — or one a driver failed to start at all, which AbCip logs explicitly — reports Unknown, and
|
|
/// rendering that as healthy is how an unstarted probe stays invisible.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Unknown_host_state_counts_as_degraded()
|
|
{
|
|
var snapshot = Snap("MAIN", "drv-a") with
|
|
{
|
|
HostStatuses = [new HostConnectivityStatus("plc-a", HostState.Unknown, When)],
|
|
};
|
|
|
|
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
|
|
|
|
row.DegradedHosts.ShouldHaveSingleItem().HostName.ShouldBe("plc-a");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A driver with no probe keeps a null host list — distinct from a probe reporting zero hosts. The
|
|
/// /hosts column renders "—" for the former and "0 hosts" for the latter, and collapsing them would
|
|
/// claim every probe-less driver's devices are fine.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_driver_without_host_statuses_keeps_null_and_reports_no_degraded_hosts()
|
|
{
|
|
var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null)
|
|
.Single().Drivers.Single();
|
|
|
|
row.HostStatuses.ShouldBeNull();
|
|
row.DegradedHosts.ShouldBeEmpty();
|
|
}
|
|
|
|
// ---- skipped tags (Gitea #541) ----
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>A complete sample carries no truncation note.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|