# Central overview dashboard for the scadaproj family — options investigation

**Date:** 2026-07-22
**Goal:** a single pane showing status of every app instance (OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway — each with multiple nodes/pairs), with deep links to each instance's own management UI, pre-configured via an appsettings registry of applications and instances. "Aspire-dashboard-like."

---

## 1. What the family already gives us (verified in code, 2026-07-22)

The reconnaissance result is unusually favorable: **the data plane for a poll-based overview already exists on all four apps, with zero changes needed in the target apps.**

- **Health endpoints are uniform and anonymous everywhere.** All four apps call the shared `MapZbHealth()` (`ZB.MOM.WW.Health`), which maps `/health/ready`, `/health/active`, and `/healthz`, all `.AllowAnonymous()` in the library itself (`ZbHealthEndpointExtensions.cs:35-64`). Even OtOpcUa's global `FallbackPolicy = RequireAuthenticatedUser` doesn't gate them (endpoint `AllowAnonymous` wins).
- **The JSON contract is canonical and stable** (`ZbHealthWriter.cs:45-81`, camelCase):
  ```json
  { "status": "Healthy|Degraded|Unhealthy",
    "totalDurationMs": 12.3,
    "entries": { "<check>": { "status": "...", "description": null, "durationMs": 1.2 } } }
  ```
  Healthy/Degraded → HTTP 200, Unhealthy → 503.
- **`/health/active` is the active/standby signal.** ScadaBridge Central's `active` tier runs the oldest-member `active-node` check; OtOpcUa has `admin-leader`. No off-the-shelf product can express active/standby natively — this endpoint makes it a first-class dashboard column for us.
- **`/metrics` (Prometheus) is anonymous on all four** (explicitly on OtOpcUa; by absence of a fallback policy on the others). OTLP push is config-only on three apps (`<App>:Telemetry:Exporter=Otlp` + `OtlpEndpoint`); **HistorianGateway hardcodes its telemetry setup and would need a one-line change** to gain the config keys.
- **Nothing aggregates across apps today.** The closest is ScadaBridge's `/monitoring/health` (CentralUI), but it's push-based over the site transport and scoped to ScadaBridge sites in one cluster. OtOpcUa/HG/mxgw pages show only their own instance. A multi-app registry + remote HTTP poller is genuinely new work.
- **Reusable building blocks for an in-house pane:** `ZB.MOM.WW.Theme` (`ThemeShell`, `StatusPill` with Ok/Warn/Bad/Idle/Info, `TechCard`), `ZB.MOM.WW.Auth.AspNetCore` (the same hardened LDAP cookie login all four apps use), `ZB.MOM.WW.Configuration` (validate the registry at startup), `ZB.MOM.WW.Telemetry`/`Health` (the dashboard monitors itself the family way). No prebuilt tile/grid widget exists — a node-card composite would be new, but it's `TechCard` + `StatusPill` composition.
- **Known ports for the registry:** OtOpcUa AdminUI dev `:9000` (driver-only nodes fall back to `:5000`); ScadaBridge Central `:5000/:5001` (site nodes expose only `:8084` metrics — no health/UI); mxgw `:5120/:7121`; HistorianGateway dashboard `:5220` dev / single TLS ALPN port (rec. `:5222`) in production.

**Watch-out for the registry design:** ScadaBridge *site* nodes map no health endpoints at all (metrics only), so a site-pair row can't be polled the same way as a central pair — either poll `:8084/metrics` for liveness, read site status from Central's aggregator, or (later) add `MapZbHealth` to site nodes.

## 2. Off-the-shelf candidates (facts verified by fetch, 2026-07-22)

