feat(overview): scaffold the family dashboard + registry + health client

Phase 3 tasks 3.1-3.3 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md.

Scaffold (3.1): a plain directory in scadaproj (NOT a nested git repo), slnx over
src/ + tests/, HistorianGateway-pattern Directory.Build.props (warnings-as-errors)
and nuget.config (nuget.org * + the Gitea feed scoped to ZB.MOM.WW.*). Inline
package pins, no CPM — the app convention here. NO Auth/Audit/Secrets packages:
the dashboard is anonymous and read-only by requirement.

Registry (3.2): OverviewOptions/ApplicationEntry/InstanceEntry bound from the
"Overview" section, plus EffectiveTimings resolving instance > application >
global overrides in ONE place so no caller re-implements the precedence.
OverviewOptionsValidator (OptionsValidatorBase + AddValidatedOptions) rejects an
empty registry, applications with no instances, duplicate names, non-absolute or
non-http(s) URLs, and non-positive intervals — and checks stale > poll on the
EFFECTIVE values, since an override at either level can invert that on one
instance while the globals look fine. A ConfigPreflight presence check runs before
the host is built so a missing registry fails with the key name, not with
"Applications must have at least 1 entry".

Health client (3.3): ZbHealthReport DTOs for the canonical ZbHealthWriter body,
with per-entry `data` kept as JsonElement — it is an open contract, and binding it
to concrete types would break the moment a check adds a field. 200 AND 503 are
both parseable answers (503 carries the body naming the failing check); only a
transport failure or a non-canonical body is a failed probe, which is why
Unreachable stays distinct from Down. Host shutdown propagates as cancellation
rather than being recorded as every instance timing out.

Status model: Up/Degraded/Down/Unreachable + Active/Standby/Unknown/NotApplicable,
with two-strike flap damping — a failing observation only replaces a good state
after 2 consecutive failures, while recovery is immediate (damping suppresses
false alarms; symmetric damping would just make them linger).

53 tests green, 0 warnings in Debug and Release. Tests for the validator, the
client golden payloads (with AND without `data`, so a partly-bumped fleet renders)
and the derivation table are front-loaded here from task 3.8 so this batch is
verified rather than pending.

