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() {