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.
252 lines
9.0 KiB
C#
252 lines
9.0 KiB
C#
using Bunit;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.Overview.Polling;
|
|
|
|
namespace ZB.MOM.WW.Overview.Tests;
|
|
|
|
/// <summary>
|
|
/// The page as a whole: the KPI strip an operator scans first, the group headers that carry the
|
|
/// split-brain warning, and the pause control — which has to freeze the view without pretending
|
|
/// the view is still live.
|
|
/// </summary>
|
|
public class OverviewPageTests : TestContext
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private readonly OverviewSnapshotStore _store = new();
|
|
private readonly FakeTimeProvider _time = new(Now);
|
|
|
|
public OverviewPageTests()
|
|
{
|
|
Services.AddSingleton(_store);
|
|
Services.AddSingleton<TimeProvider>(_time);
|
|
}
|
|
|
|
private static InstanceSnapshot Node(
|
|
string name,
|
|
InstanceStatus? status,
|
|
string? group = null,
|
|
string? leader = null,
|
|
DateTimeOffset? lastPolled = null) =>
|
|
new()
|
|
{
|
|
Application = "ScadaBridge",
|
|
Instance = name,
|
|
Group = group,
|
|
BaseUrl = $"http://{name}:8084",
|
|
HasActiveRole = group is not null,
|
|
Status = status,
|
|
LastPolledUtc = lastPolled ?? Now,
|
|
LastReachableUtc = lastPolled ?? Now,
|
|
StaleAfter = TimeSpan.FromSeconds(45),
|
|
Report = leader is null
|
|
? null
|
|
: System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(
|
|
HealthBody.Ready("Healthy", ("akka-cluster", "Healthy", leader))),
|
|
};
|
|
|
|
private static OverviewSnapshot Snapshot(params InstanceSnapshot[] instances)
|
|
{
|
|
var applications = instances
|
|
.GroupBy(i => i.Application, StringComparer.Ordinal)
|
|
.Select(g => new ApplicationSnapshot(g.Key, g.ToList(), LeaderResolver.Resolve(g.ToList())))
|
|
.ToList();
|
|
|
|
return new OverviewSnapshot(Now, applications);
|
|
}
|
|
|
|
private IRenderedComponent<Components.Pages.Overview> RenderPage() =>
|
|
RenderComponent<Components.Pages.Overview>();
|
|
|
|
[Fact]
|
|
public void EmptyStore_RendersAnExplicitEmptyState()
|
|
{
|
|
// Before the first sweep. It must say so rather than render a KPI strip full of zeroes,
|
|
// which would read as "the whole fleet is registered and nothing is up".
|
|
var page = RenderPage();
|
|
|
|
Assert.NotNull(page.Find("[data-testid=empty-registry]"));
|
|
Assert.Empty(page.FindAll(".agg-grid"));
|
|
}
|
|
|
|
[Fact]
|
|
public void KpiStrip_CountsEachStatus()
|
|
{
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up),
|
|
Node("b", InstanceStatus.Up),
|
|
Node("c", InstanceStatus.Degraded),
|
|
Node("d", InstanceStatus.Down),
|
|
Node("e", InstanceStatus.Unreachable)));
|
|
|
|
var page = RenderPage();
|
|
|
|
Assert.Equal("2", page.Find("[data-testid=kpi-up]").TextContent);
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-degraded]").TextContent);
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-unreachable]").TextContent);
|
|
Assert.Equal("0", page.Find("[data-testid=kpi-stale]").TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleInstances_CountAsStaleAndNotAlsoAsUp()
|
|
{
|
|
// Double-counting would make the KPI totals exceed the instance count and would let a
|
|
// frozen dashboard keep reporting a healthy fleet.
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up),
|
|
Node("b", InstanceStatus.Up, lastPolled: Now.AddMinutes(-5))));
|
|
|
|
var page = RenderPage();
|
|
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void GroupHeader_NamesTheLeader()
|
|
{
|
|
const string leader = "akka.tcp://scadabridge@a:8082";
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up, "site-a", leader),
|
|
Node("b", InstanceStatus.Up, "site-a", leader)));
|
|
|
|
Assert.Contains("Leader · a", RenderPage().Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void GroupHeader_WarnsOnDisagreement()
|
|
{
|
|
// The one output on this page that means "go look right now".
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@a:8082"),
|
|
Node("b", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@b:8082")));
|
|
|
|
Assert.Contains("Split brain", RenderPage().Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void AgreeingGroup_ShowsNoWarning()
|
|
{
|
|
const string leader = "akka.tcp://scadabridge@a:8082";
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up, "site-a", leader),
|
|
Node("b", InstanceStatus.Up, "site-a", leader)));
|
|
|
|
Assert.DoesNotContain("Split brain", RenderPage().Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PublishedSnapshot_ReRendersThePage()
|
|
{
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
var page = RenderPage();
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);
|
|
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Down)));
|
|
|
|
page.WaitForAssertion(() => Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent));
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Paused_StopsAdoptingNewSnapshots()
|
|
{
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
var page = RenderPage();
|
|
|
|
page.Find("[data-testid=pause-toggle]").Click();
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Down)));
|
|
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);
|
|
Assert.Contains("Resume auto-refresh", page.Markup, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resumed_AdoptsWhateverTheStoreHoldsNow()
|
|
{
|
|
// Resume must not replay the snapshots missed while paused — it must jump to current.
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
var page = RenderPage();
|
|
page.Find("[data-testid=pause-toggle]").Click();
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Down)));
|
|
|
|
page.Find("[data-testid=pause-toggle]").Click();
|
|
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void PausedView_AgesIntoStaleOnItsOwn()
|
|
{
|
|
// This is why pause freezes adoption rather than stopping the clock: a paused dashboard
|
|
// greys out by itself, so it cannot be mistaken for a live one.
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
var page = RenderPage();
|
|
page.Find("[data-testid=pause-toggle]").Click();
|
|
|
|
_time.Advance(TimeSpan.FromMinutes(5));
|
|
page.Render();
|
|
|
|
Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent);
|
|
Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Disposal_UnsubscribesFromTheStore()
|
|
{
|
|
// A leaked handler would call StateHasChanged on a disposed renderer every sweep, for the
|
|
// life of the process — one leak per page load.
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
var page = RenderPage();
|
|
|
|
page.Instance.Dispose();
|
|
|
|
var exception = Record.Exception(() => _store.Publish(Snapshot(Node("a", InstanceStatus.Down))));
|
|
Assert.Null(exception);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplicationSection_CarriesAnAnchorMatchingTheRailLink()
|
|
{
|
|
// The rail's application links are fragment anchors; a mismatch makes every one dead.
|
|
_store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
|
|
|
|
var section = RenderPage().Find("[data-testid=application]");
|
|
|
|
Assert.Equal(
|
|
ZB.MOM.WW.Overview.Components.Layout.MainLayout.Slug("ScadaBridge"),
|
|
section.GetAttribute("id"));
|
|
}
|
|
|
|
[Fact]
|
|
public void OneCardPerRegisteredInstance()
|
|
{
|
|
_store.Publish(Snapshot(
|
|
Node("a", InstanceStatus.Up, "site-a"),
|
|
Node("b", InstanceStatus.Up, "site-a"),
|
|
Node("gw", InstanceStatus.Up)));
|
|
|
|
Assert.Equal(3, RenderPage().FindAll("[data-testid=instance-card]").Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void UngroupedInstances_RenderAfterTheClusterGroups()
|
|
{
|
|
_store.Publish(Snapshot(
|
|
Node("gw", InstanceStatus.Up),
|
|
Node("a", InstanceStatus.Up, "site-a")));
|
|
|
|
// Enumerated, not indexed: bunit 1.40 was built against AngleSharp 1.2 and its
|
|
// RefreshableElementCollection indexer calls an IHtmlCollection member whose signature
|
|
// changed by 1.5.2 — the version this project pins for GHSA reasons. LINQ goes through
|
|
// IEnumerable and is unaffected.
|
|
var cards = RenderPage()
|
|
.FindAll("[data-testid=instance-card]")
|
|
.Select(c => c.GetAttribute("data-instance"))
|
|
.ToList();
|
|
|
|
Assert.Equal(["a", "gw"], cards);
|
|
}
|
|
}
|