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:
@@ -52,6 +52,16 @@
|
|||||||
</div>
|
</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)
|
@if (Instance.Error is { Length: > 0 } error)
|
||||||
{
|
{
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
@@ -330,6 +340,57 @@
|
|||||||
/// <summary>Highlights the session row only when a session is actually broken.</summary>
|
/// <summary>Highlights the session row only when a session is actually broken.</summary>
|
||||||
private string? SessionClass => SessionEntry?.DataInt("faulted") is > 0 ? "bad" : null;
|
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
|
private static string DotClass(string? status) => status switch
|
||||||
{
|
{
|
||||||
"Healthy" => "ok",
|
"Healthy" => "ok",
|
||||||
|
|||||||
@@ -306,6 +306,75 @@ public class InstanceCardTests : TestContext
|
|||||||
Assert.Empty(Render(Card()).FindAll("[data-testid=mxaccess-sessions]"));
|
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]
|
[Fact]
|
||||||
public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts()
|
public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user