diff --git a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Components/Widgets/InstanceCard.razor b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Components/Widgets/InstanceCard.razor index 70206d8..2ef9ce7 100644 --- a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Components/Widgets/InstanceCard.razor +++ b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Components/Widgets/InstanceCard.razor @@ -214,18 +214,32 @@ /// Renders the cluster check's data as the mockup's monospace evidence line. /// /// - /// Located by the data it carries rather than by check name: the two apps register the same - /// shared cluster check under different names (akka-cluster vs akka), so keying on - /// either one silently drops the evidence line for the other. + /// + /// Located by the data it carries rather than by check name: the two apps register the + /// same shared checks under different names (akka-cluster vs akka, + /// active-node vs cluster-primary), so keying on either one silently drops + /// the evidence line for the other. + /// + /// + /// active and leader are shown side by side on purpose. They come + /// from different checks and are different nodes whenever any node has restarted — the + /// leader is the lowest-addressed Up member, the active node is the oldest. Showing both + /// turns the confusion that produced lmxopcua#494 into something an operator can read + /// straight off the card, instead of a silent disagreement between the chip and the tier. + /// /// private string? ClusterDataLine { get { - var entry = Instance.Report?.Entries.Values - .FirstOrDefault(e => e.DataString(LeaderResolver.LeaderDataKey) is { Length: > 0 }); + var entries = Instance.Report?.Entries.Values; + if (entries is null) + return null; - if (entry is null) + var cluster = entries.FirstOrDefault(e => e.DataString(LeaderResolver.LeaderDataKey) is { Length: > 0 }); + var active = entries.FirstOrDefault(e => e.DataString(LeaderResolver.ActiveNodeDataKey) is { Length: > 0 }); + + if (cluster is null && active is null) return null; var parts = new List(); @@ -236,10 +250,11 @@ parts.Add($"{Escape(label)} {Escape(value)}"); } - Add("leader", entry.DataString(LeaderResolver.LeaderDataKey)); - Add("members", entry.DataInt("memberCount")?.ToString()); - Add("unreachable", entry.DataInt("unreachableCount")?.ToString()); - Add("self", entry.DataString("selfAddress")); + Add("active", active?.DataString(LeaderResolver.ActiveNodeDataKey)); + Add("leader", cluster?.DataString(LeaderResolver.LeaderDataKey)); + Add("members", cluster?.DataInt("memberCount")?.ToString()); + Add("unreachable", cluster?.DataInt("unreachableCount")?.ToString()); + Add("self", (cluster ?? active)?.DataString("selfAddress")); return parts.Count == 0 ? null : string.Join(" · ", parts); } diff --git a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/LeaderResolver.cs b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/LeaderResolver.cs index 175b113..79f751a 100644 --- a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/LeaderResolver.cs +++ b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/LeaderResolver.cs @@ -11,7 +11,16 @@ namespace ZB.MOM.WW.Overview.Polling; /// public static class LeaderResolver { - /// The data key holding the Akka leader address. + /// + /// The data key holding the address of the node actually in charge — the oldest Up member, + /// published by the active tier's ActiveNodeHealthCheck from ZB.MOM.WW.Health 0.3.0. + /// + public const string ActiveNodeDataKey = "activeNode"; + + /// + /// The data key holding the Akka cluster leader address, published by + /// AkkaClusterHealthCheck. Fallback only — see . + /// public const string LeaderDataKey = "leader"; /// Resolves leader state for every cluster group present in . @@ -79,29 +88,52 @@ public static class LeaderResolver distinct); } - /// Finds the leader address one instance reports, whatever check carries it. + /// Finds the address of the in-charge node one instance reports. /// The instance whose report to search. - /// The reported leader address, or null when this instance publishes none. + /// The reported address, or null when this instance publishes none. /// - /// Matched on the DATA KEY, not on the check name. The two apps register the same shared - /// AkkaClusterHealthCheck under different names — ScadaBridge calls it - /// akka-cluster, OtOpcUa calls it akka — and a resolver keyed on either name - /// silently renders no leader chip for the other, which is exactly how this was missed until a - /// live rig showed OtOpcUa's groups bare while ScadaBridge's were fully populated. The data key - /// is the part of the contract the shared check actually owns. + /// + /// activeNode first, leader only as a fallback. They are different + /// nodes and the chip must show the first. The Akka cluster leader is the + /// lowest-addressed Up member — address order, no relationship to time — while the node + /// that actually holds the singletons and answers 200 on /health/active is the + /// oldest Up member. They agree only until some node restarts, after which the + /// restarted node rejoins youngest but keeps its address; observed live on the rebuilt + /// OtOpcUa rig, where the leader was central-1 while central-2 was the + /// active node. Ranking leader first would put the Active chip on the standby. + /// + /// + /// The fallback still matters: it is what an instance running Health < 0.3.0, or one + /// whose active tier was not probed, publishes. Showing the leader there is better than + /// showing nothing, and it is the pre-0.3.0 behaviour unchanged. + /// + /// + /// Matched on the DATA KEY, not on the check name. The two apps register the same shared + /// checks under different names — ScadaBridge calls its cluster check + /// akka-cluster, OtOpcUa calls it akka; the active check is + /// active-node in one and cluster-primary in the other — and a resolver + /// keyed on any of those names silently renders no chip for the other app, which is + /// exactly how this was missed until a live rig showed OtOpcUa's groups bare while + /// ScadaBridge's were fully populated. The data key is the part of the contract the + /// shared checks actually own. + /// /// internal static string? LeaderReportedBy(InstanceSnapshot instance) { if (instance.Report?.Entries is not { } entries) return null; + string? leaderFallback = null; + foreach (var entry in entries.Values) { - if (entry.DataString(LeaderDataKey) is { Length: > 0 } leader) - return leader; + if (entry.DataString(ActiveNodeDataKey) is { Length: > 0 } activeNode) + return activeNode; + + leaderFallback ??= entry.DataString(LeaderDataKey) is { Length: > 0 } leader ? leader : null; } - return null; + return leaderFallback; } /// diff --git a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/LeaderResolverTests.cs b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/LeaderResolverTests.cs index 49b6026..8704dc5 100644 --- a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/LeaderResolverTests.cs +++ b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/LeaderResolverTests.cs @@ -221,4 +221,63 @@ public class LeaderResolverTests Assert.Equal("akka.tcp://otopcua@node-a:4053", LeaderResolver.LeaderReportedBy(instance)); } + + [Fact] + public void ActiveNode_WinsOverLeader_WhenTheyDisagree() + { + // The two are DIFFERENT NODES after any restart: leader is the lowest-addressed Up member, + // the active node is the oldest. Observed live on the rebuilt OtOpcUa rig — leader central-1, + // active central-2 — so ranking leader first would put the Active chip on the standby, which + // is the same class of error as lmxopcua#494 rendered in the UI instead of in routing. + var body = HealthBody.WithData( + "Healthy", + ("akka", "Healthy", new Dictionary + { + ["leader"] = "akka.tcp://otopcua@central-1:4053", + }), + ("cluster-primary", "Healthy", new Dictionary + { + ["activeNode"] = "akka.tcp://otopcua@central-2:4053", + })); + var report = System.Text.Json.JsonSerializer.Deserialize(body)!; + var instance = Snapshots.Instance("central-1", "MAIN", "http://central-1:8080") with { Report = report }; + + Assert.Equal("akka.tcp://otopcua@central-2:4053", LeaderResolver.LeaderReportedBy(instance)); + } + + [Fact] + public void ActiveNode_ReportedByAStandby_NamesTheActiveNode_NotItself() + { + // Every member publishes who IS active, so a standby votes for the same address the active + // node does. That is what lets the group resolve with one unanimous chip instead of a + // manufactured disagreement, and it is why the standby's payload alone is enough. + var body = HealthBody.WithData( + "Unhealthy", + ("cluster-primary", "Unhealthy", new Dictionary + { + ["activeNode"] = "akka.tcp://otopcua@site-a-1:4053", + ["selfAddress"] = "akka.tcp://otopcua@site-a-2:4053", + })); + var report = System.Text.Json.JsonSerializer.Deserialize(body)!; + var instance = Snapshots.Instance("site-a-2", "SITE-A", "http://site-a-2:8080") with { Report = report }; + + Assert.Equal("akka.tcp://otopcua@site-a-1:4053", LeaderResolver.LeaderReportedBy(instance)); + } + + [Fact] + public void Leader_IsStillUsed_WhenNoActiveNodeIsPublished() + { + // An instance on Health < 0.3.0, or one whose active tier was not probed, publishes only + // `leader`. Showing it beats showing nothing, and it keeps the pre-0.3.0 behaviour intact. + var body = HealthBody.WithData( + "Healthy", + ("akka-cluster", "Healthy", new Dictionary + { + ["leader"] = "akka.tcp://scadabridge@central-a:4053", + })); + var report = System.Text.Json.JsonSerializer.Deserialize(body)!; + var instance = Snapshots.Instance("central-a", "central", "http://central-a:8080") with { Report = report }; + + Assert.Equal("akka.tcp://scadabridge@central-a:4053", LeaderResolver.LeaderReportedBy(instance)); + } } diff --git a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/PollingTestSupport.cs b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/PollingTestSupport.cs index 77774ca..8ac9f18 100644 --- a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/PollingTestSupport.cs +++ b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/PollingTestSupport.cs @@ -139,6 +139,31 @@ internal static class HealthBody return JsonSerializer.Serialize(payload); } + + /// + /// Builds a body whose entries carry arbitrary data, for the cases where the key under + /// test is not leader — notably the active tier's activeNode. + /// + public static string WithData(string status, params (string Name, string Status, Dictionary? Data)[] entries) + { + var payload = new + { + status, + totalDurationMs = 1.5, + entries = entries.ToDictionary( + e => e.Name, + e => (object)new + { + status = e.Status, + description = $"{e.Name} says {e.Status}", + durationMs = 0.5, + data = e.Data, + }, + StringComparer.Ordinal), + }; + + return JsonSerializer.Serialize(payload); + } } /// Builds values for the pure-logic tests.