Files
website/client/src/routes/admin/views/ModulesAdmin.jsx
wtclaude 9b16f39a52
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 07:57:08 -05:00

425 lines
17 KiB
JavaScript

import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../../../lib/moduleAdmin.js'
import { api } from '../../../api/client.js'
// Installed modules: install from a release URL, enable, disable, uninstall,
// purge, and restart the server so the changes take effect.
//
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Everything that
// decides what a row SAYS and which buttons it offers lives in
// lib/moduleAdmin.js, which is plain JS and has tests; this file renders it.
//
// Two things about this screen are unlike the rest of the admin panel and are
// deliberate:
//
// 1. **Restart is a banner, not a per-row button.** A restart is a property of
// the server, not of a module. Offering it on five rows would suggest
// otherwise, and an operator who installed three modules should restart
// once.
// 2. **Disable is the only action that takes effect immediately.** Everything
// else is "true after the next boot", because the loader reads the volume
// at require time (§1.12). The buttons say which they are.
const TONE = {
ok: '#7fd0a4',
warn: 'var(--accent)',
bad: '#d98b84',
idle: 'var(--muted)',
}
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
function Pill({ tone, children }) {
return (
<span
className="badge"
style={{ color: TONE[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
}
// ── Install ────────────────────────────────────────────────────────────────
function InstallForm({ sourceHosts, onInstalled }) {
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null)
async function submit(e) {
e.preventDefault()
setError('')
setResult(null)
if (!url.trim()) return setError('Paste the URL of a release install manifest.')
setBusy(true)
try {
const res = await api.admin.installModule(url.trim())
setResult(res)
setUrl('')
await onInstalled()
} catch (err) {
// The server's message is written to be read by whoever pasted the URL —
// which host was refused, which hash did not match, what the archive
// contained. Replacing it with something friendlier would throw away the
// only part that helps.
setError(err.message || 'Could not install that module.')
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Install a module</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 380px' }}>
<span className="field-label">Release install-manifest URL</span>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input"
placeholder="https://gitea.example.com/org/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json"
/>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Installing…' : 'Install'}
</button>
</form>
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
The bundle is downloaded, checked against the <code>sha256</code> its release published, and
unpacked onto the modules volume. It starts serving after a restart.{' '}
{sourceHosts.length === 0
? 'No source hosts are allowed yet — add one below before installing.'
: `Allowed hosts: ${sourceHosts.join(', ')}.`}
</p>
{error && <p className="sans" style={{ margin: '12px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
{result && (
<p className="sans" style={{ margin: '12px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>
{result.replaced ? 'Upgraded' : 'Installed'} {result.module?.name} v{result.module?.version}. Restart to load it.
</p>
)}
</div>
)
}
// ── The restart banner ─────────────────────────────────────────────────────
function RestartBanner({ onDone }) {
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(false)
async function restart() {
// Said plainly, because it is true and because the failure mode is bad: a
// deployment with no supervisor does not come back on its own.
const ok = window.confirm(
'Restart the server now?\n\n'
+ 'The site will be briefly unavailable. It comes back on its own only if something is '
+ 'supervising the process — the shipped Docker Compose file does. If you are running '
+ '`npm start` by hand, you will have to start it again yourself.',
)
if (!ok) return
setBusy(true)
try {
await api.admin.restartServer()
setSent(true)
// Nothing is coming back on this connection: the process is exiting. Give
// the supervisor a moment and then reload, which is what the operator was
// about to do anyway.
setTimeout(() => { if (onDone) onDone() }, 6000)
} catch {
// A failed request here is expected as often as not — the process can win
// the race and drop the socket before the response lands.
setSent(true)
setTimeout(() => { if (onDone) onDone() }, 6000)
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 18, marginBottom: 22, borderColor: 'var(--accent)' }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px' }}>
<div className="field-label" style={{ marginBottom: 4 }}>Restart needed</div>
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
{sent
? 'Restarting. This page will reload once the server is back.'
: 'Modules are read from disk when the server starts, so an install, an uninstall or a re-enable only takes effect after a restart.'}
</p>
</div>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || sent} onClick={restart}>
{sent ? 'Restarting…' : 'Restart the server'}
</button>
</div>
</div>
)
}
// ── The source allowlist ───────────────────────────────────────────────────
function SourceHosts({ hosts, onSaved }) {
const [value, setValue] = useState(hosts.join(', '))
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => { setValue(hosts.join(', ')) }, [hosts])
async function save(e) {
e.preventDefault()
setError('')
setSaved(false)
setBusy(true)
try {
await api.admin.setModuleSources(value)
setSaved(true)
await onSaved()
} catch (err) {
setError(err.message || 'Could not save the allowlist.')
} finally {
setBusy(false)
}
}
const parsed = parseHosts(value)
return (
<div className="panel" style={{ padding: 22, marginTop: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Where modules may be installed from</div>
<form onSubmit={save} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 380px' }}>
<span className="field-label">Allowed hosts</span>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
className="input"
placeholder="gitea.example.com, releases.example.org"
/>
</label>
<button type="submit" disabled={busy} className="btn btn-sq">{busy ? 'Saving…' : 'Save'}</button>
</form>
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Installing a module runs its code inside this server, so only hosts listed here may be
installed from over HTTPS, and re-checked on every redirect. An empty list blocks all
installs.{' '}
{parsed.length > 0 && <>Will be saved as: <code>{parsed.join(', ')}</code>.</>}
</p>
{error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
{saved && !error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>Saved.</p>}
</div>
)
}
// ── One module ─────────────────────────────────────────────────────────────
function ModuleRow({ m, onChanged, onError }) {
const [busy, setBusy] = useState('')
const status = statusOf(m)
const actions = actionsFor(m)
const note = declarationNoteFor(m)
async function run(name, fn) {
setBusy(name)
try {
await fn()
await onChanged()
} catch (err) {
onError(err.message || `Could not ${name} ${m.id}.`)
} finally {
setBusy('')
}
}
const disable = () => run('disable', () => api.admin.disableModule(m.id))
const enable = () => run('enable', () => api.admin.enableModule(m.id))
function uninstall() {
// The purge choice is made HERE and only here, because purge.sql lives
// inside the directory the uninstall is about to delete — there is no
// "purge it later" (§2.7.2 decision 5). Two prompts rather than one, so
// "delete the data too" is never something you agree to by reflex.
if (!window.confirm(`Uninstall ${m.name}?\n\nIts files are removed. Its data is kept unless you ask otherwise next.`)) return
let purge = false
if (m.canPurge) {
purge = window.confirm(
`Also permanently delete ${m.name}'s data?\n\n`
+ 'This drops its tables and cannot be undone. This is the only moment it can be offered — '
+ 'the script that does it is part of the files being removed.\n\n'
+ 'OK deletes the data. Cancel keeps it.',
)
}
return run('uninstall', () => api.admin.uninstallModule(m.id, { purge }))
}
function purge() {
if (!window.confirm(`Permanently delete ${m.name}'s data?\n\nThis drops its tables and cannot be undone.`)) return
return run('purge', () => api.admin.purgeModule(m.id))
}
const forget = () => run('forget', () => api.admin.uninstallModule(m.id))
return (
<tr>
<td className="adm-td" style={{ color: 'var(--text)' }}>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div className="dim" style={{ fontSize: '0.76rem' }}>
{/* A declared module that has never installed has no version to show —
only the one MODULES asks for, which the status column carries. */}
{m.id}{m.version ? ` · v${m.version}` : ''}
</div>
{m.capabilities?.length > 0 && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>{m.capabilities.join(' · ')}</div>
)}
</td>
<td className="adm-td">
<Pill tone={status.tone}>{status.label}</Pill>
<div className="dim" style={{ fontSize: '0.74rem', marginTop: 4, maxWidth: 380 }}>{status.detail}</div>
{/* The environment's declaration, on its own line: a module can be
running fine while its declared upgrade is failing, and the status
above can only be one of those two things. */}
{note && (
<div
style={{
fontSize: '0.74rem',
marginTop: 4,
maxWidth: 380,
color: note.tone === 'warn' ? TONE.warn : 'var(--muted)',
}}
>
{note.text}
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.74rem' }}>
{/* Provenance. Null for a directory placed on the volume by hand, which
stays a supported install — so it is shown as that, not as missing.
A declared module can also reach a boot with no provenance: the
no-op path never fetches, so it has no sha256 to record and no
reason to write a row. Saying "by hand" there would be the one
wrong answer. */}
{m.source ? (
<>
<div style={{ wordBreak: 'break-all', maxWidth: 260 }}>{m.source}</div>
{m.sha256 && <div style={{ marginTop: 2 }}>sha256 {m.sha256.slice(0, 12)}</div>}
</>
) : (
<span>{m.declared ? 'From the declared module set' : 'Placed on the volume by hand'}</span>
)}
{m.installedAt && <div style={{ marginTop: 2 }}>{dateTime(m.installedAt)}</div>}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div style={{ display: 'inline-flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{actions.disable.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={disable}>
{busy === 'disable' ? 'Stopping…' : 'Disable'}
</button>
)}
{actions.enable.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={enable}>
{busy === 'enable' ? 'Enabling…' : 'Enable'}
</button>
)}
{actions.purge.shown && (
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', ...DANGER, opacity: actions.purge.enabled ? 1 : 0.45 }}
disabled={Boolean(busy) || !actions.purge.enabled}
title={actions.purge.enabled ? undefined : actions.purge.reason}
onClick={purge}
>
{busy === 'purge' ? 'Purging…' : 'Purge data'}
</button>
)}
{actions.uninstall.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem', ...DANGER }} disabled={Boolean(busy)} onClick={uninstall}>
{busy === 'uninstall' ? 'Removing…' : 'Uninstall'}
</button>
)}
{actions.forget.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={forget}>
{busy === 'forget' ? 'Clearing…' : 'Clear the row'}
</button>
)}
</div>
</td>
</tr>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function ModulesAdmin() {
const [data, setData] = useState(null)
const [error, setError] = useState('')
const [actionError, setActionError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setData(await api.admin.listModules())
} catch {
setError('Could not load installed modules.')
}
}, [])
useEffect(() => { load() }, [load])
if (error) return <ErrorState message={error} />
if (!data) return <Loading />
const modules = data.modules || []
const sourceHosts = data.sourceHosts || []
return (
<section>
{needsRestart(modules) && <RestartBanner onDone={() => window.location.reload()} />}
<InstallForm sourceHosts={sourceHosts} onInstalled={load} />
{actionError && (
<p className="sans" style={{ margin: '0 0 14px', color: TONE.bad, fontSize: '0.85rem' }}>{actionError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Module</th>
<th className="adm-th">Status</th>
<th className="adm-th">Installed from</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{modules.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
No modules installed. Paste a release install-manifest URL above to add one.
</td>
</tr>
)}
{modules.map((m) => (
<ModuleRow key={m.id} m={m} onChanged={load} onError={setActionError} />
))}
</tbody>
</table>
</div>
<SourceHosts hosts={sourceHosts} onSaved={load} />
</section>
)
}