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

Merged
whitlocktech merged 2 commits from feature/module-admin-screen into edge 2026-08-12 08:52:25 +00:00
Member

The screen website#142's API was written for.

Merge order: #142 first, then this. It is branched from #142, so until that merges this PR's diff shows slice 1's commits too; afterwards it collapses to the client half alone. It targets edge rather than #142's branch on purpose — pr-checks.yml only triggers on PRs into main or edge, so a PR stacked on a feature branch gets no CI at all.

Install from a release URL, enable, disable, uninstall, purge, restart. Admin-only, matching the server — and core's own screen, because it is how a module reaches the volume at all.

Client tests 182 (+21)
Server tests 723, unchanged
Manifest / OpenAPI unchanged

Everything that decides what a row says and which buttons it offers lives 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, 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), then enable it again. The row says enabled; the loader still says disabled, because nothing can start it before a restart. Neither "Running" nor "Disabled" is true:

Restart to start — Enabled, but still stopped in the running server — it cannot be restarted in place.

Two shapes deliberately unlike the rest of the panel:

  • The restart is a banner, not a per-row button. A restart is a property of the server; an operator who installed three modules should restart once.
  • Purge is a second confirm inside the uninstall flow, 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; none had modelled a stale row plus a fresh install.

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

Same class, one step on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. liveVersion (added in #142) lets the screen say "Restart to finish upgrading" rather than reporting the new version as running.

Verified against a live server and the real release

Pasted the published v0.3.0 install-manifest URL into the form, restarted, and watched it come up:

[modules] registered module "uo" v0.3.0 {"mounts":{"public":["/shard","/atlas"],...}}
[modules] schema ensured for module "uo" {"statements":37}
[modules] module "uo" started

The module's own nav rows (In-Game Ops, Houses) appeared in the sidebar. Then disable ran its onShutdown for real:

[modules] module "uo" stopped by an operator
[admin-modules] module disabled by an operator {"id":"uo","hookRan":true,"by":"navadmin"}
GET /api/v1/public/shard/status -> 404
GET /api/v1/public/modules -> {"modules":[]}

— the uo-link WebSocket closed, its routes went to 404, and it left the public list. Enable then showed the decision-3 state with the banner.

The restart button was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. That run is what found the Windows signal defect fixed in #142.


AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

The screen [website#142](https://gitea.whitlocktech.com/RunicGateway/website/pulls/142)'s API was written for. > **Merge order: #142 first, then this.** It is branched from #142, so until that merges this PR's diff shows slice 1's commits too; afterwards it collapses to the client half alone. It targets `edge` rather than #142's branch on purpose — `pr-checks.yml` only triggers on PRs into `main` or `edge`, so a PR stacked on a feature branch gets **no CI at all**. Install from a release URL, enable, disable, uninstall, purge, restart. Admin-only, matching the server — and core's own screen, because it is how a module reaches the volume at all. | | | | --- | --- | | Client tests | **182** (+21) | | Server tests | 723, unchanged | | Manifest / OpenAPI | unchanged | Everything that decides what a row *says* and which buttons it offers lives 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, 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), then enable it again. The row says `enabled`; the loader still says `disabled`, because nothing can start it before a restart. Neither "Running" nor "Disabled" is true: > **Restart to start** — Enabled, but still stopped in the running server — it cannot be restarted in place. Two shapes deliberately unlike the rest of the panel: - **The restart is a banner, not a per-row button.** A restart is a property of the server; an operator who installed three modules should restart once. - **Purge is a second confirm inside the uninstall flow**, 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; none had modelled a stale row plus a fresh install. The fix is a derivation, not a special case: the loader scans the volume once at require time, so a module on the volume *now* with no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. Same class, one step on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (added in #142) lets the screen say "Restart to finish upgrading" rather than reporting the new version as running. ## Verified against a live server and the real release Pasted the published v0.3.0 install-manifest URL into the form, restarted, and watched it come up: ``` [modules] registered module "uo" v0.3.0 {"mounts":{"public":["/shard","/atlas"],...}} [modules] schema ensured for module "uo" {"statements":37} [modules] module "uo" started ``` The module's own nav rows (In-Game Ops, Houses) appeared in the sidebar. Then **disable ran its `onShutdown` for real**: ``` [modules] module "uo" stopped by an operator [admin-modules] module disabled by an operator {"id":"uo","hookRan":true,"by":"navadmin"} GET /api/v1/public/shard/status -> 404 GET /api/v1/public/modules -> {"modules":[]} ``` — the uo-link WebSocket closed, its routes went to 404, and it left the public list. Enable then showed the decision-3 state with the banner. The restart button was exercised **through its endpoint rather than clicked**, because a `window.confirm` wedges the browser automation. That run is what found the Windows signal defect fixed in #142. --- ### AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude changed target branch from feature/module-install-service to edge 2026-08-12 08:51:34 +00:00
wtclaude added 2 commits 2026-08-12 08:51:34 +00:00
Standing the slice-2 screen up against a live server and installing the
published module-uo v0.3.0 through it found three things, none of which any
unit test in this repo could have caught. Two of them are older than this
phase.

1. The boot refresh nulled every install's provenance
--------------------------------------------------------
`installed_modules.source` and `.sha256` exist so the admin panel can say
where a module came from. They never survived a restart.

`lifecycle.boot()` re-records every scanned module with no source and no
sha256 -- correctly, because a scan finds a directory and never where it came
from -- and `upsert` assigned both columns unconditionally. So an install's
provenance lasted exactly until the restart that install asked for, and the
screen then described a module installed from a URL as "placed on the volume
by hand". Verified live: install, restart, provenance gone.

Nothing could have caught it before now. Phase 4 wrote the first non-null
value these columns had ever had, so lifecycle.js's comment asserting that
"recordInstalled leaves what it is not given" described an intention rather
than the statement below it -- and modules.model.test.js's fake reproduced
the defect faithfully, assigning unconditionally just like the SQL.

Fixed with COALESCE(VALUES(col), col): a value overwrites, a NULL leaves what
is there. The fake now matches, and two tests pin both directions -- a boot
refresh must not wipe it, and a re-install from a new URL must still replace
it, or the column would become write-once and an upgrade would for ever show
where the first version came from.

2. The restart killed the server on Windows instead of stopping it
------------------------------------------------------------------
The route called `process.kill(process.pid, 'SIGTERM')` to reach server.js's
graceful-shutdown handler. That works on Linux. **Windows has no POSIX
signals, and Node documents SIGTERM there as unconditional termination of the
target process** -- so on a Windows host the restart killed the server
outright: no module onShutdown, no listener close, no pool close, no log
flush. Observed exactly that: the process was gone and the shutdown handler
had logged nothing at all.

`process.on('SIGTERM', ...)` is an ordinary EventEmitter listener, so
`process.emit('SIGTERM')` reaches the same handler on every platform without
involving the OS. One shutdown path, still; it just gets there by an event.

Deployment is Linux containers and would never have shown this. Development
is not, and neither is the smoke that found it.

The test was worse than useless: it stubbed `process.kill` and asserted it
had been called with SIGTERM, which is precisely the call whose MEANING
differs by platform. It now waits for the SIGTERM EVENT -- what server.js is
actually subscribed to -- so a pass here means the handler would run.

3. `present()` did not publish the running version
--------------------------------------------------
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. Adds `liveVersion` from the loader beside `liveState`, so the
screen can tell the two apart instead of reporting the new version as running.

723 server tests (+2), manifest and OpenAPI both unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
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>
whitlocktech merged commit 75f4d29e93 into edge 2026-08-12 08:52:25 +00:00
whitlocktech deleted branch feature/module-admin-screen 2026-08-12 08:52:26 +00:00
Sign in to join this conversation.
No description provided.