| Option | Version / status | Config-file registry | Persistence | Auth | Verdict |
|---|---|---|---|---|---|
| **Aspire dashboard (standalone)** | Aspire 13 (Nov 2025); `mcr.microsoft.com/dotnet/aspire-dashboard` | **No** — resource UI is disabled in standalone; needs a custom gRPC resource service to light up | None (in-memory, lost on restart) | BrowserToken / OIDC | Not the ops pane. MS docs: dev/short-term diagnostic tool. `AddExternalService()`/custom URLs are AppHost run-mode features only. |
| **AspNetCore.HealthChecks.UI (Xabaril)** | 9.0.0 (Dec 2024), net8.0 TFM only; upstream coasting on dependabot since Aug 2025 ("looking for maintainers"); tiny `DotNetDiag` fork exists | **Yes** — `HealthChecksUI:HealthChecks` array in appsettings; exactly the requested model | SQLite/SQL Server/InMemory | In-process — **our LDAP cookie works unmodified** | Closest functional match, but it **requires the `UIResponseWriter` payload** (issue #285) — our `ZbHealthWriter` shape is close but not identical (`durationMs` vs `duration`, no `data`/`tags`), so every app would need a second UI-format endpoint. Plus a slow-moving upstream dependency. |
| **Gatus** | v5.36.0 (May 2026), active | **Yes** — YAML; conditions incl. `[BODY]` JSONPath, so it can evaluate our health JSON **with zero app changes** (incl. degraded) | SQLite/Postgres | Built-in basic/OIDC (no LDAP cookie) | Best zero-effort comparable: registry + JSON conditions + uptime history + 30+ alert providers in one Go binary. Trade: non-.NET, non-Theme UI, outside our auth stack. |
| **Homepage (gethomepage.dev)** | v1.13.2 (Jun 2026) | Yes — YAML link tiles | n/a | None (reverse proxy) | Link portal only; `siteMonitor` is status-code-only — cannot express Degraded or active/standby. Out. |
| **Grafana + Prometheus + blackbox** | Grafana 13.0 (Apr 2026) | Yes — scrape config | Full TSDB history | **Native LDAP** | Most durable, real alerting, grows into actual ops monitoring. Highest setup (3 containers + dashboard authoring); Degraded is invisible to blackbox (HTTP 200) without a status gauge metric or `ResultStatusCodes` remap; deep links via data links are clunkier than a real portal. |

## 3. Options

### Option A — build a small in-house overview app (`ZB.MOM.WW.Overview`) — **RECOMMENDED**

A thin Blazor Server app hosted in scadaproj alongside the other shared components, composed almost entirely from the family's own libraries:

- **Registry:** `Overview:Applications[]` in appsettings — per app: name, kind, accent; per instance: display name, site/role, `healthBaseUrl`, `managementUrl`, optional `metricsUrl`, `expectActive` flag, poll interval/timeout overrides. Validated at startup via `ZB.MOM.WW.Configuration` (`AddValidatedOptions` + `ConfigPreflight`) — malformed registry fails the boot, not the first poll.
- **Poller:** one `BackgroundService` + `IHttpClientFactory`; polls each instance's `/health/ready` + `/health/active` in parallel on a fixed cadence (5–15 s), deserializes the canonical `ZbHealthWriter` JSON (a ~30-line typed client — worth putting in `ZB.MOM.WW.Health` as `ZbHealthReportClient` so it's contract-owned), and keeps an in-memory snapshot. Statuses per instance: **Up / Degraded / Down / Unreachable**, plus **Active/Standby** from `/health/active` — the column no off-the-shelf option can render.
- **UI:** `ThemeShell` + a new node-card grid built from `TechCard` + `StatusPill`; per-check expandable detail (the `entries` dict); every card deep-links to the instance's own management page. LDAP cookie login via `AddZbLdapAuth` — same UX as the other four apps (the dashboard reads only anonymous endpoints, but the pane itself should sit behind the family login).
- **Self-hosting:** it registers its own `MapZbHealth`/`MapZbMetrics` like any family app; a `docker/` folder mirroring HistorianGateway's runtime-only image pattern.

