5fe7135e2c
Tasks 3.4-3.6 of the overview-dashboard plan. Polling (3.4/3.5): - OverviewPollerService: one timer at the fastest configured cadence, each target polled when its own interval is due, fan-out per sweep, no retries (the next tick is the retry, so damping stays the only place that decides whether a failure is worth showing). - OverviewSnapshotStore: atomic swap of a complete immutable graph plus a change event. Pages read it and never probe — the cache-first requirement. - LeaderResolver: per-group leader from akka-cluster data, with the split-brain flag scoped to members that are actually answering, so an ordinary failover is not misread as a disagreement. UI (3.6): ThemeShell + StatusPill/TechCard composition against the mockup; instance cards carry the status stripe (dashed for Unreachable), the check detail list and the raw ready/active signal line. Two defects found by verification rather than by review: - The active tier answers with a status code and an EMPTY body, but the client demanded parseable JSON on every probe — so a healthy pair rendered as "role unknown" instead of Active/Standby. Split into ProbeAsync (ready tier, body is the payload) and ProbeStatusAsync (active tier, code is the answer). Caught by a live smoke run against two fake health endpoints; the poller tests now serve an empty body so they hold the fix. - SweepAsync published even when cancellation was already requested, leaving a half-probed registry as the store's final state on shutdown. It now checks the token itself rather than relying on the transport to throw. Also: app.UseAntiforgery() is required despite the anonymous-by-design pipeline — AddRazorComponents stamps antiforgery metadata unconditionally and the endpoint middleware hard-fails without it (every page was 500). Chosen over DisableAntiforgery() so a future form is protected by default. 147 tests, 0 warnings. Live-verified end to end against fake endpoints: Up/Unreachable/Active/Standby, leader chip, cluster-data line, KPI counts.
250 lines
10 KiB
C#
250 lines
10 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,
|
|
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,
|
|
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))!;
|
|
}
|