Also pins AngleSharp 1.5.2 test-side: bunit 1.40.0 pulls 1.2.0, which trips NU1902
and therefore the warnings-as-errors gate (same fix HistorianGateway made in
6bc005d).
This commit is contained in:
Joseph Doherty
2026-07-24 06:42:21 -04:00
parent 80668a07bd
commit d0110cf406
14 changed files with 1301 additions and 0 deletions
@@ -0,0 +1,226 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Registry;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// Registry validation. A malformed registry must fail the host at startup with a message naming
/// the offending key — the alternative is a dashboard that boots and quietly shows nothing, or one
/// that throws on its first poll where nobody is looking.
/// </summary>
public class OverviewOptionsValidatorTests
{
private static readonly OverviewOptionsValidator Validator = new();
private static OverviewOptions Valid(Action<OverviewOptions>? mutate = null)
{
var options = new OverviewOptions
{
PollIntervalSeconds = 10,
TimeoutSeconds = 3,
StaleAfterSeconds = 45,
Applications =
{
new ApplicationEntry
{
Name = "OtOpcUa",
ManagementLabel = "AdminUI",
Instances =
{
new InstanceEntry
{
Name = "central-1",
Group = "central",
BaseUrl = "http://otopcua-central-1:9000",
ManagementUrl = "http://otopcua-central-1:9000/",
HasActiveRole = true,
},
},
},
},
};
mutate?.Invoke(options);
return options;
}
private static ValidateOptionsResult Run(OverviewOptions options) => Validator.Validate(null, options);
private static void AssertFailsWith(OverviewOptions options, string expectedFragment)
{
var result = Run(options);
Assert.True(result.Failed, "expected validation to fail");
Assert.Contains(
result.Failures,
f => f.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ValidRegistry_Succeeds()
{
// Positive control: without this, every "fails with X" assertion below could be passing
// because the fixture itself is malformed for some unrelated reason.
Assert.True(Run(Valid()).Succeeded);
}
[Fact]
public void EmptyRegistry_IsRejected()
{
AssertFailsWith(Valid(o => o.Applications.Clear()), "Overview:Applications");
}
[Fact]
public void ApplicationWithNoInstances_IsRejected()
{
AssertFailsWith(Valid(o => o.Applications[0].Instances.Clear()), "Instances");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ApplicationWithoutName_IsRejected(string name)
{
AssertFailsWith(Valid(o => o.Applications[0].Name = name), "Applications:0:Name");
}
[Fact]
public void DuplicateApplicationNames_AreRejected()
{
AssertFailsWith(
Valid(o => o.Applications.Add(new ApplicationEntry
{
Name = "OtOpcUa",
Instances = { new InstanceEntry { Name = "other", BaseUrl = "http://other:9000" } },
})),
"duplicated");
}
[Fact]
public void DuplicateInstanceNamesWithinAnApplication_AreRejected()
{
AssertFailsWith(
Valid(o => o.Applications[0].Instances.Add(new InstanceEntry
{
Name = "central-1",
BaseUrl = "http://otopcua-central-2:9000",
})),
"duplicated");
}
[Fact]
public void SameInstanceNameInDifferentApplications_IsAllowed()
{
// "central-1" under OtOpcUa and "central-1" under ScadaBridge are different machines with
// the same conventional name — uniqueness is per application, not global.
var options = Valid(o => o.Applications.Add(new ApplicationEntry
{
Name = "ScadaBridge",
Instances = { new InstanceEntry { Name = "central-1", BaseUrl = "http://sb-central-a:5000" } },
}));
Assert.True(Run(options).Succeeded);
}
[Theory]
[InlineData("")]
[InlineData("otopcua-central-1:9000")] // no scheme
[InlineData("/health/ready")] // relative
[InlineData("ftp://otopcua-central-1")] // wrong scheme
public void NonAbsoluteHttpBaseUrl_IsRejected(string baseUrl)
{
AssertFailsWith(Valid(o => o.Applications[0].Instances[0].BaseUrl = baseUrl), "BaseUrl");
}
[Fact]
public void RelativeManagementUrl_IsRejected()
{
AssertFailsWith(Valid(o => o.Applications[0].Instances[0].ManagementUrl = "/admin"), "ManagementUrl");
}
[Fact]
public void OmittedManagementUrl_IsAllowed()
{
Assert.True(Run(Valid(o => o.Applications[0].Instances[0].ManagementUrl = null)).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveGlobalIntervals_AreRejected(int value)
{
AssertFailsWith(Valid(o => o.PollIntervalSeconds = value), "PollIntervalSeconds");
AssertFailsWith(Valid(o => o.TimeoutSeconds = value), "TimeoutSeconds");
AssertFailsWith(Valid(o => o.StaleAfterSeconds = value), "StaleAfterSeconds");
}
[Fact]
public void StaleWindowNotGreaterThanPollInterval_IsRejected()
{
AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds), "StaleAfterSeconds");
AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds - 1), "StaleAfterSeconds");
}
[Fact]
public void StaleWindowIsCheckedOnEffectiveValues_NotGlobals()
{
// The globals here are perfectly sane (10 / 45). Only the INSTANCE override inverts the
// relationship, which a globals-only check would wave straight through.
AssertFailsWith(
Valid(o => o.Applications[0].Instances[0].PollIntervalSeconds = 120),
"effective StaleAfterSeconds");
}
[Fact]
public void ApplicationLevelOverrideCanSatisfyTheStaleRule()
{
// The mirror of the previous test: an override that RESTORES the relationship must pass, so
// the rule is not just "any override fails".
var options = Valid(o =>
{
o.Applications[0].Instances[0].PollIntervalSeconds = 120;
o.Applications[0].Instances[0].StaleAfterSeconds = 300;
});
Assert.True(Run(options).Succeeded);
}
[Fact]
public void AllFailuresAreAccumulated_NotJustTheFirst()
{
// OptionsValidatorBase accumulates; an operator fixing a registry one boot at a time is the
// failure mode this avoids.
var result = Run(Valid(o =>
{
o.Applications[0].Name = string.Empty;
o.Applications[0].Instances[0].BaseUrl = "not-a-url";
o.PollIntervalSeconds = 0;
}));
Assert.True(result.Failed);
Assert.True(result.Failures.Count() >= 3, $"expected >= 3 failures, got {result.Failures.Count()}");
}
[Theory]
[InlineData(null, null, null, 10, 3, 45)] // all defaults
[InlineData(20, null, null, 20, 3, 45)] // application override
[InlineData(20, 30, null, 30, 3, 45)] // instance beats application
[InlineData(null, 30, 90, 30, 90, 45)] // independent fields resolve independently
public void EffectiveTimings_ResolveInstanceOverApplicationOverGlobal(
int? applicationPoll, int? instancePoll, int? instanceTimeout,
int expectedPoll, int expectedTimeout, int expectedStale)
{
var options = Valid(o =>
{
o.Applications[0].PollIntervalSeconds = applicationPoll;
o.Applications[0].Instances[0].PollIntervalSeconds = instancePoll;
o.Applications[0].Instances[0].TimeoutSeconds = instanceTimeout;
});
var timings = EffectiveTimings.Resolve(options, options.Applications[0], options.Applications[0].Instances[0]);
Assert.Equal(TimeSpan.FromSeconds(expectedPoll), timings.PollInterval);
Assert.Equal(TimeSpan.FromSeconds(expectedTimeout), timings.Timeout);
Assert.Equal(TimeSpan.FromSeconds(expectedStale), timings.StaleAfter);
}
}
@@ -0,0 +1,155 @@
using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// Status derivation and two-strike flap damping. These rules decide what an operator sees, so
/// every branch is pinned: the difference between Down and Unreachable sends someone to a
/// different place entirely, and damping that fires the wrong way either hides a real outage or
/// cries wolf on every dropped packet.
/// </summary>
public class StatusDerivationTests
{
private static HealthProbeResult Reachable(string reportStatus, int statusCode) =>
new(true, statusCode, new ZbHealthReport { Status = reportStatus }, TimeSpan.FromMilliseconds(5), null);
private static HealthProbeResult Unreachable(string error) =>
new(false, null, null, TimeSpan.FromMilliseconds(5), error);
[Theory]
[InlineData("Healthy", 200, InstanceStatus.Up)]
[InlineData("Degraded", 200, InstanceStatus.Degraded)]
[InlineData("Unhealthy", 503, InstanceStatus.Down)]
public void Observe_MapsCanonicalStatuses(string reportStatus, int code, InstanceStatus expected)
{
Assert.Equal(expected, StatusDerivation.Observe(Reachable(reportStatus, code)));
}
[Fact]
public void Observe_TransportFailure_IsUnreachable_NotDown()
{
// The distinction is the whole point: Down means "the app answered and said it is sick",
// Unreachable means "nothing answered" — usually VPN, firewall or a registry typo.
Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Unreachable("Connection refused")));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Weird")]
public void Observe_UnrecognisedStatus_IsUnreachable(string? reportStatus)
{
// Parsed as JSON but not this contract — something else is on that port.
Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Reachable(reportStatus!, 200)));
}
[Theory]
[InlineData(false, 200, ActiveState.NotApplicable)]
[InlineData(false, 503, ActiveState.NotApplicable)]
public void ObserveActive_WithoutActiveRole_IsNotApplicable(bool hasRole, int code, ActiveState expected)
{
Assert.Equal(expected, StatusDerivation.ObserveActive(hasRole, Reachable("Healthy", code)));
}
[Theory]
[InlineData(200, ActiveState.Active)]
[InlineData(503, ActiveState.Standby)]
[InlineData(404, ActiveState.Unknown)]
public void ObserveActive_MapsStatusCodes(int code, ActiveState expected)
{
Assert.Equal(expected, StatusDerivation.ObserveActive(true, Reachable("Healthy", code)));
}
[Fact]
public void ObserveActive_NoAnswer_IsUnknown_NotStandby()
{
// Guessing Standby on a failed probe would let a pair render with zero Active nodes during
// a blip, or — worse, if guessed the other way — with two.
Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, Unreachable("timed out")));
Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, probe: null));
}
[Fact]
public void Damp_FirstFailureFromGoodState_HoldsThePreviousStatus()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(1, failures);
}
[Fact]
public void Damp_SecondConsecutiveFailure_Flips()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, status);
Assert.Equal(2, failures);
}
[Fact]
public void Damp_RecoveryIsImmediate_EvenAfterManyFailures()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Down, 27, InstanceStatus.Up);
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_FirstEverPollThatFails_IsAdoptedImmediately()
{
// No previous state means nothing to protect and nothing better to show. Holding "Up" here
// would be inventing a healthy reading that was never observed.
var (status, failures) = StatusDerivation.Damp(previous: null, 0, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, status);
Assert.Equal(1, failures);
}
[Fact]
public void Damp_AlternatingFailures_NeverFlip()
{
// The flapping link this exists for: fail, recover, fail, recover. The counter must reset
// on every good poll, so the card stays Up instead of oscillating.
InstanceStatus? status = InstanceStatus.Up;
var failures = 0;
foreach (var observed in new[]
{
InstanceStatus.Unreachable, InstanceStatus.Up,
InstanceStatus.Unreachable, InstanceStatus.Up,
InstanceStatus.Unreachable, InstanceStatus.Up,
})
{
(var next, failures) = StatusDerivation.Damp(status, failures, observed);
status = next;
}
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_DegradedIsNotAFailure_AndIsAdoptedImmediately()
{
// Degraded is a real answer from a live app, not a failed probe: it must show at once, and
// must not accumulate strikes toward Down.
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Degraded);
Assert.Equal(InstanceStatus.Degraded, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_TwoDifferentFailureKindsInARow_StillFlips()
{
// Down then Unreachable is two consecutive failures, not one of each kind restarting the
// count — the instance is not answering healthily either way.
var (first, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Down);
Assert.Equal(InstanceStatus.Up, first);
var (second, _) = StatusDerivation.Damp(first, failures, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, second);
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Test stack mirrors ZB.MOM.WW.Theme's (xunit 2.9.3 + bunit), so component tests here read the
same way as the kit's own.
-->
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="bunit" Version="1.40.0" />
<!-- Transitive-security pin, test-only: bunit 1.40.0 pulls AngleSharp 1.2.0, which carries
NU1902 (GHSA-pgww-w46g-26qg) and therefore breaks the warnings-as-errors gate. Pinning the
patched version here is the same fix HistorianGateway applied (6bc005d); AngleSharp never
reaches the shipped app, only the bUnit renderer. -->
<PackageReference Include="AngleSharp" Version="1.5.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Overview\ZB.MOM.WW.Overview.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,199 @@
using System.Net;
using System.Text;
using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// Golden-payload tests for the health client. The fixtures are hand-written to the exact shape
/// <c>ZbHealthWriter</c> emits — including an entry WITH <c>data</c> (0.2.0+) and one WITHOUT
/// (every check that publishes none, and every pre-0.2.0 node), because tolerating both is what
/// lets the dashboard run against a fleet that is only partly bumped.
/// </summary>
public class ZbHealthReportClientTests
{
private const string ReadyHealthyWithData = """
{
"status": "Healthy",
"totalDurationMs": 12.34,
"entries": {
"akka-cluster": {
"status": "Healthy",
"description": "Akka cluster member status: Up",
"durationMs": 1.23,
"data": {
"selfAddress": "akka.tcp://scadabridge@site-a-a:8082",
"selfRoles": ["Site", "site-a"],
"memberCount": 2,
"unreachableCount": 0,
"leader": "akka.tcp://scadabridge@site-a-a:8082"
}
},
"localdb": {
"status": "Healthy",
"description": "Site LocalDb reachable.",
"durationMs": 0.45
}
}
}
""";
private const string ReadyUnhealthyNoData = """
{
"status": "Unhealthy",
"totalDurationMs": 3001.5,
"entries": {
"database": {
"status": "Unhealthy",
"description": null,
"durationMs": 3000.1
}
}
}
""";
private static ZbHealthReportClient ClientReturning(
HttpStatusCode statusCode,
string body,
string contentType = "application/json") =>
new(new HttpClient(new StubHandler((statusCode, body, contentType))));
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
[Fact]
public async Task Probe_HealthyBody_ParsesEnvelopeAndEntries()
{
var client = ClientReturning(HttpStatusCode.OK, ReadyHealthyWithData);
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
Assert.True(result.Reachable);
Assert.Equal(200, result.StatusCode);
Assert.Equal("Healthy", result.Report!.Status);
Assert.Equal(12.34, result.Report.TotalDurationMs);
Assert.Equal(new[] { "akka-cluster", "localdb" }, result.Report.Entries.Keys.OrderBy(k => k, StringComparer.Ordinal));
Assert.Equal("Site LocalDb reachable.", result.Report.Entries["localdb"].Description);
}
[Fact]
public async Task Probe_EntryWithData_ExposesLeaderAndCounts()
{
var client = ClientReturning(HttpStatusCode.OK, ReadyHealthyWithData);
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
var entry = result.Report!.Entries["akka-cluster"];
Assert.Equal("akka.tcp://scadabridge@site-a-a:8082", entry.DataString("leader"));
Assert.Equal(2, entry.DataInt("memberCount"));
Assert.Equal(0, entry.DataInt("unreachableCount"));
}
[Fact]
public async Task Probe_EntryWithoutData_ReadsAsNullNotAsAnError()
{
// The 0.1.0-era / no-data case. It must degrade to "no leader shown", never to a parse
// failure — that tolerance is what keeps the dashboard usable mid-rollout.
var client = ClientReturning(HttpStatusCode.OK, ReadyHealthyWithData);
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
var entry = result.Report!.Entries["localdb"];
Assert.True(result.Reachable);
Assert.Null(entry.Data);
Assert.Null(entry.DataString("leader"));
Assert.Null(entry.DataInt("memberCount"));
}
[Fact]
public async Task Probe_503_IsAParseableAnswer_NotAFailedProbe()
{
// 503 carries a full body naming the failing check — the single most useful payload the
// dashboard receives. Treating it as a transport failure would throw that away.
var client = ClientReturning(HttpStatusCode.ServiceUnavailable, ReadyUnhealthyNoData);
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
Assert.True(result.Reachable);
Assert.Equal(503, result.StatusCode);
Assert.Equal("Unhealthy", result.Report!.Status);
Assert.Null(result.Report.Entries["database"].Description);
}
[Theory]
[InlineData("<html><body>502 Bad Gateway</body></html>")]
[InlineData("not json at all")]
public async Task Probe_NonJsonBody_IsUnreachable(string body)
{
var client = ClientReturning(HttpStatusCode.OK, body, contentType: "text/html");
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
Assert.False(result.Reachable);
Assert.Null(result.Report);
Assert.NotNull(result.Error);
}
[Fact]
public async Task Probe_ConnectionRefused_IsUnreachableAndDoesNotThrow()
{
var client = new ZbHealthReportClient(
new HttpClient(new ThrowingHandler(new HttpRequestException("Connection refused"))));
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
Assert.False(result.Reachable);
Assert.Contains("refused", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Probe_Timeout_IsUnreachableAndReportsTheTimeout()
{
var client = new ZbHealthReportClient(new HttpClient(new HangingHandler()));
var result = await client.ProbeAsync("http://node/health/ready", TimeSpan.FromMilliseconds(50));
Assert.False(result.Reachable);
Assert.Contains("timed out", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Probe_HostShutdown_PropagatesCancellation()
{
// A shutdown must NOT be recorded as a timed-out instance — the sweep is being abandoned,
// and writing "Unreachable" for every node on the way out would be a lie in the snapshot.
using var shutdown = new CancellationTokenSource();
await shutdown.CancelAsync();
var client = new ZbHealthReportClient(new HttpClient(new HangingHandler()));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => client.ProbeAsync("http://node/health/ready", ProbeTimeout, shutdown.Token));
}
private sealed class StubHandler((HttpStatusCode Status, string Body, string ContentType) response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(response.Status)
{
Content = new StringContent(response.Body, Encoding.UTF8, response.ContentType),
});
}
private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromException<HttpResponseMessage>(exception);
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
}