11# dashboard — plugin status page with live liveness probes
22
3- One HTML page (plus a JSON API) showing every plugin the operator declared ,
3+ One HTML page (plus a JSON API) showing every plugin loaded on the server ,
44each 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 ` .
5+ simplest possible ops surface — and it is the ** first live consumer of two
6+ shipped plugin-API seams** : it auto-discovers its co-loaded siblings from
7+ ` api.plugins ` (#610 ) and reaches the host over loopback via ` api.serverInfo `
8+ (#601 ), both landed in JSS ** 0.0.218** . This plugin drove the need for both
9+ seams and now runs on them — no hand-maintained list, no origin in config.
910
1011## Usage
1112
1213``` js
1314import { createServer } from ' javascript-solid-server/src/server.js' ;
1415
15- const PORT = 3240 ;
1616const fastify = createServer ({
1717 root: ' ./data/pods' ,
1818 plugins: [
1919 { id: ' relay' , module: ' plugins/relay/plugin.js' , prefix: ' /relay' },
20- { id: ' capability' , module: ' plugins/capability/plugin.js' , prefix: ' /cap' , config : {} },
20+ { id: ' capability' , module: ' plugins/capability/plugin.js' , prefix: ' /cap' },
2121 { 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 } ` } },
22+ config: { baseUrl: PUBLIC_URL } },
2523
24+ // No plugins list, no loopbackUrl — the dashboard discovers everything
25+ // above via api.plugins and finds the host via api.serverInfo. The only
26+ // config is optional per-plugin probe *refinement*:
2627 { id: ' dashboard' , module: ' plugins/dashboard/plugin.js' , prefix: ' /dashboard' ,
2728 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- ],
29+ probes: {
30+ relay: { kind: ' ws' }, // a WebSocket endpoint
31+ rss: { probe: ' /feed/atom' }, // a better liveness path than bare /feed
32+ // capability: { expect: [200] }, // declare a stricter "up"
33+ },
3834 } },
3935 ],
4036});
@@ -56,121 +52,107 @@ const fastify = createServer({
5652
5753### Probe semantics
5854
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.
55+ The plugin ** list** comes from ` api.plugins ` — you don't supply it. Each
56+ discovered plugin is probed at its own ` prefix ` by default; ` config.probes `
57+ optionally refines a single plugin's probe as ` { probe?, expect?, kind? } ` :
58+
59+ - ` probe ` — a ** local path** on this server, overriding the default (the
60+ plugin's prefix). It must start with ` / ` , must not contain ` :// ` , and must
61+ not point back at the dashboard itself (recursion); all three are enforced
62+ with a throw at ` activate ` . Probes go ** only** to the host's own loopback
63+ origin, never to external URLs.
64+ - Probes carry ** no Authorization** — anonymous liveness checks of public
65+ surfaces. By default any status ** < 500 counts as alive** : a 400/401/404
66+ from a guard is a living plugin answering. ` expect: [200] ` narrows that.
67+ - ` kind: 'ws' ` — a WebSocket endpoint; an upgrade-refusal (400/426) to the
68+ plain-HTTP probe counts as * up* (see finding 3).
69+ - States: ** up** (2xx/3xx or a matched ` expect ` ), ** degraded** (a 4xx —
70+ alive but guarded), ** down** (no answer, timeout, 5xx, or ` expect `
71+ mismatch). ` alive ` = not down.
72+ - Probes run server-side on ** every** ` status.json ` request, concurrently,
73+ each with a ~ 3s timeout (` config.timeoutMs ` ), ** never cached** — O(N)
74+ loopback fetches per refresh. The host itself is probed too (` GET / ` ).
75+ - If the dashboard is the only plugin loaded, the page renders with a note
76+ explaining there are no siblings to show.
8277
8378### The page, in words
8479
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 |
80+ A single narrow column, system font, honest table: ** plugin | probe |
8881status | 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.
82+ discovered plugin — id, probe path in ` code ` (ws probes tagged with a small
83+ ` ws ` chip), a pill badge (green ** up** , amber ** degraded** , red ** down** ),
84+ the HTTP status ("—" when nothing answered), latency in tabular numerals.
85+ Everything inline: no external assets, one small script that re-fetches
86+ ` status.json ` every ` refreshMs ` (default 5s). Dark-mode via
87+ ` prefers-color-scheme ` . Without JS you still get the discovered list, so
88+ ` curl ` shows the inventory.
9789
9890## What maps / what doesn't
9991
10092| capability | status |
10193| ---| ---|
102- | list every installed plugin | ✅ but only from a hand-copied ` config.plugins ` (the finding) |
94+ | list every loaded plugin | ✅ auto-discovered from ` api.plugins ` (#610 ) — no hand-copied list |
95+ | find the host to probe it | ✅ ` api.serverInfo ` (#601 ) — no ` loopbackUrl ` in config |
10396| live liveness per plugin | ✅ anonymous loopback GET, <500 = alive, ` expect ` to sharpen |
10497| host self-check | ✅ ` GET / ` non-5xx |
98+ | meaningful per-plugin probe | ⚠️ default = the plugin's prefix; refine via ` config.probes ` (finding 1) |
10599| 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 |
100+ | deep health (deps, storage, queue lag) | ❌ out of scope; surface liveness only |
108101
109102## Findings
110103
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).
104+ 1 . ** ` api.plugins ` shipped — and this is what it unlocked (with a residual).**
105+ This plugin was the proof-of-need for the app-registry seam (#463 /#464 →
106+ filed #610 → merged #612 , JSS 0.0.218), and it now consumes it: the
107+ hand-copied ` config.plugins ` array is ** gone** , and drift is impossible —
108+ add or remove a plugin and the dashboard reflects it with zero config
109+ change. The residual, now sharpened: the roster is ` { id, prefix, module } `
110+ with ** no health hints** , so the dashboard defaults to probing each
111+ plugin's prefix and still needs ` config.probes ` to characterise the ones a
112+ bare-prefix hit doesn't (ws endpoints; a plugin whose prefix 404s but a
113+ sub-path answers). That is exactly the * optional per-entry ` probe ` /` health `
114+ hint* anticipated in the #610 sketch — the remaining, smaller ask.
115+ 2 . ** ` api.serverInfo ` shipped — origin repetition gone.** The dashboard used
116+ to require ` loopbackUrl ` and throw without it; now it calls
117+ ` api.serverInfo() ` (#601 , 0.0.218) per request and builds the callable
118+ ` host:port ` itself. One timing note that makes serverInfo's function
119+ shape (not a snapshot) matter: the origin is resolved ** at request time** ,
120+ not cached at ` activate ` — a probe-time port-0 boot only knows its real
121+ port once listening, which is precisely the case serverInfo() handles.
122+ 3 . ** WebSocket endpoints still can't be truthfully probed over plain HTTP —
123+ the ` kind: 'ws' ` refinement.** A ws endpoint's healthy answer to a plain
124+ GET is * refusal* ; we accept 400/426 as * up* . But this host's upgrade stack
125+ registers no plain-GET route for ` ws.route ` paths, so ` GET /relay ` draws a
126+ ** 404** , indistinguishable from "no such plugin" (lands as * degraded* ).
127+ An honest ws probe needs a real handshake. This is now a corollary of
128+ finding 1's residual: if the roster carried a ` kind ` /health hint, the
129+ plugin itself (relay * knows* it's a socket) could supply it instead of the
130+ operator typing ` { kind: 'ws' } ` — or ` api.ws.route ` could register a
131+ conventional ` GET → 426 ` on the same path.
132+ 4 . ** Anonymous probes can't tell "guarded" from "missing".** On this host an
133+ unknown path draws ** 401** (WAC answers before routing), and a POST-only
134+ route answers GET with 404. So every anonymous 4xx means only "the server
135+ routed and answered", and the default <500-is-alive rule is exactly that
136+ strong. ` expect: [200] ` is the operator's tool for surfaces that should
137+ answer anonymously. A per-probe ` Authorization ` was deliberately not
138+ added: a dashboard config holding live bearer tokens is a worse failure
139+ mode than a mushy probe.
140+ 5 . ** Self-probe recursion.** The dashboard filters * itself* out of the
141+ roster (by matching its own prefix) so it never probes ` status.json ` —
142+ which would fan out N more probes until timeouts cascade. A ` config.probes `
143+ refinement pointing under the dashboard's own prefix is also rejected at
144+ ` activate ` . Exactly the footgun the #610 sketch flags for any
145+ registry-driven consumer: it finds itself in the list.
165146
166147## Test
167148
168149```
169150node --test --test-concurrency=1 dashboard/test.js
170151```
171152
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
153+ Boots one JSS (≥ 0.0.218) carrying the dashboard plus three real siblings
154+ (rss, capability, relay), with ** no** hand-fed list and ** no** loopbackUrl,
155+ and asserts: auto-discovery names every sibling, the JSON shape, the
156+ alive/expect/down semantics, the no-cache property, an empty-server render,
157+ and the ` config.probes ` validation throws (ordered before the long-lived
176158boot — the ` DATA_ROOT ` footgun).
0 commit comments