035c5feeb843d78f9d75f85e1920cd80f6c21721
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
344bdc4da5 |
fix(overview): rank activeNode over leader for the Active chip
The dashboard derived every leader chip from AkkaClusterHealthCheck's `leader` data — ClusterState.Leader, the LOWEST-ADDRESSED Up member. That is not the node in charge. The node holding the singletons, gated for writes, and answering 200 on /health/active is the OLDEST Up member. The two agree only until some node restarts, after which the restarted node rejoins youngest but keeps its address. Observed live on the rebuilt OtOpcUa rig during the Health 0.3.0 work: leader central-1, active node central-2. The dashboard would have put the Active chip on central-1 while /health/active said central-2 — the chip contradicting the tier it exists to visualise. The Phase 4 acceptance run missed it because the rig had just been formed, the one state in which the two orderings agree. Health 0.3.0's active tier now publishes `activeNode` on every member, including standbys, so the fix is to prefer it and keep `leader` as the fallback for an instance on an older Health or whose active tier was not probed. The instance card now shows `active` and `leader` side by side rather than swapping one for the other: they are legitimately different nodes, and showing both turns a silent contradiction into something readable at 3am. Tests: 177 (+3) — precedence when the two disagree, a standby naming the active node rather than itself, and the fallback still working with no activeNode. |
||
|
|
d95292c95d |
fix(overview): locate the cluster view by data key, not by check name
Found by the live rig, and invisible to every unit test that existed.
LeaderResolver and InstanceCard.ClusterDataLine both looked the cluster view up
under the check name "akka-cluster". ScadaBridge registers the shared
AkkaClusterHealthCheck under that name; OtOpcUa registers the SAME check as
"akka". So once the OtOpcUa rig was rebuilt on Health 0.2.0 and started
publishing data.leader, its groups still rendered with no leader chip and no
evidence line - and a group with no votes is a legitimate state, so nothing
looked wrong. ScadaBridge's four groups were fully populated the whole time,
which is what made it look like the feature worked.
Both now find the entry by the DATA KEY ("leader"), which is the part of the
contract the shared check actually owns rather than the part each consumer names
for itself.
Regression tests: the leader is found under akka-cluster, akka, and an arbitrary
future name; and an entry that publishes data WITHOUT a leader (ScadaBridge's
localdb check, whose data carries replication counters) does not get picked in
preference to the one that answers the question. Every prior fixture used
ScadaBridge's name, which is precisely why this got through.
After the fix all seven cluster groups render a leader chip with zero
split-brain flags - design section 9 check 2 now passes for BOTH products.
174 tests, 0 warnings.
|
||
|
|
92997b40dc |
feat(overview): self-observability, config and the container image
Closes Phase 3 of the overview-dashboard plan (tasks 3.7-3.9).
Self-observability
- OverviewMetrics: an observable gauge read from the published snapshot at scrape
time, so the metric and the page cannot disagree about what the dashboard
believes, plus a sweep-duration histogram. Instrument names follow the family
METRIC-CONVENTIONS spec (overview.instance.status / overview.poll.duration,
unit s) rather than the plan's literal zb_ names; Prometheus renders them as
overview_instance_status and overview_poll_duration_seconds. Labels are
application/node/group - "node", not "instance", because Prometheus stamps its
own instance label on every scraped series and a colliding metric label is
silently renamed exported_instance. Instances never probed emit nothing: the
enum's zero ordinal is Up, so a placeholder would publish the single most
reassuring answer about a node nobody has heard from.
- Two readiness checks make the dashboard's own /health/ready mean something:
registry-loaded (reports the counts it bound) and last-poll-cycle-completed,
which catches the failure where Kestrel is fine, the page still renders, and
every card on it is quietly frozen. The heartbeat is stamped by the sweep, not
derived from the snapshot's timestamp - the poller publishes the registry
before it probes anything, so a stuck poller would otherwise look healthy.
No cycle yet is Degraded inside the grace window and Unhealthy past it, so a
restart does not fail readiness for its first seconds and a poller that never
started cannot hide behind "still starting".
Configuration
- appsettings.json ships tuning defaults with an EMPTY Applications list, so a
deployment that supplies no registry fails at ConfigPreflight naming the key
instead of booting into a dashboard of nothing.
- Three registries, because reachability genuinely differs: Development is the
host-reachable subset (4 instances), Docker is the full 16-instance fleet by
container DNS name. The rigs publish almost no per-node HTTP port to the host
- OtOpcUa publishes none (only Traefik :9200, load-balanced across the central
pair, so it cannot identify a node) and ScadaBridge keeps site :8084
container-internal. Ports were established by probing the running rigs, not
read off the compose files, because the two disagree: OtOpcUa site nodes serve
health on :8080, not the :9000 their compose anchor implies.
- Real Serilog sinks, not the family's empty "Serilog": {}. AddZbSerilog drives
sinks entirely from configuration and adds none of its own, so the empty
section produces an app that logs NOWHERE - verified against the running
HistorianGateway container, whose log stream is completely empty for exactly
this reason. The HttpClient level override matters too: 16 instances on a 10s
cadence emit several Information lines a second of pure probe noise.
Container
- HistorianGateway-pattern runtime-only image; the compose stack joins the three
rig networks as external, so bringing the dashboard up or down cannot disturb
a rig. The Dockerfile clears ASPNETCORE_HTTP_PORTS as well as ASPNETCORE_URLS:
on .NET 8+ the base image declares its binding via the former, and clearing
only the latter leaves Kestrel logging "Overriding address(es)" every boot.
No healthcheck - aspnet:10.0 has neither curl nor wget, and adding a
--healthcheck entry point to production code to satisfy Compose is not a
trade worth making.
Tests
- BootTests boot the real Program.cs: every endpoint anonymous, the page
rendering from cache while nothing it watches is reachable, the Meters
allowlist proven by an end-to-end /metrics scrape, and both refuse-to-boot
paths (missing registry, stale window inside the poll interval).
The registry is supplied via environment variables, not
ConfigureAppConfiguration, because ConfigPreflight runs before Build() - when
the factory's callbacks are applied - and would not see them.
170 tests, 0 warnings. Verified live against the running rigs: 16 cards, 16
gauge series, page and gauge agreeing exactly.
|
||
|
|
5fe7135e2c |
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. |
||
|
|
d0110cf406 |
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). |