feat(admin): the Modules screen (phase 4, slice 2)

The screen slice 1's API was written for: install from a release URL, enable,
disable, uninstall, purge, and restart. Admin-only, matching the server, and
core's own screen because it is how a module reaches the volume at all.

182 client tests (+21), manifest and OpenAPI unchanged.

Everything that decides what a row SAYS and which buttons it offers is in
`lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the
same reason `lib/adminNav.js` is. The JSX renders what it returns.

Three sources of truth, and they are allowed to disagree
--------------------------------------------------------
The row records what the operator decided and what the last boot did; the
loader says what is mounted and answering; the volume says whether there is a
directory at all. Picking one and rendering it is simpler and lies. The case
that makes it concrete is the one decision 3 creates on purpose: disable a
module (its onShutdown runs) and enable it again, and the row says `enabled`
while the loader still says `disabled` because nothing can start it before a
restart. Neither "Running" nor "Disabled" is true; "Restart to start" is.

Two shapes that are deliberately unlike the rest of the panel: the restart is
a BANNER, because a restart is a property of the server rather than of a
module and an operator who installed three modules should restart once; and
purge is offered inside the uninstall flow as a second confirm, because
purge.sql lives inside the directory being deleted and there is no later.

What the browser found that no test could
-----------------------------------------
Installing over a row the previous boot had left `startup_failed` rendered
"Failed at the require stage: module directory not present on the volume" one
second after the files had been written to the volume -- and, because that
branch is not pending, it suppressed the restart banner the install had just
told the operator to use. Every unit test passed, because none of them had
modelled a stale row plus a fresh install.

The fix is a derivation rather than a special case: the loader scans the
volume once at require time, so a module that is on the volume now and has no
live record arrived after that scan, and everything the row says about it
predates the install. That check runs before the failure one.

The same class, one place further on: an upgrade leaves the old code loaded,
so the row's version is a promise about the next boot. `liveVersion` (slice 1)
lets the screen say "Restart to finish upgrading" instead of reporting the new
version as running.

Verified against a live server and the real published release: pasted the
v0.3.0 install-manifest URL, restarted, watched the module register its five
mounts and seven streams and its own nav rows appear in the sidebar. Disable
ran its onShutdown for real -- the uo-link WebSocket closed, its routes went
to 404, and it left /public/modules -- and enable then showed the decision-3
state with the banner. The restart button itself was exercised through its
endpoint rather than clicked, because a window.confirm wedges the browser
automation.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 03:49:02 -05:00
parent 732927a6bb
commit 9083e4135a
7 changed files with 894 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
// What an admin should be told about one installed module, and what they may do
// to it — derived, not spelled out at each button.
//
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Plain JS rather than
// a hook or a chunk of JSX, for the same reason `lib/adminNav.js` is: the test
// runner here has no DOM, and this is the part of the Modules screen that is
// actually worth testing.
//
// **The screen has three sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4):
//
// state what the DATABASE row records — what the operator decided, and
// what the last boot ended up doing
// liveState what the LOADER has mounted in this process and is answering with
// onVolume whether there is still a directory there at all
//
// Picking one and rendering it would be simpler and would lie. The case that
// makes this concrete is the one decision 3 creates on purpose: an operator
// disables a module (its onShutdown runs, its routes 404) and then enables it
// again. The row says `enabled`; the loader still says `disabled`, because
// there is no `onBoot` re-dispatch and nothing can start it before a restart.
// It is neither running nor off, and the honest thing to show is "enabled —
// restart to start it".
/**
* The one-line status of a module, and whether that status is waiting on a
* restart.
*
* Ordering matters here. The checks run most-alarming first, so a module whose
* directory has been deleted is described that way rather than by whatever its
* row happens to still say.
*
* @param {object} m a row from GET /admin/modules
* @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }}
*/
export function statusOf(m) {
// Gone from the volume, but still known. Either a hand-deleted directory (the
// boot reconcile marks that `startup_failed`) or an uninstall waiting for its
// restart. Both are "there is nothing to run here".
if (!m.onVolume) {
return {
label: m.state === 'disabled' ? 'Uninstalled' : 'Missing from the volume',
tone: m.state === 'disabled' ? 'idle' : 'bad',
pending: m.liveState !== null,
detail: m.state === 'disabled'
? 'The files are gone. Its data was kept, and reinstalling brings it back.'
: 'A row exists but there is no module directory. Reinstall it, or uninstall to clear the row.',
}
}
// **Installed since this process booted**, and this check has to come before
// the failure one. `liveState` is the loader's record, and the loader scans
// the volume once at require time — so a module that is on the volume NOW and
// has no live record was put there after the scan. Anything the row still says
// about it therefore predates the install and is stale by definition.
//
// Found by the §7.7 browser smoke, and no unit test here had modelled it:
// installing over a row left `startup_failed` by the previous boot rendered
// "Failed at the require stage: module directory not present on the volume"
// one second after the file had been written to the volume — and, because that
// branch is not pending, suppressed the restart banner the install had just
// told the operator to use.
if (m.liveState === null) {
return {
label: 'Restart to start',
tone: 'warn',
pending: true,
detail: 'Installed. It mounts when the server next starts.',
}
}
if (m.state === 'startup_failed' || m.liveState === 'startup_failed') {
return {
label: 'Failed to start',
tone: 'bad',
pending: false,
detail: m.failureReason
? `Failed at the ${m.failureStage || 'unknown'} stage: ${m.failureReason}`
: 'It failed to start and recorded no reason.',
}
}
if (m.state === 'disabled') {
return {
label: 'Disabled',
tone: 'idle',
pending: false,
detail: 'Stopped and switched off. Its routes answer 404 and it stays off across restarts.',
}
}
// The row has been switched on but the loader has not started it — the
// decision-3 case: disable ran its onShutdown, and nothing can start it again
// before a restart.
if (m.liveState !== 'started') {
return {
label: 'Restart to start',
tone: 'warn',
pending: true,
detail: m.liveState === 'disabled'
? 'Enabled, but still stopped in the running server — it cannot be restarted in place.'
: 'Enabled. It mounts when the server next starts.',
}
}
// Running, but not the version that is installed. An upgrade writes new files
// and a new row while the old code stays loaded, so the row's `version` is a
// promise about the next boot rather than a description of this one — and
// "Running v2.0.0" beside a process serving v1.0.0 is the same lie as the
// stale-failure one above, in a different place.
if (m.liveVersion && m.liveVersion !== m.version) {
return {
label: 'Restart to finish upgrading',
tone: 'warn',
pending: true,
detail: `v${m.version} is installed; v${m.liveVersion} is still running.`,
}
}
return {
label: 'Running',
tone: 'ok',
pending: false,
detail: 'Mounted and serving.',
}
}
/**
* Which actions are offered for a module, and why the others are not.
*
* Returned as a map of `{ shown, reason }` rather than a list of shown actions,
* so a disabled button can say what would make it available. Every rule here
* mirrors one the server enforces — this is presentation, never the boundary.
*
* @param {object} m a row from GET /admin/modules
*/
export function actionsFor(m) {
const running = m.liveState === 'started'
const disabled = m.state === 'disabled'
return {
// Only offered while something is actually running: disabling a module that
// is already stopped has nothing to stop and no guard to flip.
disable: {
shown: !disabled && m.onVolume,
reason: disabled ? 'Already disabled.' : 'Nothing is running to stop.',
},
enable: {
shown: disabled && m.onVolume,
reason: 'Only a disabled module can be enabled.',
},
uninstall: {
shown: m.onVolume,
reason: 'There are no files left to remove.',
},
// The server refuses a standalone purge unless the module is disabled, so
// the button says so rather than offering a click that 409s.
purge: {
shown: m.onVolume && m.canPurge,
enabled: disabled,
reason: !m.canPurge
? 'This module ships no purge.sql, so its data cannot be deleted.'
: 'Disable it first, so nothing is serving out of the tables being dropped.',
},
// A row with no directory is the one thing an uninstall cannot tidy through
// the normal path — offer clearing it instead.
forget: {
shown: !m.onVolume && m.state !== null,
reason: 'The module is still installed.',
},
running,
}
}
/**
* Does anything on this list need a restart before it matches what is running?
*
* Drives the one banner at the top of the screen rather than a badge per row:
* the restart is a property of the SERVER, not of a module, and offering it
* five times would suggest otherwise.
*/
export const needsRestart = (modules) => modules.some((m) => statusOf(m).pending)
/**
* Split a hosts string the way the server will.
*
* Duplicated from `install.parseHosts` deliberately — it is four lines, and the
* alternative is an API round trip to preview what the field is going to mean.
* The server remains the one that decides; this only shows the operator how
* their typing will be read.
*/
export function parseHosts(value) {
return String(value || '')
.split(/[,\s]+/)
.map((h) => h.trim().toLowerCase())
.filter(Boolean)
}