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>
425 lines
17 KiB
JavaScript
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>
|
|
)
|
|
}
|