// 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 four sources of truth and they are allowed to disagree** // (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth): // // 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 // declared what this container's MODULES variable asks for — the only one of // the four that no button on this screen can change // // 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) { // Declared by the environment and not there at all: no row, no directory, // nothing mounted. Every other branch below reads one of those three, so // without this the screen would describe a module it has never had as though // a row had gone stale — and the one thing the operator needs, the reason // resolution failed, would be nowhere. if (m.declared && !m.onVolume && m.state === null) { return { label: 'Declared, not installed', tone: 'bad', pending: false, detail: m.declaredError ? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}` : `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`, } } // 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.', } } /** * What the environment's declaration means for this module, as one sentence — or * null if nothing declares it. * * Kept out of `statusOf` on purpose. A module can be running perfectly while its * declared upgrade is failing, and collapsing both into one label would have to * pick which of the two is "the" status. This is a second line, beside the first. * * The sentence an operator most needs is the uninstall one: MODULES owns what is * on the volume and the row owns whether it runs, so uninstalling a declared * module puts its files back at the next start and leaves it switched off. Files * reappearing unexplained is exactly the kind of thing that gets debugged for an * afternoon. * * @param {object} m a row from GET /admin/modules * @returns {{ text: string, tone: 'warn'|'idle' }|null} */ export function declarationNoteFor(m) { if (!m.declared) return null if (m.declaredError) { return { text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`, tone: 'warn', } } if (!m.onVolume) { return { text: `MODULES declares v${m.declaredVersion}, so its files come back when the server next starts` + (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'), tone: 'warn', } } return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' } } /** * 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) }