One self-contained HTML page (plus status.json) that shows everything about
a JSS deployment the plugin api lets an operator see: the server (origin,
liveness, uptime, node, platform), every installed plugin (live loopback
probes, prefix links, per-plugin admin pages), the pods (count, disk usage,
newest activity) — composed on top of dashboard/'s probe machinery,
metrics/'s healthz idea, nip05/'s podsRoot scan, and shortlink/'s
getAgent auth.
The point is the gap analysis. The read side of wp-admin is mostly expressible today — but every pillar of it rests on config that repeats something the host already knows, and the entire write side (install, enable/disable, settings, log viewing) is architecturally absent. The page carries a "Not possible yet" strip naming each missing pillar and the seam behind it; the table below is the same analysis in full.
import { createServer } from 'javascript-solid-server/src/server.js';
const PORT = 3240;
const PUBLIC_URL = 'https://pod.example';
const PODS = '/srv/jss/data/pods';
const fastify = createServer({
root: PODS,
idp: true,
plugins: [
{ id: 'rss', module: 'plugins/rss/plugin.js', prefix: '/feed',
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
{ id: 'metrics', module: 'plugins/metrics/plugin.js', prefix: '/metrics',
config: { loopbackUrl: `http://127.0.0.1:${PORT}` } },
{ id: 'admin', module: 'plugins/admin/plugin.js', prefix: '/admin',
config: { // all optional
podsRoot: PODS, // → pod/storage stats
// Who is an operator? The api has no answer, so this plugin invents
// one: an agent-id allowlist. Omit it and the page serves OPEN
// (loud warn at activate).
adminAgents: ['https://pod.example/alice/profile/card.jsonld#me'],
// The plugin list is auto-discovered via api.plugins (#610) — no
// hand-copied inventory. `probes` only *refines* individual rows
// (the roster carries no health hints):
probes: {
rss: { probe: '/feed/atom', expect: [400], description: 'Atom/RSS feeds' },
metrics: { probe: '/metrics/healthz', expect: [200], description: 'healthz + Prometheus', adminPage: '/metrics/metrics' },
},
} },
],
});Or from the CLI — since admin needs no required config, it loads via
--plugin (open, no pod stats): jss start --plugin ./admin/plugin.js@/admin
alongside other plugins. Add podsRoot/adminAgents via a config file to
enable stats and gate it.
GET /admin/— the page (server-side rendered from a live snapshot, socurlshows real content; an inline script re-polls every ~10s — when guarded, append#token=<bearer>to the URL so the poller can authenticate).GET /admin/status.json— the same snapshot as JSON. Probed live per request: anonymous loopback GETs, concurrent, ~3s timeout each, never cached. Probe semantics aredashboard/'s:<500= alive,expect: [...]narrows,kind: 'ws'accepts 400/404/426-style upgrade refusals as routed.- Both surfaces are guarded by
adminAgentswhen present: 401 anonymous, 403 authenticated-but-not-listed, JSON errors either way.
A single column, system font, four labeled sections plus a warning strip.
Header "server admin" with the origin and either "guarded: 1 admin agent" or
"OPEN: no adminAgents configured — anyone can read this page". Server: a
key/value table — base URL, host liveness as a green/red pill with the HTTP
status and loopback latency, process uptime ("3h 12m"), node version,
platform. Plugins: a table of plugin | description | prefix | status |
http | latency | admin — each prefix a link, each status a pill (green up /
amber degraded / red down / grey unprobed), and a "settings" link where the
entry declared an adminPage. Pods / Users: pod count, disk usage
("1.2 GB (1288490188 bytes, 4302 entries walked)", with a TRUNCATED note if
the 50k-entry walk cap hit), newest pod mtime. Not possible yet: an
amber strip listing the five wp-admin pillars this page can't have, each
with its seam. Meta: "3 declared — 2 up, 0 degraded, 0 down, 1 unprobed
· generated 2026-07-11T… · polls status.json every 10s". Dark-mode via
prefers-color-scheme; no external assets anywhere.
The headline: how much of a wp-admin/Nextcloud-style admin can the plugin
api express today? Answer: most of the read side, none of the write
side. Two read pillars that used to lean on operator-repeated config now run
on shipped seams — the inventory on api.plugins (#610) and the origin on
api.serverInfo (#601) — so what's still re-declared is podsRoot and the
operator gate. Pillar by pillar:
| wp-admin pillar | what this plugin does today | the seam that would close the gap |
|---|---|---|
| Plugin inventory | ✅ auto-discovered via api.plugins (#610) — no hand-copied list, no drift |
closed. The roster is { id, prefix, module } with no health hints, so ws endpoints / odd prefixes still want a config.probes refinement; an optional per-entry health/probe hint would close that residual too |
| Install / marketplace | nothing — architecturally impossible; shown in the "not possible yet" strip | a package source + runtime install (#200 marketplace); would also need the loader to activate entries after boot |
| Enable / disable | nothing — the loader activates at boot only; deactivate() exists in the plugin contract but nothing calls it at runtime |
runtime activate()/deactivate() on the loader, driven by an authorized surface |
| Settings panels | links to a per-plugin adminPage path the operator declares — each plugin hand-rolls its own page and this one merely points at it |
a contribute-a-panel affordance (a plugin registers a settings fragment/route with the admin surface); today there is no gated hook or registration point for it |
| Health / status | ✅ the strongest pillar: host liveness + per-plugin anonymous loopback probes, live per request, expect/ws semantics from dashboard/ |
mostly closed already; per-plugin self-reported health (health: () => … on a registry entry) would beat anonymous probing |
| Users / storage | ✅ if the operator repeats podsRoot: pod count, du-style recursive size (capped at 50k entries, truncation reported, realpath'd root, symlinks never followed, contents never read), newest pod mtime |
the api doesn't mediate any filesystem access — podsRoot re-declares what the host already knows; an api.storage.stats() / data-root accessor would do this honestly. (The origin repetition this used to share is gone — api.serverInfo shipped.) |
| Log viewer | nothing — api.log is write-only |
an api.log tailing/reading seam (ring buffer or stream); until then "recent logs" can't exist on any plugin page |
| Auth / roles | invents an operator concept from scratch: config.adminAgents, an agent-id allowlist checked via api.auth.getAgent; without it the surface is OPEN behind a warn |
an operator/role concept in the api. Today every ops plugin invents its own admin auth — metrics/ a shared token, terminal/ a token, this one an agent allowlist — three plugins, three incompatible answers to "who is an operator?" |
Beyond the table:
- "Who is an operator?" has no api answer (the auth/roles row, sharpened).
getAgentverifies identity perfectly across every credential scheme, but authorization is entirely the plugin's problem, and there is no host-level notion of "the server operator" to defer to — even though the host clearly has one (whoever writescreateServer's config). Anapi.isOperator(agent)(or anoperators: [...]server option plugins can read) would let every ops surface in this repo share one answer. - Pod stats are the last config-repetition standing. The plugin can
count users and measure storage only because the operator passed the same
rootpath to bothcreateServerand this plugin's config — the api mediates no storage introspection. This used to be one of three things the operator re-declared (origin, plugin list, data root); the first two shipped (api.serverInfo,api.plugins) and admin now consumes them, so the read side is down to this one. Anapi.storage.stats()/ data-root accessor would close it. - A guarded plugin page has no session story. Core's UI has its own
login; a plugin page can't ride it — the only credential a plugin can
check is what arrives on the request, so the browser poller has to shuttle
a bearer by hand (
#token=in the URL fragment here; the fragment never leaves the browser, but it is still a token pasted into a URL bar). A contribute-a-panel seam would solve this for free, since panels would be served inside an already-authenticated core surface. - The write side is absent by architecture, not by effort — and that is
the finding, not a TODO list. Install, enable/disable, and config editing
all require the loader to act after boot; today the plugins array is
consumed once by
createServerand the only lifecycle event a plugin ever sees is its ownactivate(). Nothing on this page pretends otherwise: it has zero write operations, and the "not possible yet" strip names each absence with its seam (also exported asNOT_POSSIBLEfromplugin.jsso the list can't drift from the docs). - Composition worked. This plugin is
dashboard/'s probes +metrics/'s healthz idea +nip05/'s podsRoot scan +shortlink/'sgetAgentguard in one page, with no new primitive needed — evidence that the api's read-side vocabulary is genuinely composable. Two of the repeated-config findings it once carried are now closed (it consumesapi.pluginsandapi.serverInfo);podsRootand the operator gate remain.
node --test --test-concurrency=1 admin/test.js
Validation throws first (external probe URL, self-probe recursion — before
the long-lived boot; the DATA_ROOT footgun), then one JSS with admin + rss
- metrics auto-discovered (no hand-fed list, no
loopbackUrl),podsRootat the server root andadminAgentsset to a pod minted via/.pods+/idp/credentials: anonymous → 401, second pod's bearer → 403, admin bearer → the full page (plugin ids, server-side-rendered pods count, the not-possible strip) and thestatus.jsonshape (rss up viaexpect: [400], metrics up via[200], an unprobed self-entry, pod count 2, real byte totals, meta counts), plus the no-caching property. The open-mode boot (noadminAgents) runs LAST against a fresh server after the first is closed, and asserts the page itself declares it is OPEN.