d0110cf406
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).
156 lines
6.1 KiB
C#
156 lines
6.1 KiB
C#
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);
|
|
}
|
|
}
|