846b77f5b4
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.
395 lines
16 KiB
C#
395 lines
16 KiB
C#
using Bunit;
|
|
using ZB.MOM.WW.Overview.Components.Widgets;
|
|
using ZB.MOM.WW.Overview.Polling;
|
|
|
|
namespace ZB.MOM.WW.Overview.Tests;
|
|
|
|
/// <summary>
|
|
/// The instance card is where a reading becomes a decision, so the tests pin what an operator can
|
|
/// actually distinguish at a glance: which of the four states is shown, whether it is still
|
|
/// current, and whether the raw evidence behind it is visible.
|
|
/// </summary>
|
|
public class InstanceCardTests : TestContext
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private static InstanceSnapshot Card(
|
|
InstanceStatus? status = InstanceStatus.Up,
|
|
ActiveState active = ActiveState.NotApplicable,
|
|
DateTimeOffset? lastPolled = null,
|
|
DateTimeOffset? lastReachable = null,
|
|
TimeSpan? staleAfter = null,
|
|
string? error = null,
|
|
ZbHealthReport? report = null,
|
|
ZbHealthReport? activeReport = null,
|
|
string? managementUrl = null) =>
|
|
new()
|
|
{
|
|
Application = "ScadaBridge",
|
|
Instance = "site-a-a",
|
|
Group = "site-a",
|
|
BaseUrl = "http://site-a-a:8084",
|
|
ManagementUrl = managementUrl,
|
|
ManagementLabel = "AdminUI",
|
|
HasActiveRole = active != ActiveState.NotApplicable,
|
|
Status = status,
|
|
Active = active,
|
|
LastPolledUtc = lastPolled ?? Now,
|
|
LastReachableUtc = lastReachable ?? Now,
|
|
Latency = TimeSpan.FromMilliseconds(18),
|
|
Error = error,
|
|
Report = report,
|
|
ActiveReport = activeReport,
|
|
StaleAfter = staleAfter ?? TimeSpan.FromSeconds(45),
|
|
};
|
|
|
|
private IRenderedComponent<InstanceCard> Render(InstanceSnapshot instance, bool isLeader = false) =>
|
|
RenderComponent<InstanceCard>(p => p
|
|
.Add(c => c.Instance, instance)
|
|
.Add(c => c.Now, Now)
|
|
.Add(c => c.IsLeader, isLeader));
|
|
|
|
[Theory]
|
|
[InlineData(InstanceStatus.Up, "up", "Up")]
|
|
[InlineData(InstanceStatus.Degraded, "degraded", "Degraded")]
|
|
[InlineData(InstanceStatus.Down, "down", "Down")]
|
|
[InlineData(InstanceStatus.Unreachable, "unreachable", "Unreachable")]
|
|
public void Card_CarriesItsStatusAsBothClassAndLabel(InstanceStatus status, string cssClass, string label)
|
|
{
|
|
var card = Render(Card(status));
|
|
|
|
Assert.Contains(cssClass, card.Find("article").ClassList, StringComparer.Ordinal);
|
|
Assert.Contains(label, card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnreachableAndDown_AreVisuallyDistinct_NotJustDifferentWords()
|
|
{
|
|
// The CSS gives Unreachable a dashed border precisely because both are red. If the class
|
|
// did not differ, the two would be indistinguishable on a wall display.
|
|
var down = Render(Card(InstanceStatus.Down)).Find("article").ClassList;
|
|
var unreachable = Render(Card(InstanceStatus.Unreachable)).Find("article").ClassList;
|
|
|
|
Assert.NotEqual(down, unreachable);
|
|
}
|
|
|
|
[Fact]
|
|
public void NeverPolled_RendersPending_NotHealthy()
|
|
{
|
|
var card = Render(Card(status: null, lastPolled: null));
|
|
|
|
Assert.Contains("pending", card.Find("article").ClassList, StringComparer.Ordinal);
|
|
Assert.Contains("Pending", card.Markup, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(">Up<", card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleCard_IsMarkedStaleButKeepsItsLastKnownStatusClass()
|
|
{
|
|
// Both facts matter: the reading was Up, and it is no longer current. Dropping either one
|
|
// loses information the operator needs.
|
|
var card = Render(Card(lastPolled: Now.AddMinutes(-5)));
|
|
|
|
var classes = card.Find("article").ClassList;
|
|
Assert.Contains("stale", classes, StringComparer.Ordinal);
|
|
Assert.Contains("up", classes, StringComparer.Ordinal);
|
|
Assert.Contains("Stale", card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleCard_CountsFromLastContact_NotFromTheLastPollAttempt()
|
|
{
|
|
// The poller kept trying for ten minutes and kept failing. "Stale since 5 s ago" would
|
|
// suggest the instance was fine moments ago; it has actually been gone for ten minutes.
|
|
var card = Render(Card(
|
|
status: InstanceStatus.Unreachable,
|
|
lastPolled: Now.AddSeconds(-5),
|
|
lastReachable: Now.AddMinutes(-10),
|
|
staleAfter: TimeSpan.FromSeconds(1)));
|
|
|
|
Assert.Contains("Stale since", card.Markup, StringComparison.Ordinal);
|
|
Assert.Contains("10 min ago", card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ActiveState.Active, "Active")]
|
|
[InlineData(ActiveState.Standby, "Standby")]
|
|
[InlineData(ActiveState.Unknown, "Role ?")]
|
|
public void ActiveState_IsBadged(ActiveState state, string expected)
|
|
{
|
|
Assert.Contains(expected, Render(Card(active: state)).Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoActiveRole_ShowsNoActivityBadgeAtAll()
|
|
{
|
|
// A single-instance gateway has no active/standby concept; a badge would invent one.
|
|
var markup = Render(Card(active: ActiveState.NotApplicable)).Markup;
|
|
|
|
Assert.DoesNotContain("Standby", markup, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Role ?", markup, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("· active", markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void LeaderBadge_IsShownOnlyWhenTheCardIsTheLeader()
|
|
{
|
|
Assert.Contains("Leader", Render(Card(), isLeader: true).Markup, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Leader", Render(Card(), isLeader: false).Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Checks_AreListedWithAHealthyCount()
|
|
{
|
|
var card = Render(Card(report: Report(("localdb", "Healthy", null), ("akka-cluster", "Unhealthy", null))));
|
|
|
|
Assert.Contains("1 / 2 healthy", card.Markup, StringComparison.Ordinal);
|
|
Assert.Equal(2, card.FindAll(".check-row").Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterData_IsRenderedAsRawEvidence()
|
|
{
|
|
var card = Render(Card(report: Report(("akka-cluster", "Healthy", "akka.tcp://scadabridge@site-a-a:8082"))));
|
|
|
|
var data = card.Find(".check-data");
|
|
Assert.Contains("akka.tcp://scadabridge@site-a-a:8082", data.TextContent, StringComparison.Ordinal);
|
|
Assert.Contains("leader", data.TextContent, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterData_FromAnInstanceWithoutIt_IsOmittedEntirely()
|
|
{
|
|
// Every pre-0.2.0 node, and every check that publishes no data. The card must simply not
|
|
// show the line rather than showing an empty one.
|
|
var card = Render(Card(report: Report(("localdb", "Healthy", null))));
|
|
|
|
Assert.Empty(card.FindAll(".check-data"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterData_IsHtmlEscaped()
|
|
{
|
|
// The value is remote input from another application. Operationally trusted, but a health
|
|
// check that ever emits markup must not be able to inject it into this page.
|
|
var card = Render(Card(report: Report(("akka-cluster", "Healthy", "<img src=x onerror=alert(1)>"))));
|
|
|
|
var data = card.Find(".check-data");
|
|
Assert.Empty(data.QuerySelectorAll("img"));
|
|
Assert.Contains("<img src=x onerror=alert(1)>", data.TextContent, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ManagementLink_OpensInANewTabWithNoopener()
|
|
{
|
|
var link = Render(Card(managementUrl: "http://site-a-a:9000/")).Find(".panel-foot a");
|
|
|
|
Assert.Equal("http://site-a-a:9000/", link.GetAttribute("href"));
|
|
Assert.Equal("_blank", link.GetAttribute("target"));
|
|
Assert.Equal("noopener", link.GetAttribute("rel"));
|
|
Assert.Contains("AdminUI", link.TextContent, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoManagementUrl_RendersNoDeadLink()
|
|
{
|
|
var card = Render(Card(managementUrl: null));
|
|
|
|
Assert.Empty(card.FindAll(".panel-foot a"));
|
|
Assert.Contains("no management link", card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(InstanceStatus.Up, ActiveState.Active, "ready 200 · active 200")]
|
|
[InlineData(InstanceStatus.Up, ActiveState.Standby, "ready 200 · active 503")]
|
|
[InlineData(InstanceStatus.Down, ActiveState.NotApplicable, "ready 503")]
|
|
[InlineData(InstanceStatus.Unreachable, ActiveState.NotApplicable, "ready fail")]
|
|
public void RawSignalLine_ShowsWhatWasActuallyReceived(
|
|
InstanceStatus status, ActiveState active, string expected)
|
|
{
|
|
// The footer is the escape hatch from every derived label above it: when the dashboard and
|
|
// a curl disagree, this line is what reconciles them.
|
|
Assert.Contains(
|
|
expected,
|
|
Render(Card(status, active)).Find("[data-testid=raw-signals]").TextContent,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProbeError_IsSurfacedOnTheCard()
|
|
{
|
|
var card = Render(Card(InstanceStatus.Unreachable, error: "Connection refused"));
|
|
|
|
Assert.Contains("Connection refused", card.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Host_IsShownWithoutTheScheme()
|
|
{
|
|
Assert.Contains("site-a-a:8084", Render(Card()).Find(".inst-host").TextContent, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(4, "4 s ago")]
|
|
[InlineData(90, "2 min ago")]
|
|
[InlineData(7200, "2 h ago")]
|
|
[InlineData(172800, "2 d ago")]
|
|
public void Relative_ScalesTheUnit(int seconds, string expected)
|
|
{
|
|
Assert.Equal(expected, InstanceCard.Relative(TimeSpan.FromSeconds(seconds)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Relative_NegativeAge_ReadsAsNow_NotAsTheFuture()
|
|
{
|
|
// Clock skew between this host and a probed one is routine; "-3 s ago" is nonsense.
|
|
Assert.Equal("0 s ago", InstanceCard.Relative(TimeSpan.FromSeconds(-3)));
|
|
}
|
|
|
|
private static ZbHealthReport Report(params (string Name, string Status, string? Leader)[] entries) =>
|
|
System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(HealthBody.Ready("Healthy", entries))!;
|
|
|
|
// ---- MXAccess session counts (active tier) -------------------------------------------------
|
|
// The gateway publishes live MXAccess session counts on its ACTIVE tier. These pin that the
|
|
// number reaches the card face, since a count behind a disclosure triangle is one nobody reads.
|
|
|
|
private static ZbHealthReport SessionReport(int total, int ready, int faulted = 0, int starting = 0) =>
|
|
System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(
|
|
HealthBody.WithData(
|
|
"Healthy",
|
|
("mxaccess-sessions", "Healthy", new Dictionary<string, object>(StringComparer.Ordinal)
|
|
{
|
|
["total"] = total,
|
|
["ready"] = ready,
|
|
["faulted"] = faulted,
|
|
["starting"] = starting,
|
|
["closing"] = 0,
|
|
})))!;
|
|
|
|
[Fact]
|
|
public void Card_ShowsMxAccessSessionCount_FromTheActiveTier()
|
|
{
|
|
var card = Render(Card(activeReport: SessionReport(total: 2, ready: 2)));
|
|
|
|
var row = card.Find("[data-testid=mxaccess-sessions]");
|
|
Assert.Contains("MXAccess", row.TextContent, StringComparison.Ordinal);
|
|
Assert.Contains("2 · 2 ready", row.TextContent, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Card_ShowsZeroSessions_RatherThanHidingTheRow()
|
|
{
|
|
// A gateway holding no MXAccess sessions is the interesting case. Suppressing zero would
|
|
// make the row appear only when the news is good, which is when nobody needs it.
|
|
var card = Render(Card(activeReport: SessionReport(total: 0, ready: 0)));
|
|
|
|
Assert.Equal("0", card.Find("[data-testid=mxaccess-sessions] .v").TextContent.Trim());
|
|
}
|
|
|
|
[Fact]
|
|
public void Card_CallsOutFaultedSessions()
|
|
{
|
|
var card = Render(Card(activeReport: SessionReport(total: 3, ready: 2, faulted: 1)));
|
|
|
|
var value = card.Find("[data-testid=mxaccess-sessions] .v");
|
|
Assert.Contains("1 faulted", value.TextContent, StringComparison.Ordinal);
|
|
Assert.Contains("bad", value.ClassList, StringComparer.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Card_OmitsTheMxAccessRow_ForAppsThatPublishNoSessionCounts()
|
|
{
|
|
// Positive control first: the SAME assertion must find the row when the data IS present,
|
|
// or "not found" below would prove nothing about the omission.
|
|
Assert.NotEmpty(Render(Card(activeReport: SessionReport(1, 1))).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]
|
|
public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts()
|
|
{
|
|
// The row is located by the total/ready/faulted triple, not by check name. An active-node
|
|
// check publishes its own data on the same tier and must not be mistaken for a session count.
|
|
var activeNode = System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(
|
|
HealthBody.WithData(
|
|
"Healthy",
|
|
("active-node", "Healthy", new Dictionary<string, object>(StringComparer.Ordinal)
|
|
{
|
|
["activeNode"] = "site-a-a",
|
|
["total"] = 3,
|
|
})))!;
|
|
|
|
Assert.Empty(Render(Card(activeReport: activeNode)).FindAll("[data-testid=mxaccess-sessions]"));
|
|
}
|
|
}
|