43bdeff2b6
Owner decision 2026-11-11: /fleet/diagnostics "Faulted devices" counts DISTINCT devices across the nodes of the selected node's cluster, Faulted state only. - DriverHostActor: FaultedDevices = HostState.Faulted only (Stopped no longer counted); new StoppedDevices + FaultedHostNames. Counting extracted to the testable DiagnosticsFromHealth. - DriverInstanceDiagnostics: StoppedDevices (default 0) and FaultedHostNames (default null) are additive with defaults, so a pre-#786 sender's snapshot still deserializes over Akka's default JSON serializer (pinned by a test). - FleetDiagnosticsAggregation (pure): identity key = (driver-instance string id, HostName), case-insensitive; a single-host driver / nameless pre-#786 count is combined by MAX across nodes, never summed. Cluster peers come from enabled ClusterNode rows of the selected node's ClusterId. - FleetDiagnostics.razor: tile fetches the cluster peers and shows the distinct count + "n of m nodes answered"; per-driver table gains a Stopped devices column. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
123 lines
5.5 KiB
C#
123 lines
5.5 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Review 02 U1: <c>GetDiagnostics</c> answered a placeholder — <c>Guid.Empty</c>, "Spawned", zero devices and
|
|
/// "now" for every child, in every behaviour — so the snapshot looked live and said nothing. It now carries
|
|
/// each child's row id, instance id, last-published health state, device counts and last change time.
|
|
/// </summary>
|
|
public sealed class DriverHostActorDiagnosticsTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
|
|
private const string DriverId = "DI-1";
|
|
|
|
[Fact]
|
|
public async Task A_connected_child_reports_its_row_id_name_health_state_and_device_count()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var rowId = Guid.NewGuid();
|
|
var before = DateTime.UtcNow.AddSeconds(-1);
|
|
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
|
db, RevA, new[] { (DriverId, "Line1-Modbus", "dev1", "speed", "Float", false) },
|
|
Array.Empty<(string, string, string, string, string?)>(),
|
|
enabled: true, driverRowIds: new Dictionary<string, Guid> { [DriverId] = rowId });
|
|
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
|
|
|
var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Any(d => d.State == "Healthy"));
|
|
|
|
snapshot.NodeId.ShouldBe(TestNode);
|
|
snapshot.CurrentRevision.ShouldBe(RevA);
|
|
snapshot.RunningFromCache.ShouldBeFalse();
|
|
var driver = snapshot.Drivers.ShouldHaveSingleItem();
|
|
driver.DriverInstanceId.ShouldBe(rowId, "the real row id, not Guid.Empty");
|
|
driver.Name.ShouldBe(DriverId, "the string instance id every other surface keys on");
|
|
driver.State.ShouldBe("Healthy");
|
|
driver.ConnectedDevices.ShouldBe(1);
|
|
driver.FaultedDevices.ShouldBe(0);
|
|
driver.StoppedDevices.ShouldBe(0);
|
|
driver.LastChangeUtc.ShouldBeGreaterThan(before);
|
|
driver.LastChangeUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stubbed_child_reports_Stubbed_with_no_devices()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
// The factory answers for a type the artifact does not use, so the host stubs the child.
|
|
var factory = new RecordingSubscribableDriverFactory("NotModbus");
|
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
|
db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
|
Array.Empty<(string, string, string, string, string?)>(), enabled: true);
|
|
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
|
|
|
var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Count == 1);
|
|
|
|
var driver = snapshot.Drivers.Single();
|
|
driver.State.ShouldBe("Stubbed");
|
|
driver.ConnectedDevices.ShouldBe(0);
|
|
driver.FaultedDevices.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Faulted_counts_only_Faulted_hosts_and_Stopped_is_reported_separately()
|
|
{
|
|
// #786: a Stopped host used to be counted as faulted.
|
|
var now = DateTime.UtcNow;
|
|
var health = new ChildHealthRecord(
|
|
DriverState.Degraded, now, null,
|
|
new[]
|
|
{
|
|
new HostConnectivityStatus("plc-a:502", HostState.Running, now),
|
|
new HostConnectivityStatus("plc-b:502", HostState.Faulted, now),
|
|
new HostConnectivityStatus("plc-c:502", HostState.Stopped, now),
|
|
new HostConnectivityStatus("plc-d:502", HostState.Stopped, now),
|
|
new HostConnectivityStatus("plc-e:502", HostState.Unknown, now),
|
|
},
|
|
now);
|
|
|
|
var row = DriverHostActor.DiagnosticsFromHealth(Guid.NewGuid(), DriverId, health);
|
|
|
|
row.ConnectedDevices.ShouldBe(1);
|
|
row.FaultedDevices.ShouldBe(1);
|
|
row.StoppedDevices.ShouldBe(2);
|
|
row.FaultedHostNames.ShouldBe(new[] { "plc-b:502" });
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DriverState.Faulted, 0, 1)]
|
|
[InlineData(DriverState.Healthy, 1, 0)]
|
|
[InlineData(DriverState.Degraded, 1, 0)]
|
|
public void A_single_host_driver_is_one_device_with_no_host_name(DriverState state, int connected, int faulted)
|
|
{
|
|
var row = DriverHostActor.DiagnosticsFromHealth(
|
|
Guid.NewGuid(), DriverId, new ChildHealthRecord(state, null, null, null, DateTime.UtcNow));
|
|
|
|
row.ConnectedDevices.ShouldBe(connected);
|
|
row.FaultedDevices.ShouldBe(faulted);
|
|
row.StoppedDevices.ShouldBe(0);
|
|
row.FaultedHostNames.ShouldNotBeNull().ShouldBeEmpty();
|
|
}
|
|
|
|
private async Task<NodeDiagnosticsSnapshot> AwaitDiagnosticsAsync(IActorRef host, Func<NodeDiagnosticsSnapshot, bool> ready)
|
|
{
|
|
NodeDiagnosticsSnapshot? snapshot = null;
|
|
await AwaitAssertAsync(async () =>
|
|
{
|
|
snapshot = await host.Ask<NodeDiagnosticsSnapshot>(new GetDiagnostics(CorrelationId.NewId()), PresenceBudget);
|
|
ready(snapshot).ShouldBeTrue();
|
|
}, PresenceBudget);
|
|
return snapshot!;
|
|
}
|
|
}
|