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