|
| 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