One HTML page (plus a JSON API) showing every plugin loaded on the server,
each probed live over loopback on every request. It is deliberately the
simplest possible ops surface — and it is the first live consumer of two
shipped plugin-API seams: it auto-discovers its co-loaded siblings from
api.plugins (#610) and reaches the host over loopback via api.serverInfo
(#601), both landed in JSS 0.0.218. This plugin drove the need for both
seams and now runs on them — no hand-maintained list, no origin in config.
import { createServer } from 'javascript-solid-server/src/server.js';
const fastify = createServer({
root: './data/pods',
plugins: [
{ id: 'relay', module: 'plugins/relay/plugin.js', prefix: '/relay' },
{ id: 'capability', module: 'plugins/capability/plugin.js', prefix: '/cap' },
{ id: 'rss', module: 'plugins/rss/plugin.js', prefix: '/feed',
config: { baseUrl: PUBLIC_URL } },
// No plugins list, no loopbackUrl — the dashboard discovers everything
// above via api.plugins and finds the host via api.serverInfo. The only
// config is optional per-plugin probe *refinement*:
{ id: 'dashboard', module: 'plugins/dashboard/plugin.js', prefix: '/dashboard',
config: {
probes: {
relay: { kind: 'ws' }, // a WebSocket endpoint
rss: { probe: '/feed/atom' }, // a better liveness path than bare /feed
// capability: { expect: [200] }, // declare a stricter "up"
},
} },
],
});GET /dashboard/— the page.GET /dashboard/status.json— the same data as JSON:
{
"generated": "2026-07-11T12:00:00.000Z",
"server": { "alive": true, "status": 200, "latency_ms": 2 },
"plugins": [
{ "id": "rss", "probe": "/feed/atom", "kind": "http",
"alive": true, "state": "degraded", "status": 400, "latency_ms": 3 }
]
}The plugin list comes from api.plugins — you don't supply it. Each
discovered plugin is probed at its own prefix by default; config.probes
optionally refines a single plugin's probe as { probe?, expect?, kind? }:
probe— a local path on this server, overriding the default (the plugin's prefix). It must start with/, must not contain://, and must not point back at the dashboard itself (recursion); all three are enforced with a throw atactivate. Probes go only to the host's own loopback origin, never to external URLs.- Probes carry no Authorization — anonymous liveness checks of public
surfaces. By default any status < 500 counts as alive: a 400/401/404
from a guard is a living plugin answering.
expect: [200]narrows that. kind: 'ws'— a WebSocket endpoint; an upgrade-refusal (400/426) to the plain-HTTP probe counts as up (see finding 3).- States: up (2xx/3xx or a matched
expect), degraded (a 4xx — alive but guarded), down (no answer, timeout, 5xx, orexpectmismatch).alive= not down. - Probes run server-side on every
status.jsonrequest, concurrently, each with a ~3s timeout (config.timeoutMs), never cached — O(N) loopback fetches per refresh. The host itself is probed too (GET /). - If the dashboard is the only plugin loaded, the page renders with a note explaining there are no siblings to show.
Live /dashboard/ from npm run serve — 32 plugins auto-discovered via
api.plugins, each probed over loopback. up = 2xx/3xx or a matched
expect; degraded = a 4xx from a guarded surface (alive, but a bare-prefix
probe can't say more — finding 1's residual); nothing down.
A single narrow column, system font, honest table: plugin | probe |
status | http | latency. First row is the server itself, then one row per
discovered plugin — id, probe path in code (ws probes tagged with a small
ws chip), a pill badge (green up, amber degraded, red down),
the HTTP status ("—" when nothing answered), latency in tabular numerals.
Everything inline: no external assets, one small script that re-fetches
status.json every refreshMs (default 5s). Dark-mode via
prefers-color-scheme. Without JS you still get the discovered list, so
curl shows the inventory.
| capability | status |
|---|---|
| list every loaded plugin | ✅ auto-discovered from api.plugins (#610) — no hand-copied list |
| find the host to probe it | ✅ api.serverInfo (#601) — no loopbackUrl in config |
| live liveness per plugin | ✅ anonymous loopback GET, <500 = alive, expect to sharpen |
| host self-check | ✅ GET / non-5xx |
| meaningful per-plugin probe | config.probes (finding 1) |
| ws endpoint health | |
| deep health (deps, storage, queue lag) | ❌ out of scope; surface liveness only |
api.pluginsshipped — and this is what it unlocked (with a residual). This plugin was the proof-of-need for the app-registry seam (#463/#464 → filed #610 → merged #612, JSS 0.0.218), and it now consumes it: the hand-copiedconfig.pluginsarray is gone, and drift is impossible — add or remove a plugin and the dashboard reflects it with zero config change. The residual, now sharpened: the roster is{ id, prefix, module }with no health hints, so the dashboard defaults to probing each plugin's prefix and still needsconfig.probesto characterise the ones a bare-prefix hit doesn't (ws endpoints; a plugin whose prefix 404s but a sub-path answers). That is exactly the optional per-entryprobe/healthhint anticipated in the #610 sketch — the remaining, smaller ask.api.serverInfoshipped — origin repetition gone. The dashboard used to requireloopbackUrland throw without it; now it callsapi.serverInfo()(#601, 0.0.218) per request and builds the callablehost:portitself. One timing note that makes serverInfo's function shape (not a snapshot) matter: the origin is resolved at request time, not cached atactivate— a probe-time port-0 boot only knows its real port once listening, which is precisely the case serverInfo() handles.- WebSocket endpoints still can't be truthfully probed over plain HTTP —
the
kind: 'ws'refinement. A ws endpoint's healthy answer to a plain GET is refusal; we accept 400/426 as up. But this host's upgrade stack registers no plain-GET route forws.routepaths, soGET /relaydraws a 404, indistinguishable from "no such plugin" (lands as degraded). An honest ws probe needs a real handshake. This is now a corollary of finding 1's residual: if the roster carried akind/health hint, the plugin itself (relay knows it's a socket) could supply it instead of the operator typing{ kind: 'ws' }— orapi.ws.routecould register a conventionalGET → 426on the same path. - Anonymous probes can't tell "guarded" from "missing". On this host an
unknown path draws 401 (WAC answers before routing), and a POST-only
route answers GET with 404. So every anonymous 4xx means only "the server
routed and answered", and the default <500-is-alive rule is exactly that
strong.
expect: [200]is the operator's tool for surfaces that should answer anonymously. A per-probeAuthorizationwas deliberately not added: a dashboard config holding live bearer tokens is a worse failure mode than a mushy probe. - Self-probe recursion. The dashboard filters itself out of the
roster (by matching its own prefix) so it never probes
status.json— which would fan out N more probes until timeouts cascade. Aconfig.probesrefinement pointing under the dashboard's own prefix is also rejected atactivate. Exactly the footgun the #610 sketch flags for any registry-driven consumer: it finds itself in the list.
node --test --test-concurrency=1 dashboard/test.js
Boots one JSS (≥ 0.0.218) carrying the dashboard plus three real siblings
(rss, capability, relay), with no hand-fed list and no loopbackUrl,
and asserts: auto-discovery names every sibling, the JSON shape, the
alive/expect/down semantics, the no-cache property, an empty-server render,
and the config.probes validation throws (ordered before the long-lived
boot — the DATA_ROOT footgun).
