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 ( {children} ) } // ── 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 (
Install a module

The bundle is downloaded, checked against the sha256 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(', ')}.`}

{error &&

{error}

} {result && (

{result.replaced ? 'Upgraded' : 'Installed'} {result.module?.name} v{result.module?.version}. Restart to load it.

)}
) } // ── 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 (
Restart needed

{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.'}

) } // ── 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 (
Where modules may be installed from

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: {parsed.join(', ')}.}

{error &&

{error}

} {saved && !error &&

Saved.

}
) } // ── 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 (
{m.name}
{/* 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}` : ''}
{m.capabilities?.length > 0 && (
{m.capabilities.join(' · ')}
)} {status.label}
{status.detail}
{/* 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 && (
{note.text}
)} {/* 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 ? ( <>
{m.source}
{m.sha256 &&
sha256 {m.sha256.slice(0, 12)}…
} ) : ( {m.declared ? 'From the declared module set' : 'Placed on the volume by hand'} )} {m.installedAt &&
{dateTime(m.installedAt)}
}
{actions.disable.shown && ( )} {actions.enable.shown && ( )} {actions.purge.shown && ( )} {actions.uninstall.shown && ( )} {actions.forget.shown && ( )}
) } // ── 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 if (!data) return const modules = data.modules || [] const sourceHosts = data.sourceHosts || [] return (
{needsRestart(modules) && window.location.reload()} />} {actionError && (

{actionError}

)}
{modules.length === 0 && ( )} {modules.map((m) => ( ))}
Module Status Installed from
No modules installed. Paste a release install-manifest URL above to add one.
) }