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:
@@ -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)!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user