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 2ef9ce7..74eb3a9 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
@@ -42,6 +42,16 @@
@ChecksText
+ @if (SessionText is { Length: > 0 } sessionText)
+ {
+ @* Only the gateway publishes these counts, so the row is absent everywhere else
+ rather than showing an empty or zero value on apps the concept does not apply to. *@
+
+ MXAccess
+ @sessionText
+
+ }
+
@if (Instance.Error is { Length: > 0 } error)
{
@@ -260,6 +270,66 @@
}
}
+ ///
+ /// The MXAccess session check's entry from the active tier, or null when this instance
+ /// publishes none.
+ ///
+ ///
+ ///
+ /// Located by the data it carries rather than by check name, for the same reason
+ /// is: a name is a rename away from silently dropping the
+ /// line, whereas the total/ready/faulted triple is the check's
+ /// actual contract. All three are required so a future check that happens to publish a
+ /// lone total cannot be mistaken for this one.
+ ///
+ ///
+ /// It comes from , never the ready tier —
+ /// the gateway registers this check on active because a gateway with no sessions
+ /// is still ready to serve.
+ ///
+ ///
+ private ZbHealthEntry? SessionEntry =>
+ Instance.ActiveReport?.Entries.Values.FirstOrDefault(e =>
+ e.DataInt("total") is not null
+ && e.DataInt("ready") is not null
+ && e.DataInt("faulted") is not null);
+
+ ///
The session counts as a short face-level line, or null when there are none to show.
+ ///
+ /// Rendered on the card face rather than inside the collapsed check list: "how many MXAccess
+ /// connections are up" is a number an operator scans for, and a value behind a disclosure
+ /// triangle is one nobody sees. Zero is shown, not hidden — a gateway holding no sessions at
+ /// all is precisely the interesting case, and suppressing it would make the row appear only
+ /// when the news is good.
+ ///
+ private string? SessionText
+ {
+ get
+ {
+ var entry = SessionEntry;
+ if (entry is null)
+ return null;
+
+ int total = entry.DataInt("total") ?? 0;
+ if (total == 0)
+ return "0";
+
+ var parts = new List
{ total.ToString(System.Globalization.CultureInfo.InvariantCulture) };
+ parts.Add($"{entry.DataInt("ready") ?? 0} ready");
+
+ foreach (var (key, label) in new[] { ("faulted", "faulted"), ("starting", "starting") })
+ {
+ if (entry.DataInt(key) is > 0 and var n)
+ parts.Add($"{n} {label}");
+ }
+
+ return string.Join(" · ", parts);
+ }
+ }
+
+ /// Highlights the session row only when a session is actually broken.
+ private string? SessionClass => SessionEntry?.DataInt("faulted") is > 0 ? "bad" : null;
+
private static string DotClass(string? status) => status switch
{
"Healthy" => "ok",
diff --git a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs
index 7805f2a..79b04c1 100644
--- a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs
+++ b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs
@@ -158,8 +158,16 @@ public sealed class OverviewPollerService : BackgroundService
// timeout to learn what we already know, and ObserveActive maps a missing probe to Unknown
// anyway. A 503 from ready is NOT a skip — that instance is talking, and whether it is the
// active half is exactly what an operator wants to know about a sick node.
+ //
+ // Deliberately NOT gated on HasActiveRole. The active tier answers two different questions
+ // and they have to be separated: (1) am I the active half of a pair, and (2) what does this
+ // tier report about me. A standalone gateway has no answer to (1) but can have plenty to say
+ // about (2) — MxAccessGateway publishes its live MXAccess session counts there. Gating the
+ // probe on the role made that data unreachable, and the tempting fix (declare the gateway
+ // HasActiveRole) is wrong: it would paint an Active/Standby badge asserting a role that does
+ // not exist. So probe unconditionally and let ObserveActive stay role-gated below.
HealthProbeResult? active = null;
- if (target.Instance.HasActiveRole && ready.Reachable)
+ if (ready.Reachable)
active = await probe.ProbeStatusAsync(target.ActiveUrl, timeout, cancellationToken).ConfigureAwait(false);
var observed = StatusDerivation.Observe(ready);
@@ -178,6 +186,7 @@ public sealed class OverviewPollerService : BackgroundService
Latency = ready.Latency,
Error = ready.Error,
Report = ready.Report,
+ ActiveReport = active?.Report,
};
}
diff --git a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewSnapshot.cs b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewSnapshot.cs
index b12d7b9..5addf49 100644
--- a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewSnapshot.cs
+++ b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewSnapshot.cs
@@ -67,6 +67,17 @@ public sealed record InstanceSnapshot
/// The last parsed ready-tier body, which drives the per-check detail list.
public ZbHealthReport? Report { get; init; }
+ ///
+ /// The last parsed active-tier body, when that tier answered with one.
+ ///
+ ///
+ /// Kept separate from rather than merged into it: the two tiers answer
+ /// different questions, and a check name is only unique within a tier. It is null whenever the
+ /// active tier was not probed, did not answer, or answered without a canonical body — every one
+ /// of which is normal, so nothing driven by it may be required for the card to render.
+ ///
+ public ZbHealthReport? ActiveReport { get; init; }
+
/// This instance's effective staleness window.
public TimeSpan StaleAfter { get; init; }
diff --git a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/appsettings.Wonder.json b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/appsettings.Wonder.json
index 96744ae..52af02c 100644
--- a/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/appsettings.Wonder.json
+++ b/ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/appsettings.Wonder.json
@@ -118,9 +118,17 @@
"ManagementLabel": "Dashboard",
"Instances": [
{
- // HasActiveRole false: mxgw is a standalone gateway with no cluster. Its
- // /health/active does answer 200, but only because the active tier runs an EMPTY
- // check set — an Active badge here would assert a role that does not exist.
+ // HasActiveRole false: mxgw is a standalone gateway with no cluster, so an Active
+ // badge here would assert a role that does not exist.
+ //
+ // This flag no longer decides whether the active tier is PROBED — since 2026-08-12 it
+ // always is, for any reachable instance. That tier is where mxgw publishes its live
+ // MXAccess session counts (the "MXAccess" row on the card), and gating the probe on the
+ // role made those counts unreachable. Do NOT flip this to true to get them back: the
+ // probe and the badge are deliberately separate, and true would paint a false badge.
+ //
+ // (The previous note here said the active tier ran an EMPTY check set. That was true
+ // when written and stopped being true when the gateway added its session check.)
"Name": "wonder",
"BaseUrl": "http://127.0.0.1:5130",
"ManagementUrl": "http://10.220.157.247:5130/",
diff --git a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs
index fb3d328..81dfe0f 100644
--- a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs
+++ b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs
@@ -21,6 +21,7 @@ public class InstanceCardTests : TestContext
TimeSpan? staleAfter = null,
string? error = null,
ZbHealthReport? report = null,
+ ZbHealthReport? activeReport = null,
string? managementUrl = null) =>
new()
{
@@ -38,6 +39,7 @@ public class InstanceCardTests : TestContext
Latency = TimeSpan.FromMilliseconds(18),
Error = error,
Report = report,
+ ActiveReport = activeReport,
StaleAfter = staleAfter ?? TimeSpan.FromSeconds(45),
};
@@ -246,4 +248,78 @@ public class InstanceCardTests : TestContext
private static ZbHealthReport Report(params (string Name, string Status, string? Leader)[] entries) =>
System.Text.Json.JsonSerializer.Deserialize(HealthBody.Ready("Healthy", entries))!;
+
+ // ---- MXAccess session counts (active tier) -------------------------------------------------
+ // The gateway publishes live MXAccess session counts on its ACTIVE tier. These pin that the
+ // number reaches the card face, since a count behind a disclosure triangle is one nobody reads.
+
+ private static ZbHealthReport SessionReport(int total, int ready, int faulted = 0, int starting = 0) =>
+ System.Text.Json.JsonSerializer.Deserialize(
+ HealthBody.WithData(
+ "Healthy",
+ ("mxaccess-sessions", "Healthy", new Dictionary(StringComparer.Ordinal)
+ {
+ ["total"] = total,
+ ["ready"] = ready,
+ ["faulted"] = faulted,
+ ["starting"] = starting,
+ ["closing"] = 0,
+ })))!;
+
+ [Fact]
+ public void Card_ShowsMxAccessSessionCount_FromTheActiveTier()
+ {
+ var card = Render(Card(activeReport: SessionReport(total: 2, ready: 2)));
+
+ var row = card.Find("[data-testid=mxaccess-sessions]");
+ Assert.Contains("MXAccess", row.TextContent, StringComparison.Ordinal);
+ Assert.Contains("2 · 2 ready", row.TextContent, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Card_ShowsZeroSessions_RatherThanHidingTheRow()
+ {
+ // A gateway holding no MXAccess sessions is the interesting case. Suppressing zero would
+ // make the row appear only when the news is good, which is when nobody needs it.
+ var card = Render(Card(activeReport: SessionReport(total: 0, ready: 0)));
+
+ Assert.Equal("0", card.Find("[data-testid=mxaccess-sessions] .v").TextContent.Trim());
+ }
+
+ [Fact]
+ public void Card_CallsOutFaultedSessions()
+ {
+ var card = Render(Card(activeReport: SessionReport(total: 3, ready: 2, faulted: 1)));
+
+ var value = card.Find("[data-testid=mxaccess-sessions] .v");
+ Assert.Contains("1 faulted", value.TextContent, StringComparison.Ordinal);
+ Assert.Contains("bad", value.ClassList, StringComparer.Ordinal);
+ }
+
+ [Fact]
+ public void Card_OmitsTheMxAccessRow_ForAppsThatPublishNoSessionCounts()
+ {
+ // Positive control first: the SAME assertion must find the row when the data IS present,
+ // or "not found" below would prove nothing about the omission.
+ Assert.NotEmpty(Render(Card(activeReport: SessionReport(1, 1))).FindAll("[data-testid=mxaccess-sessions]"));
+
+ Assert.Empty(Render(Card()).FindAll("[data-testid=mxaccess-sessions]"));
+ }
+
+ [Fact]
+ public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts()
+ {
+ // The row is located by the total/ready/faulted triple, not by check name. An active-node
+ // check publishes its own data on the same tier and must not be mistaken for a session count.
+ var activeNode = System.Text.Json.JsonSerializer.Deserialize(
+ HealthBody.WithData(
+ "Healthy",
+ ("active-node", "Healthy", new Dictionary(StringComparer.Ordinal)
+ {
+ ["activeNode"] = "site-a-a",
+ ["total"] = 3,
+ })))!;
+
+ Assert.Empty(Render(Card(activeReport: activeNode)).FindAll("[data-testid=mxaccess-sessions]"));
+ }
}
diff --git a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/OverviewPollerServiceTests.cs b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/OverviewPollerServiceTests.cs
index 0da8a69..6f1be7a 100644
--- a/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/OverviewPollerServiceTests.cs
+++ b/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/OverviewPollerServiceTests.cs
@@ -75,7 +75,10 @@ public class OverviewPollerServiceTests
Assert.Equal(2, snapshot.AllInstances.Count());
Assert.All(snapshot.AllInstances, i => Assert.Equal(InstanceStatus.Up, i.Status));
- Assert.Equal(2, handler.Requests.Count);
+ // Counted on the READY tier alone: one ready probe == one polled instance, which is what
+ // this test means. A reachable instance is also probed on the active tier for its data, so
+ // a raw request count would pin the number of tiers rather than the polling behaviour.
+ Assert.Equal(2, ReadyProbes(handler));
}
[Fact]
@@ -92,12 +95,18 @@ public class OverviewPollerServiceTests
}
[Fact]
- public async Task Sweep_ActiveTierProbedOnlyForInstancesThatHaveTheRole()
+ public async Task Sweep_ProbesTheActiveTierEvenWithoutTheRole_ButStillAwardsNoBadge()
{
+ // The active tier answers two separable questions: "am I the active half of a pair" and
+ // "what does this tier report about me". A standalone gateway has no answer to the first
+ // and plenty to say about the second (its live MXAccess session counts), so the PROBE is
+ // unconditional while the BADGE stays role-gated. Declaring such a gateway hasActiveRole
+ // would fetch the data at the cost of asserting a role that does not exist.
var handler = new RoutingHandler()
.Json("http://a:8080/health/ready", HttpStatusCode.OK, HealthBody.Ready("Healthy"))
.Status("http://a:8080/health/active", HttpStatusCode.OK)
- .Json("http://gw:5120/health/ready", HttpStatusCode.OK, HealthBody.Ready("Healthy"));
+ .Json("http://gw:5120/health/ready", HttpStatusCode.OK, HealthBody.Ready("Healthy"))
+ .Status("http://gw:5120/health/active", HttpStatusCode.OK);
var (poller, _) = Build(
Registry(App("App", Node("a", "http://a:8080", "pair", hasActiveRole: true), Node("gw", "http://gw:5120"))),
handler,
@@ -105,12 +114,39 @@ public class OverviewPollerServiceTests
var snapshot = await poller.SweepAsync();
- Assert.DoesNotContain("http://gw:5120/health/active", handler.Requests, StringComparer.Ordinal);
+ Assert.Contains("http://gw:5120/health/active", handler.Requests, StringComparer.Ordinal);
var byName = snapshot.AllInstances.ToDictionary(i => i.Instance, StringComparer.Ordinal);
Assert.Equal(ActiveState.Active, byName["a"].Active);
Assert.Equal(ActiveState.NotApplicable, byName["gw"].Active);
}
+ [Fact]
+ public async Task Sweep_RetainsTheActiveTierBody_SoItsDataCanBeRendered()
+ {
+ var body = HealthBody.WithData(
+ "Healthy",
+ ("mxaccess-sessions", "Healthy", new Dictionary(StringComparer.Ordinal)
+ {
+ ["total"] = 2,
+ ["ready"] = 2,
+ ["faulted"] = 0,
+ }));
+ var handler = new RoutingHandler()
+ .Json("http://gw:5120/health/ready", HttpStatusCode.OK, HealthBody.Ready("Healthy"))
+ .Json("http://gw:5120/health/active", HttpStatusCode.OK, body);
+ var (poller, _) = Build(
+ Registry(App("App", Node("gw", "http://gw:5120"))),
+ handler,
+ new FakeTimeProvider(Start));
+
+ var instance = Assert.Single((await poller.SweepAsync()).AllInstances);
+
+ Assert.NotNull(instance.ActiveReport);
+ Assert.Equal(2, instance.ActiveReport!.Entries["mxaccess-sessions"].DataInt("total"));
+ // The tiers stay separate: a check name is only unique within a tier.
+ Assert.DoesNotContain("mxaccess-sessions", instance.Report!.Entries.Keys, StringComparer.Ordinal);
+ }
+
[Fact]
public async Task Sweep_StandbyAnswers503OnActive_AndIsStillUp()
{
@@ -220,7 +256,9 @@ public class OverviewPollerServiceTests
time.Advance(TimeSpan.FromSeconds(1));
await poller.SweepAsync();
- Assert.Single(handler.Requests);
+ // One ready probe == one poll. The second sweep must not poll at all; counting ready
+ // probes says exactly that without also asserting how many tiers a poll touches.
+ Assert.Equal(1, ReadyProbes(handler));
}
[Fact]
@@ -245,8 +283,8 @@ public class OverviewPollerServiceTests
time.Advance(TimeSpan.FromSeconds(5));
}
- Assert.Equal(4, handler.Requests.Count(r => r.Contains("fast", StringComparison.Ordinal)));
- Assert.Equal(1, handler.Requests.Count(r => r.Contains("slow", StringComparison.Ordinal)));
+ Assert.Equal(4, ReadyProbes(handler, "fast"));
+ Assert.Equal(1, ReadyProbes(handler, "slow"));
}
[Fact]
@@ -262,7 +300,7 @@ public class OverviewPollerServiceTests
time.Advance(TimeSpan.FromMilliseconds(9_900));
await poller.SweepAsync();
- Assert.Equal(2, handler.Requests.Count);
+ Assert.Equal(2, ReadyProbes(handler));
}
[Fact]
@@ -430,4 +468,14 @@ public class OverviewPollerServiceTests
return await inner.HandleAsync(request).ConfigureAwait(false);
}
}
+
+ ///
+ /// Ready-tier probes only - one per polled instance. Tests about polling cadence must not
+ /// count total requests, which also includes the active-tier probe every reachable instance
+ /// now receives.
+ ///
+ private static int ReadyProbes(RoutingHandler handler, string? hostContains = null) =>
+ handler.Requests.Count(r =>
+ r.EndsWith("/health/ready", StringComparison.Ordinal)
+ && (hostContains is null || r.Contains(hostContains, StringComparison.Ordinal)));
}