feat(overview): poller, leader aggregation and the dashboard UI

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.
This commit is contained in:
Joseph Doherty
2026-07-24 07:05:16 -04:00
parent 61fc88c8a8
commit 5fe7135e2c
24 changed files with 2829 additions and 11 deletions
@@ -0,0 +1,142 @@
using System.Net;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>A clock the test drives by hand.</summary>
/// <remarks>
/// Only <see cref="GetUtcNow"/> is overridden: the poller's sweep — the part with rules worth
/// pinning — is exercised directly, so no test here needs a fake timer.
/// </remarks>
internal sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
/// <summary>Serves canned responses per absolute URL and counts what was requested.</summary>
internal sealed class RoutingHandler : HttpMessageHandler
{
private readonly Dictionary<string, Func<HttpResponseMessage>> _routes = new(StringComparer.OrdinalIgnoreCase);
public List<string> Requests { get; } = [];
public RoutingHandler Json(string url, HttpStatusCode status, string body)
{
_routes[url] = () => new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
return this;
}
/// <summary>
/// A status-code-only response with an EMPTY body — what the active tier actually returns when
/// it is a bare gate endpoint rather than a full health report.
/// </summary>
public RoutingHandler Status(string url, HttpStatusCode status)
{
_routes[url] = () => new HttpResponseMessage(status)
{
Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"),
};
return this;
}
public RoutingHandler Fails(string url, string message)
{
_routes[url] = () => throw new HttpRequestException(message);
return this;
}
/// <summary>Lets a wrapping handler delegate here without re-entering the protected member.</summary>
public Task<HttpResponseMessage> HandleAsync(HttpRequestMessage request) =>
SendAsync(request, CancellationToken.None);
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
lock (Requests)
Requests.Add(url);
if (!_routes.TryGetValue(url, out var factory))
return Task.FromException<HttpResponseMessage>(new HttpRequestException($"no route for {url}"));
try
{
return Task.FromResult(factory());
}
catch (HttpRequestException ex)
{
return Task.FromException<HttpResponseMessage>(ex);
}
}
}
/// <summary>Hands every named client the same test handler.</summary>
internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) =>
new(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan };
}
/// <summary>Builds canonical health bodies so tests state intent, not JSON.</summary>
internal static class HealthBody
{
public static string Ready(string status, params (string Name, string Status, string? Leader)[] entries)
{
var payload = new
{
status,
totalDurationMs = 1.5,
entries = entries.ToDictionary(
e => e.Name,
e => (object)new
{
status = e.Status,
description = $"{e.Name} says {e.Status}",
durationMs = 0.5,
data = e.Leader is null ? null : new Dictionary<string, object> { ["leader"] = e.Leader },
},
StringComparer.Ordinal),
};
return JsonSerializer.Serialize(payload);
}
}
/// <summary>Builds <see cref="InstanceSnapshot"/> values for the pure-logic tests.</summary>
internal static class Snapshots
{
public static InstanceSnapshot Instance(
string name,
string? group = null,
string baseUrl = "http://host:8080",
bool hasActiveRole = true,
InstanceStatus? status = InstanceStatus.Up,
string? leader = null) =>
new()
{
Application = "App",
Instance = name,
Group = group,
BaseUrl = baseUrl,
HasActiveRole = hasActiveRole,
Status = status,
Report = leader is null ? null : ReportWithLeader(leader),
StaleAfter = TimeSpan.FromSeconds(45),
};
private static ZbHealthReport ReportWithLeader(string leader)
{
var body = HealthBody.Ready("Healthy", ("akka-cluster", "Healthy", leader));
return JsonSerializer.Deserialize<ZbHealthReport>(body)!;
}
}