fix(overview): rank activeNode over leader for the Active chip

The dashboard derived every leader chip from AkkaClusterHealthCheck's `leader`
data — ClusterState.Leader, the LOWEST-ADDRESSED Up member. That is not the node
in charge. The node holding the singletons, gated for writes, and answering 200
on /health/active is the OLDEST Up member. The two agree only until some node
restarts, after which the restarted node rejoins youngest but keeps its address.

Observed live on the rebuilt OtOpcUa rig during the Health 0.3.0 work: leader
central-1, active node central-2. The dashboard would have put the Active chip on
central-1 while /health/active said central-2 — the chip contradicting the tier
it exists to visualise. The Phase 4 acceptance run missed it because the rig had
just been formed, the one state in which the two orderings agree.

Health 0.3.0's active tier now publishes `activeNode` on every member, including
standbys, so the fix is to prefer it and keep `leader` as the fallback for an
instance on an older Health or whose active tier was not probed.

The instance card now shows `active` and `leader` side by side rather than
swapping one for the other: they are legitimately different nodes, and showing
both turns a silent contradiction into something readable at 3am.

Tests: 177 (+3) — precedence when the two disagree, a standby naming the active
node rather than itself, and the fallback still working with no activeNode.
This commit is contained in:
Joseph Doherty
2026-07-24 13:27:07 -04:00
parent 6a4fa220de
commit 344bdc4da5
4 changed files with 153 additions and 22 deletions
@@ -139,6 +139,31 @@ internal static class HealthBody
return JsonSerializer.Serialize(payload);
}
/// <summary>
/// Builds a body whose entries carry arbitrary <c>data</c>, for the cases where the key under
/// test is not <c>leader</c> — notably the active tier's <c>activeNode</c>.
/// </summary>
public static string WithData(string status, params (string Name, string Status, Dictionary<string, object>? 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);
}
}
/// <summary>Builds <see cref="InstanceSnapshot"/> values for the pure-logic tests.</summary>