**Fit:** best-practice fit for this shop. The requested feature set (appsettings registry, status, deep links) maps 1:1 onto libraries that exist precisely so new apps can be assembled this way; the only genuinely new code is the poller + snapshot + one grid page — realistically **2–4 days** for a first useful version. It also keeps auth, theming, and ops conventions identical to the rest of the family, which none of the off-the-shelf options can do. Deliberate scope cut for v1: no history/alerting (that's Option D's job if it's ever needed — and the registry file ports straight to a Prometheus scrape config).

**Costs / risks:** you own the code (small); no built-in uptime history or webhooks in v1; the ScadaBridge-site-node gap (§1 watch-out) needs a decision in the registry design.

### Option B — AspNetCore.HealthChecks.UI embedded in a thin family host

Host the Xabaril UI inside a small ASP.NET app behind our LDAP cookie. Gets registry-from-appsettings, polling, SQLite history, and webhooks for near-zero UI code.

**Fit:** the strongest off-the-shelf functional match, and the only one where `ZB.MOM.WW.Auth` works unmodified. But it costs a change in **all four target apps** (a second health endpoint in `UIResponseWriter` format, because the UI hard-requires that payload), buys a visibly unmaintained upstream (net8.0-only TFM, dependabot-only commits since Aug 2025), has no active/standby concept, and its UI will never look like Theme. For a shop that already owns Health + Theme + Auth libs, it saves less than it costs.

### Option C — Gatus container as a zero-app-change status page

Deploy Gatus with a YAML registry; its `[BODY]` JSONPath conditions consume our existing health JSON directly (including Degraded), and it brings uptime history + alerting for free.

**Fit:** the best effort-to-value stopgap — running in an afternoon, no app changes, actively maintained. Best practice would call this the "buy" answer if the family didn't have its own UI/auth stack. Trades: separate basic/OIDC auth outside the LDAP cookie, un-Theme-able UI, and active/standby only expressible as another endpoint row rather than a paired-node concept. Reasonable as an interim pane while Option A is built, or as a permanent low-ceremony answer if the dashboard turns out to be rarely used.

### Option D — Grafana + Prometheus + blackbox exporter

**Fit:** the right long-term answer **if** the real goal grows into ops monitoring (history, alerting, SLOs) rather than a convenience pane — native LDAP auth is a genuine plus. As a *first* deliverable for "status + links" it's the highest effort and the worst at deep links/branding, and Degraded/active-standby need extra plumbing (a health-status gauge in `ZB.MOM.WW.Telemetry` would be the clean fix, and is worth doing eventually regardless). Not first; a natural phase 2 that Option A's registry feeds directly.

### Option E — Aspire dashboard standalone

**Fit:** rejected as the overview pane — in standalone mode the resource/status/links UI is disabled (it's an OTLP telemetry viewer only), nothing persists across restarts, and Microsoft positions it explicitly as a dev/short-term diagnostic tool. **Worth keeping as a free companion tool:** three of the four apps can push OTLP with config only, so an ad-hoc `aspire-dashboard` container gives structured logs + traces during debugging for zero code. (HistorianGateway needs the one-line telemetry-config change to join.)

> **DECISION (2026-07-22):** Option A chosen — custom app, family look-and-feel (Theme/Auth/Health kit). Aspire rejected for the pane. V1 design: [`docs/plans/2026-07-22-overview-dashboard-design.md`](docs/plans/2026-07-22-overview-dashboard-design.md).

## 4. Recommendation

**Option A** — build the thin `ZB.MOM.WW.Overview` app from the family's own parts, with two riders:

1. If an interim pane is wanted *this week*, stand up **Gatus (Option C)** against the existing health endpoints — zero app changes, discard later.
2. Keep **Aspire standalone (Option E)** in the toolbox as a dev-time OTLP viewer, and make HistorianGateway's telemetry config-driven (one line) so all four apps can join it.

Decisions to make before building A: how site-node ScadaBridge rows are sourced (metrics-liveness vs Central-aggregator vs adding MapZbHealth to sites), and whether v1 needs any history/alerting (recommended: no — defer to Option D if ever needed).

---

*Sources: family code (ZB.MOM.WW.Health `ZbHealthEndpointExtensions.cs`/`ZbHealthWriter.cs`, per-app Program.cs wiring); aspire.dev standalone/configuration/security docs + Aspire 13 announcement; Xabaril repo + NuGet + issue #285 + DotNetDiag fork; TwiN/gatus releases; gethomepage.dev docs; Grafana/blackbox dashboards. All web claims verified by fetch 2026-07-22.*
