feat(overview): show historian session-pool depths on the HistorianGateway card

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.
This commit is contained in:
Joseph Doherty
2026-08-12 05:16:25 -04:00
parent da276ff15a
commit 846b77f5b4
2 changed files with 130 additions and 0 deletions
@@ -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<ZbHealthReport>(
HealthBody.WithData(
"Healthy",
pools.Select(p => (
$"historian-{p.Pool}-session-pool",
"Healthy",
(Dictionary<string, object>?)new Dictionary<string, object>(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()
{