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
166 lines
7.1 KiB
C#
166 lines
7.1 KiB
C#
using System.Text;
|
|
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
/// <summary>
|
|
/// #786: the <c>/fleet/diagnostics</c> "Faulted devices" tile counts DISTINCT faulted devices across a
|
|
/// cluster's nodes (a device faulted on both nodes of a redundant pair counts once), Faulted only.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FleetDiagnosticsAggregationTests
|
|
{
|
|
private static readonly DateTime Now = DateTime.UtcNow;
|
|
|
|
private static DriverInstanceDiagnostics Driver(
|
|
string name, int faulted, IReadOnlyList<string>? faultedHosts, int stopped = 0) =>
|
|
new(Guid.NewGuid(), name, "Degraded", 0, faulted, Now, stopped, faultedHosts);
|
|
|
|
private static NodeDiagnosticsSnapshot Node(string nodeId, params DriverInstanceDiagnostics[] drivers) =>
|
|
new(NodeId.Parse(nodeId), null, drivers, Now);
|
|
|
|
[Fact]
|
|
public void The_same_device_faulted_on_both_nodes_of_a_pair_counts_once()
|
|
{
|
|
var a = Node("node-a:4053", Driver("drv-modbus", 2, new[] { "plc-1:502", "plc-2:502" }));
|
|
var b = Node("node-b:4053", Driver("drv-modbus", 1, new[] { "PLC-1:502" })); // host names compare case-insensitively
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a, b }).ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Different_devices_faulted_on_each_node_are_unioned()
|
|
{
|
|
var a = Node("node-a:4053", Driver("drv-modbus", 1, new[] { "plc-1:502" }));
|
|
var b = Node("node-b:4053", Driver("drv-modbus", 1, new[] { "plc-2:502" }));
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a, b }).ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_same_host_name_under_two_drivers_is_two_devices()
|
|
{
|
|
var a = Node("node-a:4053",
|
|
Driver("drv-modbus", 1, new[] { "10.0.0.5" }),
|
|
Driver("drv-s7", 1, new[] { "10.0.0.5" }));
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a }).ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_faulted_single_host_driver_on_both_nodes_counts_once()
|
|
{
|
|
// No IHostConnectivityProbe: the driver itself is the device and carries no host name.
|
|
var a = Node("node-a:4053", Driver("drv-galaxy", 1, Array.Empty<string>()));
|
|
var b = Node("node-b:4053", Driver("drv-galaxy", 1, Array.Empty<string>()));
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a, b }).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stopped_devices_are_not_counted()
|
|
{
|
|
var a = Node("node-a:4053", Driver("drv-galaxy", 0, Array.Empty<string>(), stopped: 3));
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a }).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_pre_786_sender_without_host_names_is_combined_by_max_not_sum()
|
|
{
|
|
var oldSender = Node("node-a:4053", Driver("drv-modbus", 3, faultedHosts: null));
|
|
var newSender = Node("node-b:4053", Driver("drv-modbus", 2, new[] { "plc-1:502", "plc-2:502" }));
|
|
|
|
// Unnamed 3 (max over nodes) + named 2: the old sender's devices cannot be matched by name, so the
|
|
// figure is the best the wire can prove, never a doubled sum of the same unnamed devices.
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { oldSender, oldSender }).ShouldBe(3);
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { oldSender, newSender }).ShouldBe(5);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unreachable_node_empty_snapshot_contributes_nothing()
|
|
{
|
|
var a = Node("node-a:4053", Driver("drv-modbus", 1, new[] { "plc-1:502" }));
|
|
var unreachable = Node("node-b:4053");
|
|
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(new[] { a, unreachable }).ShouldBe(1);
|
|
FleetDiagnosticsAggregation.DistinctFaultedDevices(Array.Empty<NodeDiagnosticsSnapshot>()).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cluster_node_ids_are_the_enabled_rows_of_the_selected_nodes_cluster_self_first()
|
|
{
|
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
|
|
await using (var seed = new OtOpcUaConfigDbContext(options))
|
|
{
|
|
seed.ClusterNodes.AddRange(
|
|
Row("site-a-2:4053", "SITE-A"),
|
|
Row("site-a-1:4053", "SITE-A"),
|
|
Row("site-a-3:4053", "SITE-A", enabled: false),
|
|
Row("site-b-1:4053", "SITE-B"));
|
|
await seed.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
await using var db = new OtOpcUaConfigDbContext(options);
|
|
var (clusterId, nodeIds) = await FleetDiagnosticsAggregation.LoadClusterNodeIdsAsync(
|
|
db, "site-a-2:4053", TestContext.Current.CancellationToken);
|
|
clusterId.ShouldBe("SITE-A");
|
|
nodeIds.ShouldBe(new[] { "site-a-2:4053", "site-a-1:4053" });
|
|
|
|
var (unknownCluster, alone) = await FleetDiagnosticsAggregation.LoadClusterNodeIdsAsync(
|
|
db, "stray:4053", TestContext.Current.CancellationToken);
|
|
unknownCluster.ShouldBeNull();
|
|
alone.ShouldBe(new[] { "stray:4053" });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_snapshot_from_a_pre_786_sender_still_deserializes_over_the_akka_wire()
|
|
{
|
|
// The snapshot crosses Akka remoting with the default (Newtonsoft JSON) serializer. Strip the #786
|
|
// members from a serialized snapshot to reproduce an older sender's bytes.
|
|
using var system = ActorSystem.Create("diag-compat");
|
|
try
|
|
{
|
|
var snapshot = Node("node-a:4053", Driver("drv-modbus", 1, new[] { "plc-1:502" }, stopped: 2));
|
|
var serializer = system.Serialization.FindSerializerFor(snapshot);
|
|
var json = Encoding.UTF8.GetString(serializer.ToBinary(snapshot));
|
|
json.ShouldContain("StoppedDevices");
|
|
|
|
var tree = JObject.Parse(json);
|
|
foreach (var prop in tree.Descendants().OfType<JProperty>()
|
|
.Where(p => p.Name is "StoppedDevices" or "FaultedHostNames").ToList())
|
|
prop.Remove();
|
|
var oldJson = tree.ToString(Formatting.None);
|
|
oldJson.ShouldNotContain("StoppedDevices");
|
|
oldJson.ShouldNotContain("FaultedHostNames");
|
|
|
|
var back = (NodeDiagnosticsSnapshot)serializer.FromBinary(Encoding.UTF8.GetBytes(oldJson), typeof(NodeDiagnosticsSnapshot));
|
|
var driver = back.Drivers.ShouldHaveSingleItem();
|
|
driver.FaultedDevices.ShouldBe(1);
|
|
driver.StoppedDevices.ShouldBe(0);
|
|
driver.FaultedHostNames.ShouldBeNull();
|
|
}
|
|
finally
|
|
{
|
|
await system.Terminate();
|
|
}
|
|
}
|
|
|
|
private static ClusterNode Row(string nodeId, string clusterId, bool enabled = true) => new()
|
|
{
|
|
NodeId = nodeId, ClusterId = clusterId, Host = nodeId.Split(':')[0],
|
|
ApplicationUri = "urn:" + nodeId, CreatedBy = "test", Enabled = enabled,
|
|
};
|
|
}
|