From 846b77f5b4df77562fa66a5edf2d9e40fe889262 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 12 Aug 2026 05:16:25 -0400 Subject: [PATCH] feat(overview): show historian session-pool depths on the HistorianGateway card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked whether the gateway's new session row also covered HistorianGateway. It did not, and could not: HistorianGateway publishes its pool depths on a different TIER with a different KEY SET. mxaccessgw active tier total / ready / faulted one check historiangw READY tier warmSessions / pool / … TWO checks The session row is located by the total/ready/faulted triple, so it correctly ignored these rather than half-matching them. This adds a sibling row instead of unifying the two behind a shared shape, because there is no such shape - inventing one would mean rendering a contract neither app publishes. No poller change was needed: the ready tier's body is already retained, so this is entirely a rendering change. Labelled "Sessions pooled", deliberately not "connections". warmSessions is pool DEPTH - warm pre-authenticated sessions held ready - and sessions are leased on demand, so the event pool legitimately sits at 0 until something reads events. Calling that a connection count would make a normal idle pool read as an outage, so 0 is shown plainly and the row goes red only on consecutiveFactoryFailures, which is an actual fault. Pools render read-then-event rather than in dictionary order, so the line is stable across polls and the pool an operator cares about comes first. 188 tests (183 + 5): ordering, that an idle pool is not painted red, that a failing factory is, a positive control on the omission assertion, and one pinning that neither app's row can render the other's data. --- .../Components/Widgets/InstanceCard.razor | 61 ++++++++++++++++ .../InstanceCardTests.cs | 69 +++++++++++++++++++ 2 files changed, 130 insertions(+) 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 74eb3a9..a1c90ad 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 @@ -52,6 +52,16 @@ } + @if (HistorianPoolText is { Length: > 0 } poolText) + { + @* "pooled" and not "connections": this is warm pool depth, and an idle pool sitting at + 0 is normal. Labelling it as connections would read as an outage. *@ +
+ Sessions pooled + @poolText +
+ } + @if (Instance.Error is { Length: > 0 } error) {
@@ -330,6 +340,57 @@ /// Highlights the session row only when a session is actually broken. private string? SessionClass => SessionEntry?.DataInt("faulted") is > 0 ? "bad" : null; + /// Pools listed in the order an operator cares about; anything unrecognised follows. + private static readonly string[] PoolOrder = ["read", "event"]; + + /// + /// The historian session-pool checks, which publish a warmSessions depth per pool. + /// + /// + /// + /// These live on the ready tier, not the active one, and carry an entirely + /// different key set from the gateway's session check — so the two rows are separate + /// renderings rather than one abstraction. Forcing them together would mean inventing a + /// shared shape that neither app actually publishes. + /// + /// + /// This is pool depth, not connections in use. A pool reporting 0 is idle, not + /// broken: sessions are leased on demand and the event pool legitimately sits empty until + /// something reads events. The row is labelled "pooled" so nobody reads a 0 as an outage. + /// + /// + private IEnumerable<(string Pool, int Warm, int Failures)> HistorianPools => + (Instance.Report?.Entries.Values ?? Enumerable.Empty()) + .Where(e => e.DataInt("warmSessions") is not null && e.DataString("pool") is { Length: > 0 }) + .Select(e => ( + Pool: e.DataString("pool")!, + Warm: e.DataInt("warmSessions") ?? 0, + Failures: e.DataInt("consecutiveFactoryFailures") ?? 0)) + .OrderBy(p => Array.IndexOf(PoolOrder, p.Pool) is var i && i >= 0 ? i : int.MaxValue) + .ThenBy(p => p.Pool, StringComparer.Ordinal); + + /// The pool depths as a short face-level line, or null when this app publishes none. + private string? HistorianPoolText + { + get + { + var pools = HistorianPools.ToList(); + if (pools.Count == 0) + return null; + + return string.Join(" · ", pools.Select(p => + p.Failures > 0 + ? $"{p.Pool} {p.Warm} ({p.Failures} fail)" + : $"{p.Pool} {p.Warm}")); + } + } + + /// + /// Red only when a pool's session factory is actually failing — never merely because a pool is + /// empty, which is the normal idle state. + /// + private string? HistorianPoolClass => HistorianPools.Any(p => p.Failures > 0) ? "bad" : null; + private static string DotClass(string? status) => status switch { "Healthy" => "ok", 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 81dfe0f..77be83a 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 @@ -306,6 +306,75 @@ public class InstanceCardTests : TestContext Assert.Empty(Render(Card()).FindAll("[data-testid=mxaccess-sessions]")); } + // ---- HistorianGateway session pools (ready tier) ------------------------------------------- + // A different app, a different tier and a different key set, so this is a sibling row rather + // than a shared abstraction neither app actually publishes. + + private static ZbHealthReport PoolReport(params (string Pool, int Warm, int Failures)[] pools) => + System.Text.Json.JsonSerializer.Deserialize( + HealthBody.WithData( + "Healthy", + pools.Select(p => ( + $"historian-{p.Pool}-session-pool", + "Healthy", + (Dictionary?)new Dictionary(StringComparer.Ordinal) + { + ["pool"] = p.Pool, + ["warmSessions"] = p.Warm, + ["consecutiveFactoryFailures"] = p.Failures, + })).ToArray()))!; + + [Fact] + public void Card_ShowsHistorianPoolDepths_FromTheReadyTier() + { + var card = Render(Card(report: PoolReport(("read", 1, 0), ("event", 0, 0)))); + + var row = card.Find("[data-testid=historian-pools]"); + // read before event: the order an operator cares about, not dictionary order. + Assert.Contains("read 1 · event 0", row.TextContent, StringComparison.Ordinal); + } + + [Fact] + public void Card_DoesNotPaintAnIdlePoolAsBroken() + { + // 0 warm sessions is the normal idle state - sessions are leased on demand. Colouring it + // red would train operators to ignore the one colour that should mean something. + var card = Render(Card(report: PoolReport(("event", 0, 0)))); + + Assert.DoesNotContain("bad", card.Find("[data-testid=historian-pools] .v").ClassList, StringComparer.Ordinal); + } + + [Fact] + public void Card_FlagsAPoolWhoseSessionFactoryIsFailing() + { + var card = Render(Card(report: PoolReport(("read", 0, 3)))); + + var value = card.Find("[data-testid=historian-pools] .v"); + Assert.Contains("3 fail", value.TextContent, StringComparison.Ordinal); + Assert.Contains("bad", value.ClassList, StringComparer.Ordinal); + } + + [Fact] + public void Card_OmitsThePoolRow_ForAppsWithNoSessionPools() + { + // Positive control first, or the omission below proves nothing. + Assert.NotEmpty(Render(Card(report: PoolReport(("read", 1, 0)))).FindAll("[data-testid=historian-pools]")); + + Assert.Empty(Render(Card(report: Report(("db", "Healthy", null)))).FindAll("[data-testid=historian-pools]")); + } + + [Fact] + public void Card_KeepsTheTwoSessionRowsIndependent() + { + // The gateway's active-tier session count and the historian's ready-tier pool depths use + // different keys on different tiers; neither may render the other's row. + var gateway = Render(Card(activeReport: SessionReport(total: 2, ready: 2))); + Assert.Empty(gateway.FindAll("[data-testid=historian-pools]")); + + var historian = Render(Card(report: PoolReport(("read", 1, 0)))); + Assert.Empty(historian.FindAll("[data-testid=mxaccess-sessions]")); + } + [Fact] public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts() {