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
@@ -52,6 +52,16 @@
</div>
}
@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. *@
<div class="kv" data-testid="historian-pools">
<span class="k">Sessions pooled</span>
<span class="v @HistorianPoolClass" title="Warm pre-authenticated historian sessions per pool">@poolText</span>
</div>
}
@if (Instance.Error is { Length: > 0 } error)
{
<div class="kv">
@@ -330,6 +340,57 @@
/// <summary>Highlights the session row only when a session is actually broken.</summary>
private string? SessionClass => SessionEntry?.DataInt("faulted") is > 0 ? "bad" : null;
/// <summary>Pools listed in the order an operator cares about; anything unrecognised follows.</summary>
private static readonly string[] PoolOrder = ["read", "event"];
/// <summary>
/// The historian session-pool checks, which publish a <c>warmSessions</c> depth per pool.
/// </summary>
/// <remarks>
/// <para>
/// These live on the <b>ready</b> 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.
/// </para>
/// <para>
/// <b>This is pool depth, not connections in use.</b> 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.
/// </para>
/// </remarks>
private IEnumerable<(string Pool, int Warm, int Failures)> HistorianPools =>
(Instance.Report?.Entries.Values ?? Enumerable.Empty<ZbHealthEntry>())
.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);
/// <summary>The pool depths as a short face-level line, or null when this app publishes none.</summary>
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}"));
}
}
/// <summary>
/// Red only when a pool's session factory is actually failing — never merely because a pool is
/// empty, which is the normal idle state.
/// </summary>
private string? HistorianPoolClass => HistorianPools.Any(p => p.Failures > 0) ? "bad" : null;
private static string DotClass(string? status) => status switch
{
"Healthy" => "ok",
@@ -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()
{