Skip to content

Commit a2d3288

Browse files
dashboard plugin: live plugin-status page over anonymous loopback probes
One self-contained HTML page + status.json, probing every declared plugin concurrently (3s timeout, uncached, <500-is-alive with per-probe expect lists; probes never leave loopback — validated at activate, including a self-probe recursion guard). Headline finding: the first live consumer of the #463/#464 app-registry seam — a plugin cannot enumerate its co-loaded siblings, so the operator hands the dashboard a hand-copied duplicate of the createServer plugins list, and drift is silent. Also: anonymous probes can't tell guarded from missing (WAC answers 401 before routing), and ws endpoints can't be truthfully probed over plain HTTP.
1 parent a32bec2 commit a2d3288

3 files changed

Lines changed: 653 additions & 0 deletions

File tree

dashboard/README.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# dashboard — plugin status page with live liveness probes
2+
3+
One HTML page (plus a JSON API) showing every plugin the operator declared,
4+
each probed live over loopback on every request. It is deliberately the
5+
simplest possible ops surface — and deliberately the first live consumer of
6+
the **#463/#464 app-registry seam**: the api gives a plugin no way to see
7+
its co-loaded siblings, so the operator must hand this dashboard a *copy* of
8+
the very plugins list they already passed to `createServer`.
9+
10+
## Usage
11+
12+
```js
13+
import { createServer } from 'javascript-solid-server/src/server.js';
14+
15+
const PORT = 3240;
16+
const fastify = createServer({
17+
root: './data/pods',
18+
plugins: [
19+
{ id: 'relay', module: 'plugins/relay/plugin.js', prefix: '/relay' },
20+
{ id: 'capability', module: 'plugins/capability/plugin.js', prefix: '/cap', config: {} },
21+
{ id: 'rss', module: 'plugins/rss/plugin.js', prefix: '/feed',
22+
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
23+
{ id: 'mastodon', module: 'plugins/mastodon/plugin.js', prefix: '/mastodon',
24+
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
25+
26+
{ id: 'dashboard', module: 'plugins/dashboard/plugin.js', prefix: '/dashboard',
27+
config: {
28+
loopbackUrl: `http://127.0.0.1:${PORT}`,
29+
// A HAND-COPIED duplicate of the list above — the api has no
30+
// registry a plugin could read (#463/#464). Keep them in sync
31+
// yourself; nothing will tell you when they drift.
32+
plugins: [
33+
{ id: 'relay', probe: '/relay', kind: 'ws' },
34+
{ id: 'capability', probe: '/cap/issue' },
35+
{ id: 'rss', probe: '/feed/atom' },
36+
{ id: 'mastodon', probe: '/api/v1/instance', expect: [200] },
37+
],
38+
} },
39+
],
40+
});
41+
```
42+
43+
- `GET /dashboard/` — the page.
44+
- `GET /dashboard/status.json` — the same data as JSON:
45+
46+
```json
47+
{
48+
"generated": "2026-07-11T12:00:00.000Z",
49+
"server": { "alive": true, "status": 200, "latency_ms": 2 },
50+
"plugins": [
51+
{ "id": "rss", "probe": "/feed/atom", "kind": "http",
52+
"alive": true, "state": "degraded", "status": 400, "latency_ms": 3 }
53+
]
54+
}
55+
```
56+
57+
### Probe semantics
58+
59+
Each `config.plugins` entry is `{ id, probe, expect?, kind? }`:
60+
61+
- `probe` — a **local path** on this server. It must start with `/`, must
62+
not contain `://`, and must not point back at the dashboard itself
63+
(recursion); all three are enforced with a throw at `activate`. Probes go
64+
**only** to `loopbackUrl + probe`, never to external URLs.
65+
- Probes carry **no Authorization** — they are anonymous liveness checks of
66+
public surfaces. By default any status **< 500 counts as alive**: a
67+
400/401/404 from a guard is a living plugin answering. `expect: [200]`
68+
(a status allowlist) narrows that per probe.
69+
- `kind: 'ws'` — see finding 3 below: WebSocket endpoints are probed with a
70+
plain HTTP GET; upgrade-refusal statuses (400/426) count as *up*.
71+
- States: **up** (2xx/3xx, or a matched `expect`), **degraded** (answered
72+
4xx — alive, but guarded/not plainly OK), **down** (no answer, timeout,
73+
5xx, or an `expect` mismatch). `alive` = not down.
74+
- Probes run server-side on **every** `status.json` request, concurrently
75+
(`Promise.all`), each with a ~3s timeout (`AbortSignal.timeout`,
76+
`config.timeoutMs`), **never cached** — so each page refresh costs O(N)
77+
loopback fetches. Fine for a human-refresh dashboard; put a poller with
78+
its own cadence in front if you want to hammer it.
79+
- The host itself is probed too (`GET /`, any non-5xx counts).
80+
- No `config.plugins` → an empty dashboard that still renders, with a note
81+
explaining why it can't populate itself.
82+
83+
### The page, in words
84+
85+
A single narrow column, system font, honest table. Header "plugin
86+
dashboard", a muted meta line ("probed 2026-07-11T… — live, uncached,
87+
anonymous loopback probes every 5s"). Then one table: **plugin | probe |
88+
status | http | latency**. First row is the server itself, then one row per
89+
declared plugin: the id, the probe path in `code` (ws probes tagged with a
90+
small `ws` chip), a pill badge — green **up**, amber **degraded**, red
91+
**down** — the HTTP status ("—" when nothing answered), and "3 ms" in
92+
tabular numerals. Everything inline: no external assets, no framework, one
93+
small script that re-fetches `status.json` every `refreshMs` (default 5s)
94+
and rewrites the cells. Dark-mode via `prefers-color-scheme`. Without JS
95+
you still get the declared list (statuses stay "…"), so `curl` shows the
96+
inventory.
97+
98+
## What maps / what doesn't
99+
100+
| capability | status |
101+
|---|---|
102+
| list every installed plugin | ✅ but only from a hand-copied `config.plugins` (the finding) |
103+
| live liveness per plugin | ✅ anonymous loopback GET, <500 = alive, `expect` to sharpen |
104+
| host self-check |`GET /` non-5xx |
105+
| ws endpoint health | ⚠️ plain-HTTP approximation only (finding 3) |
106+
| auto-discovery of siblings | ❌ no `api.plugins` — impossible today (#463/#464) |
107+
| deep health (deps, storage, queue lag) | ❌ out of scope; probes are surface liveness only |
108+
109+
## Findings
110+
111+
1. **A plugin cannot enumerate its co-loaded plugins — the #463/#464
112+
app-registry seam, first live consumer.** The `api` object carries no
113+
registry: no `api.plugins`, no `api.serverInfo`, nothing that says "these
114+
entries were passed to `createServer` alongside you". So the one plugin
115+
whose entire job is *describing the deployment* must be handed a
116+
**duplicate** of the operator's own plugins list in `config.plugins`
117+
and the two lists drift silently: add a plugin to `createServer` and
118+
forget the dashboard, and the dashboard simply doesn't show it; remove
119+
one and the dashboard cheerfully probes a path that now 401s/404s, which
120+
the default rule calls *alive* (see finding 4 — on this host a missing
121+
route and a guarded one are anonymously indistinguishable). Nothing can
122+
detect the drift from inside. The seam this proves the need for:
123+
`api.plugins``[{ id, prefix, module }]` (read-only, the loader already
124+
holds exactly this), ideally with an optional operator/plugin-supplied
125+
hint like `probe: '/feed/atom'` or `health: () => …` per entry — which is
126+
also precisely the "surface installed plugins as Solid resources" ask of
127+
#463/#464: this dashboard is what the consumer of that resource looks
128+
like, built today at the cost of a hand-maintained shadow copy.
129+
2. **`api.serverInfo` again.** The dashboard needs the host's own origin to
130+
probe it (`loopbackUrl`), and throws at `activate` when it's missing —
131+
the same origin the operator has already told `createServer` (port) and
132+
a dozen sibling plugins (`baseUrl`/`loopbackUrl` in nearly every entry in
133+
`serve.js`). This is the ~13th consumer of the `api.serverInfo` finding;
134+
the repetition is now itself dashboard-visible, since the operator types
135+
the same `http://127.0.0.1:PORT` string into yet another config block.
136+
3. **WebSocket endpoints can't be truthfully probed over plain HTTP — the
137+
`kind: 'ws'` simplification.** A ws endpoint's healthy answer to a plain
138+
GET is *refusal*. We accept 400/426 (upgrade-required) as *up* — but this
139+
host's upgrade stack registers no plain-GET route at all for `ws.route`
140+
paths, so `GET /relay` actually draws a **404**, indistinguishable from
141+
"no such plugin" (it lands as *degraded*, honest but mushy). An honest ws
142+
probe needs a real upgrade handshake — buildable here since `ws` is an
143+
allowed repo dep, but it drags in a dep and connection lifecycle for a
144+
liveness ping, so we documented the approximation instead. Which seam?
145+
None cleanly: it's a corollary of the registry gap — if `api.plugins`
146+
existed, entries could carry `kind`/liveness hints from the plugins
147+
themselves (relay *knows* it's a socket; `api.ws.route` could register a
148+
conventional HTTP `GET → 426` on the same path for free, which would
149+
also make this dashboard's 400/426 rule land as designed).
150+
4. **Anonymous probes can't tell "guarded" from "missing".** On this host,
151+
an unknown path (`/ghost/health`) draws **401**, not 404 — WAC answers
152+
before routing — and a POST-only route answers GET with 404. So every
153+
anonymous 4xx means only "the server routed and answered", and the
154+
default <500-is-alive rule is exactly as strong as that statement, no
155+
stronger. `expect: [200]` is the operator's tool for surfaces that should
156+
answer anonymously (that's how the test demonstrates *down*). A
157+
per-probe `Authorization` was deliberately not added: a dashboard config
158+
holding live bearer tokens is a worse failure mode than a mushy probe.
159+
5. **Self-probe recursion.** `status.json` runs probes; a probe pointed at
160+
`status.json` would recurse — each probe fans out N more probes until
161+
the timeouts cascade. Declaring a probe under the dashboard's own prefix
162+
is therefore rejected at `activate`. Trivial, but it's the kind of
163+
footgun a real `api.plugins` registry would have to consider too (a
164+
registry-driven dashboard would find *itself* in the list).
165+
166+
## Test
167+
168+
```
169+
node --test --test-concurrency=1 dashboard/test.js
170+
```
171+
172+
Boots one JSS carrying the dashboard plus three real siblings from this
173+
repo (rss, capability, relay) and asserts the page, the JSON shape, the
174+
alive/expect/down semantics, the no-cache property, the empty-list
175+
fallback, and the pre-boot validation throws (ordered before the long-lived
176+
boot — the `DATA_ROOT` footgun).

0 commit comments

Comments
 (0)