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",