feat(admin): the Modules screen (phase 4, slice 2) #143
@@ -41,6 +41,7 @@ import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
|||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||||
|
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
@@ -169,6 +170,10 @@ export default function App() {
|
|||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="users/:id" element={<UserDetail />} />
|
<Route path="users/:id" element={<UserDetail />} />
|
||||||
<Route path="invites" element={<InvitesAdmin />} />
|
<Route path="invites" element={<InvitesAdmin />} />
|
||||||
|
{/* Core's own screen, and it has to be: it is how a module reaches
|
||||||
|
the volume in the first place. Declared here with the rest of
|
||||||
|
core's routes, above the module-supplied ones below. */}
|
||||||
|
<Route path="modules" element={<ModulesAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||||
|
|||||||
@@ -233,6 +233,20 @@ export const api = {
|
|||||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag
|
||||||
|
// is a query parameter rather than a body because it hangs off a DELETE, and
|
||||||
|
// it is spelled out at the call site rather than defaulted, so the
|
||||||
|
// destructive branch is never the one you get by forgetting an argument.
|
||||||
|
listModules: () => req('/admin/modules'),
|
||||||
|
installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }),
|
||||||
|
enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
|
disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||||
|
uninstallModule: (id, { purge } = {}) =>
|
||||||
|
req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }),
|
||||||
|
purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }),
|
||||||
|
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||||
|
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
modRecent: (params = {}) => {
|
modRecent: (params = {}) => {
|
||||||
|
|||||||
197
client/src/lib/moduleAdmin.js
Normal file
197
client/src/lib/moduleAdmin.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// 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 three sources of truth and they are allowed to disagree**
|
||||||
|
// (MODULE_SYSTEM.md §2.4):
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// 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) {
|
||||||
|
// 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.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
|||||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||||
|
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
||||||
|
|
||||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
@@ -83,6 +84,10 @@ export const NAV = [
|
|||||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||||
|
// Admin-only, matching the server: every route under /admin/modules
|
||||||
|
// re-gates to `admin` on top of the group's staff gate, because installing
|
||||||
|
// a module runs its code in this process.
|
||||||
|
{ to: '/admin/modules', label: 'Modules', icon: IconModules, roles: ['admin'] },
|
||||||
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
||||||
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
|
|||||||
402
client/src/routes/admin/views/ModulesAdmin.jsx
Normal file
402
client/src/routes/admin/views/ModulesAdmin.jsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { dateTime } from '../../../lib/format.js'
|
||||||
|
import { statusOf, actionsFor, 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)
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{m.id} · 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>
|
||||||
|
</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. */}
|
||||||
|
{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>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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -140,3 +140,48 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
|||||||
assert.equal(calls[0].opts.method, 'DELETE')
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
||||||
|
//
|
||||||
|
// These pin the URLs, because the destructive one differs from the harmless one
|
||||||
|
// by a query parameter and nothing else.
|
||||||
|
|
||||||
|
test('module actions hit the right paths and methods', async () => {
|
||||||
|
const cases = [
|
||||||
|
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
||||||
|
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
||||||
|
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
||||||
|
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
||||||
|
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
||||||
|
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
||||||
|
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
||||||
|
]
|
||||||
|
for (const [call, method, url] of cases) {
|
||||||
|
calls = []
|
||||||
|
willReply({ body: {} })
|
||||||
|
await call()
|
||||||
|
assert.equal(calls[0].url, url)
|
||||||
|
assert.equal(calls[0].opts.method || 'GET', method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uninstall only asks for a purge when it is told to', async () => {
|
||||||
|
// The difference between "remove the module" and "remove the module and drop
|
||||||
|
// every table it owns" is this query parameter, so a default that leaned the
|
||||||
|
// wrong way would be irreversible.
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.admin.uninstallModule('uo')
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
||||||
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.admin.uninstallModule('uo', { purge: true })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module id is URL-encoded on the way into the path', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.admin.disableModule('a b/c')
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||||
|
})
|
||||||
|
|||||||
226
client/test/moduleAdmin.test.js
Normal file
226
client/test/moduleAdmin.test.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { statusOf, actionsFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
|
||||||
|
|
||||||
|
// lib/moduleAdmin.js — what the Modules screen says about a module and what it
|
||||||
|
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
|
||||||
|
//
|
||||||
|
// This is the part of the screen worth testing, and it is plain JS so this
|
||||||
|
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
|
||||||
|
// that the row, the loader and the volume are three sources of truth which are
|
||||||
|
// ALLOWED to disagree — so most of these cases are combinations that a screen
|
||||||
|
// picking one source would render as a lie.
|
||||||
|
|
||||||
|
/** A module as GET /admin/modules returns it, with the running case as default. */
|
||||||
|
const mod = (over = {}) => ({
|
||||||
|
id: 'uo',
|
||||||
|
name: 'Ultima Online',
|
||||||
|
version: '1.0.0',
|
||||||
|
state: 'started',
|
||||||
|
failureStage: null,
|
||||||
|
failureReason: null,
|
||||||
|
source: 'https://gitea.example.com/x/uo.json',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
installedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
liveState: 'started',
|
||||||
|
liveVersion: '1.0.0',
|
||||||
|
capabilities: [],
|
||||||
|
onVolume: true,
|
||||||
|
canPurge: true,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── statusOf ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a mounted, started module is Running and needs nothing', () => {
|
||||||
|
const s = statusOf(mod())
|
||||||
|
assert.equal(s.label, 'Running')
|
||||||
|
assert.equal(s.tone, 'ok')
|
||||||
|
assert.equal(s.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enabled in the row but disabled in the loader is "Restart to start"', () => {
|
||||||
|
// THE case decision 3 creates on purpose: disable ran the module's onShutdown,
|
||||||
|
// then the operator enabled it again. The row says enabled; nothing can start
|
||||||
|
// it before a restart. Showing either "Running" or "Disabled" would be false.
|
||||||
|
const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
|
||||||
|
assert.equal(s.label, 'Restart to start')
|
||||||
|
assert.equal(s.tone, 'warn')
|
||||||
|
assert.equal(s.pending, true)
|
||||||
|
assert.match(s.detail, /cannot be restarted in place/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('freshly installed and never booted into is also "Restart to start"', () => {
|
||||||
|
const s = statusOf(mod({ state: 'installed', liveState: null }))
|
||||||
|
assert.equal(s.label, 'Restart to start')
|
||||||
|
assert.equal(s.pending, true)
|
||||||
|
assert.match(s.detail, /mounts when the server next starts/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a disabled module is Disabled, and that is not pending anything', () => {
|
||||||
|
// Disable takes effect immediately — it is the one action that does — so there
|
||||||
|
// is nothing for a restart banner to be about.
|
||||||
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
|
||||||
|
assert.equal(s.label, 'Disabled')
|
||||||
|
assert.equal(s.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a fresh install over a failed row is pending, not failed', () => {
|
||||||
|
// THE defect the §7.7 browser smoke found, and one no test here had modelled.
|
||||||
|
// Installing over a row the previous boot left `startup_failed` rendered
|
||||||
|
// "Failed at the require stage: module directory not present on the volume" a
|
||||||
|
// second after the files had been written — and suppressed the restart banner
|
||||||
|
// the install had just told the operator to use.
|
||||||
|
//
|
||||||
|
// `liveState === null` with the module on the volume means the loader's scan
|
||||||
|
// never saw it, so it arrived after boot and everything the row says predates
|
||||||
|
// it.
|
||||||
|
const s = statusOf(mod({
|
||||||
|
state: 'startup_failed',
|
||||||
|
liveState: null,
|
||||||
|
failureStage: 'require',
|
||||||
|
failureReason: 'module directory not present on the volume',
|
||||||
|
}))
|
||||||
|
assert.equal(s.label, 'Restart to start')
|
||||||
|
assert.equal(s.pending, true)
|
||||||
|
assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the restart banner appears for that install', () => {
|
||||||
|
// The second half of the same defect: the banner is driven by `pending`, so a
|
||||||
|
// row wrongly classified as failed silently removed the only way to act on it.
|
||||||
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an upgrade that has not been restarted into says so', () => {
|
||||||
|
// Same class as the stale-failure defect: the row is a promise about the next
|
||||||
|
// boot, not a description of this one. Reporting "Running v2.0.0" while the
|
||||||
|
// process is serving v1.0.0 would hide the only action that fixes it.
|
||||||
|
const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
|
||||||
|
assert.equal(s.label, 'Restart to finish upgrading')
|
||||||
|
assert.equal(s.pending, true)
|
||||||
|
assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reinstalling the SAME version is not an upgrade in progress', () => {
|
||||||
|
assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed module reports the stage and the reason it recorded', () => {
|
||||||
|
const s = statusOf(mod({
|
||||||
|
state: 'startup_failed',
|
||||||
|
liveState: 'startup_failed',
|
||||||
|
failureStage: 'schema',
|
||||||
|
failureReason: "Unknown column 'x' in 'field list'",
|
||||||
|
}))
|
||||||
|
assert.equal(s.label, 'Failed to start')
|
||||||
|
assert.equal(s.tone, 'bad')
|
||||||
|
assert.match(s.detail, /schema stage/)
|
||||||
|
assert.match(s.detail, /Unknown column/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failure with no recorded reason says so rather than showing a blank', () => {
|
||||||
|
const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
|
||||||
|
assert.match(s.detail, /recorded no reason/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a row whose directory is gone by hand is bad, not merely disabled', () => {
|
||||||
|
// The boot reconcile marks this `startup_failed` because a row claiming to be
|
||||||
|
// enabled for a module that is not on the volume is simply untrue.
|
||||||
|
const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
|
||||||
|
assert.equal(s.label, 'Missing from the volume')
|
||||||
|
assert.equal(s.tone, 'bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
|
||||||
|
// Uninstall leaves the row `disabled` and the data alone — which is the whole
|
||||||
|
// point of keeping the row, so the screen has to say it.
|
||||||
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
|
||||||
|
assert.equal(s.label, 'Uninstalled')
|
||||||
|
assert.equal(s.tone, 'idle')
|
||||||
|
assert.match(s.detail, /data was kept/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing-from-the-volume beats every other status', () => {
|
||||||
|
// Ordering: a module with no files is described that way whatever its row
|
||||||
|
// still claims, because there is nothing there to be running.
|
||||||
|
for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
|
||||||
|
assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── actionsFor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a running module offers disable, uninstall and a blocked purge', () => {
|
||||||
|
const a = actionsFor(mod())
|
||||||
|
assert.equal(a.disable.shown, true)
|
||||||
|
assert.equal(a.enable.shown, false)
|
||||||
|
assert.equal(a.uninstall.shown, true)
|
||||||
|
assert.equal(a.purge.shown, true)
|
||||||
|
// Shown but not clickable: the server refuses a standalone purge on anything
|
||||||
|
// that is not disabled, so offering the click would only produce a 409.
|
||||||
|
assert.equal(a.purge.enabled, false)
|
||||||
|
assert.match(a.purge.reason, /Disable it first/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a disabled module offers enable, and purge is now live', () => {
|
||||||
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
|
||||||
|
assert.equal(a.enable.shown, true)
|
||||||
|
assert.equal(a.disable.shown, false)
|
||||||
|
assert.equal(a.purge.enabled, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module with no purge.sql never offers purge, and says why', () => {
|
||||||
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
|
||||||
|
assert.equal(a.purge.shown, false)
|
||||||
|
assert.match(a.purge.reason, /ships no purge.sql/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module with no files offers only clearing the row', () => {
|
||||||
|
const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
|
||||||
|
assert.equal(a.uninstall.shown, false)
|
||||||
|
assert.equal(a.disable.shown, false)
|
||||||
|
assert.equal(a.enable.shown, false)
|
||||||
|
assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
|
||||||
|
assert.equal(a.forget.shown, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a directory with no row yet is actionable, and offers nothing to forget', () => {
|
||||||
|
// A hand-placed install before its first boot: it has no row, so `state` is
|
||||||
|
// null. Its routes are already being served, so it must be disableable.
|
||||||
|
const a = actionsFor(mod({ state: null, liveState: 'started' }))
|
||||||
|
assert.equal(a.disable.shown, true)
|
||||||
|
assert.equal(a.uninstall.shown, true)
|
||||||
|
assert.equal(a.forget.shown, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── needsRestart ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the restart banner is driven by the list, not by any one module', () => {
|
||||||
|
// A restart is a property of the SERVER. One pending module is enough, and
|
||||||
|
// three do not mean three restarts.
|
||||||
|
assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
|
||||||
|
assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
|
||||||
|
assert.equal(needsRestart([]), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a disabled module does not ask for a restart', () => {
|
||||||
|
// Disable is immediate; a banner here would be asking for a restart that
|
||||||
|
// would change nothing.
|
||||||
|
assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed module does not ask for a restart either', () => {
|
||||||
|
// It is retried on every boot anyway, and the operator has to fix the cause
|
||||||
|
// first — a banner would suggest restarting is the remedy.
|
||||||
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseHosts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseHosts previews exactly what the server will store', () => {
|
||||||
|
assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
|
||||||
|
assert.deepEqual(parseHosts(' '), [])
|
||||||
|
assert.deepEqual(parseHosts(undefined), [])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user