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))); }