diff --git a/api/router.ts b/api/router.ts index 5375782..66458fe 100644 --- a/api/router.ts +++ b/api/router.ts @@ -11,6 +11,7 @@ import { reconcile } from './derive'; import { createDatabases, pushSchema, readDatabases } from './databases'; import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups'; import { watchMailbox } from './messages'; +import { readServers, savePorts, startServer, stopServer } from './servers'; import { join } from 'node:path'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; @@ -402,6 +403,42 @@ router.post('/_config/backups/run', async (ctx) => { } }); +/** + * The site's two servers, under pm2. + * + * The target is a route PARAM and is checked against the two names this plugin owns before anything is + * run. Everything downstream is built from that pair and from nothing a caller supplies, so no request + * can reach another process on this machine — which matters, because Officer's own processes share the + * pm2 daemon these commands talk to. + */ +router.get('/_config/servers', async (ctx) => ctx.json(await readServers())); + +router.put('/_config/servers', async (ctx) => { + const { development, production } = (ctx.get('body') ?? {}) as { development?: number; production?: number }; + try { + return ctx.json(await savePorts(development, production)); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + +router.post('/_config/servers/:which/:action', async (ctx) => { + const which = ctx.req.param('which'); + const action = ctx.req.param('action'); + if (which !== 'development' && which !== 'production') { + return ctx.json({ error: 'Server must be development or production.' }, 400); + } + if (action !== 'start' && action !== 'stop') { + return ctx.json({ error: 'Action must be start or stop.' }, 400); + } + try { + const result = action === 'start' ? await startServer(which) : await stopServer(which); + return ctx.json({ ...result, servers: await readServers() }, result.ok ? 200 : 400); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.get('/_config/summary', async (ctx) => { const user = ctx.get('user'); const settings = await readSettings(user.email); diff --git a/api/servers.ts b/api/servers.ts new file mode 100644 index 0000000..17f4208 --- /dev/null +++ b/api/servers.ts @@ -0,0 +1,212 @@ +import { join } from 'node:path'; +import { db } from 'officerdb/db'; +import { eq } from 'drizzle-orm'; +import { fofosAdminSettings } from '../db/schema'; +import { readDatabases } from './databases'; + +/** + * The site's two servers — development and production — run under pm2. + * + * ── pm2 for BOTH, including development ── + * + * A `bun dev` started from a terminal dies with the terminal, and nobody can tell whether it is running + * without finding the window it was started in. Under pm2 both are the same kind of thing: a named + * process with a status, a log and an uptime, startable from here and inspectable from anywhere. The + * cost is that development no longer prints to a console somebody is watching, which is what + * `pm2 logs` is for. + * + * ── This plugin owns EXACTLY two process names, and touches nothing else ── + * + * `fofos-dev` and `fofos-prod`. Every command here is built from that pair and from nothing a caller + * supplies, so there is no request that can stop, restart or delete another process on the machine. + * That matters more than usual: this runs on a host where Officer's own processes and other people's + * production services share one pm2 daemon, and a plugin that could name any of them would be a way to + * take them down through a web form. + * + * ── Absolute paths for pm2 and bun ── + * + * The platform's PATH is whatever pm2 was started with, which is not a login shell's. `website.ts` + * already learned this about bun; the same applies to pm2 itself. + */ + +const PM2 = '/usr/local/bin/pm2'; + +export type ServerName = 'development' | 'production'; + +/** The one place these names exist. Nothing else in this module may build one. */ +const PROCESS = { development: 'fofos-dev', production: 'fofos-prod' } as const; + +export type ServerStatus = { + name: ServerName; + /** The pm2 process name, so somebody can go and look at it with `pm2 logs`. */ + process: string; + port: number | null; + /** `online`, `stopped`, `errored` — pm2's own word, or null when it has never been started. */ + status: string | null; + uptimeMs: number | null; + restarts: number | null; + /** How the site would be reached, once it is up. */ + url: string | null; +}; + +const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; + +const run = async ( + argv: string[], + timeoutMs = 60_000, + env?: Record, +): Promise<{ ok: boolean; output: string }> => { + /* + pm2 hands the environment it was INVOKED with to the process it starts, so the way to give the site + its port and its database is to put them in this command's own environment. There is no flag for + it — `--env` names a block in an ecosystem file, not a variable. + */ + const proc = Bun.spawn(argv, { + stdout: 'pipe', + stderr: 'pipe', + stdin: 'ignore', + ...(env ? { env: { ...process.env, ...env } } : {}), + }); + const timer = setTimeout(() => proc.kill(), timeoutMs); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: code === 0, output: `${stdout}${stderr}`.trim() }; + } finally { + clearTimeout(timer); + } +}; + +export function checkPort(value: unknown, field: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port < 1024 || port > 65535) { + throw new Error(`The ${field} port must be a whole number between 1024 and 65535.`); + } + return port; +} + +/** What pm2 says about our two, and nothing about anybody else's. */ +export async function readServers(): Promise { + const row = await readRow(); + const ports: Record = { + development: row?.devPort ?? null, + production: row?.prodPort ?? null, + }; + + const listed = await run([PM2, 'jlist'], 20_000); + let processes: Array<{ name?: string; pm2_env?: { status?: string; pm_uptime?: number; restart_time?: number } }> = []; + if (listed.ok) { + try { + processes = JSON.parse(listed.output); + } catch { + // pm2 printed something that is not JSON — a daemon starting, usually. Treated as "no answer" + // rather than as "not running", because those are different and only one of them is alarming. + processes = []; + } + } + + return (['development', 'production'] as const).map((name) => { + const found = processes.find((entry) => entry.name === PROCESS[name]); + const port = ports[name]; + return { + name, + process: PROCESS[name], + port, + status: found?.pm2_env?.status ?? null, + uptimeMs: found?.pm2_env?.pm_uptime && found.pm2_env.status === 'online' ? Date.now() - found.pm2_env.pm_uptime : null, + restarts: found?.pm2_env?.restart_time ?? null, + url: port ? `http://127.0.0.1:${port}` : null, + }; + }); +} + +export async function savePorts(dev: unknown, prod: unknown): Promise { + const devPort = checkPort(dev, 'development'); + const prodPort = checkPort(prod, 'production'); + if (devPort === prodPort) throw new Error('The two servers cannot share a port.'); + + const row = await readRow(); + if (row) { + await db + .update(fofosAdminSettings) + .set({ devPort, prodPort, updatedAt: new Date() }) + .where(eq(fofosAdminSettings.id, row.id)); + } else { + await db.insert(fofosAdminSettings).values({ devPort, prodPort }); + } + return readServers(); +} + +/** + * Start one of them. + * + * `pm2 delete` first, then `start`. Not `restart`: restart on a process that has never existed fails, + * and restart on one that exists reuses the env it was started with — so a port or database changed + * here would be saved, reported, and not actually in effect until somebody worked out why. + * + * The environment is assembled here rather than left to the checkout's `.env`, because the plugin is + * what knows the ports and the database names. `src/env-file.ts` reads files first and the environment + * last, so anything written down in the checkout still wins — this fills what is absent, it does not + * override what somebody chose. + */ +export async function startServer(which: ServerName): Promise<{ ok: boolean; output: string }> { + const row = await readRow(); + if (!row?.rootPath) throw new Error('No root location is set.'); + + const port = which === 'development' ? row.devPort : row.prodPort; + if (!port) throw new Error(`No ${which} port is set.`); + + const websiteDir = join(row.rootPath, 'website'); + const databases = await readDatabases(); + const target = which === 'development' ? databases.development : databases.production; + if (!target.name || !target.exists) throw new Error(`The ${which} database does not exist yet.`); + + const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; + if (!raw) throw new Error('The platform has no Postgres connection configured.'); + const url = new URL(raw); + url.pathname = `/${target.name}`; + + const env: Record = { + BUN_PORT: String(port), + // Both names, because the site's guard compares them and one alone leaves the other falling back + // to whatever the repository shipped with. Same reasoning as the schema push. + ...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}), + ...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}), + ...(which === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }), + }; + + // Removed first so a stale process cannot survive with the old environment. Failure is expected and + // ignored: the usual case is that it was never there. + await run([PM2, 'delete', PROCESS[which]], 20_000); + + const started = await run( + [ + PM2, + 'start', + process.execPath, + '--name', + PROCESS[which], + '--cwd', + websiteDir, + '--', + 'run', + which === 'development' ? 'dev' : 'start', + ], + 120_000, + env, + ); + + return { + ok: started.ok, + output: `$ pm2 start ${PROCESS[which]} (${which}, port ${port})\n${started.output}`, + }; +} + +/** Stop one. `delete`, not `stop`, so a stopped entry does not linger in the list looking like a fault. */ +export async function stopServer(which: ServerName): Promise<{ ok: boolean; output: string }> { + const result = await run([PM2, 'delete', PROCESS[which]], 30_000); + return { ok: result.ok, output: `$ pm2 delete ${PROCESS[which]}\n${result.output}` }; +} diff --git a/db/schema.ts b/db/schema.ts index b4c42a1..f6286d5 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -145,6 +145,16 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', { */ backupKeep: integer('backup_keep'), + /** + * The ports the site's two servers listen on. + * + * Null means not chosen, and there is no default: a port is the one setting where a sensible-looking + * guess is worst — it binds, the site appears to work, and it is quietly answering on something + * another service wanted. + */ + devPort: integer('dev_port'), + prodPort: integer('prod_port'), + /** * When the last one finished, which is what makes the schedule survive a restart. * diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 3831a80..e3208e5 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,6 +1,7 @@ import { useSearchParams } from 'react-router'; import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; +import { ServersPanel } from './ServersPanel'; import { MediaPanel } from './MediaPanel'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; @@ -40,6 +41,7 @@ import { useReady } from './useReady'; const PANELS: Partial React.JSX.Element>> = { messages: MessagesPanel, administration: AdministrationPanel, + servers: ServersPanel, media: MediaPanel, }; diff --git a/web/ServersPanel.tsx b/web/ServersPanel.tsx new file mode 100644 index 0000000..312ee5e --- /dev/null +++ b/web/ServersPanel.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Check, CircleDot, Loader2, Play, Server, Square } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { readableError } from './errors'; +import { Block } from './Block'; +import { Console } from './Console'; +import { useFlash } from './useFlash'; + +/** + * The two servers that serve the site, under pm2. + * + * ── Both under pm2, development included ── + * + * A `bun dev` started in a terminal dies with the terminal, and nobody can tell whether it is running + * without finding the window it was started in. Under pm2 the two are the same kind of thing: a named + * process with a status and an uptime, startable from here and inspectable from anywhere. + * + * ── Start is really a replace ── + * + * The port and the database come from these settings, and pm2 keeps the environment a process was + * STARTED with. So starting removes any existing process of that name first — otherwise a port changed + * here would be saved, shown, and not in effect, which is the kind of thing somebody debugs for an hour. + * + * ── The process names are shown ── + * + * `fofos-dev` and `fofos-prod`, in the row. Whatever this panel can do, `pm2 logs fofos-dev` does more, + * and somebody who knows that should not have to guess the name to use it. + */ + +type Server = { + name: 'development' | 'production'; + process: string; + port: number | null; + status: string | null; + uptimeMs: number | null; + restarts: number | null; + url: string | null; +}; + +const uptime = (ms: number) => { + const minutes = Math.floor(ms / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`; +}; + +export const ServersPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [servers, setServers] = useState(null); + const [dev, setDev] = useState(''); + const [prod, setProd] = useState(''); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [output, setOutput] = useState(null); + const [failed, setFailed] = useState(false); + const [flashing, flash] = useFlash(); + + const load = useCallback(async () => { + try { + const current = await clientRef.current.get('/fofos-admin/_config/servers'); + setServers(current); + setDev((typed) => typed || String(current.find((s) => s.name === 'development')?.port ?? '')); + setProd((typed) => typed || String(current.find((s) => s.name === 'production')?.port ?? '')); + } catch { + setServers(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const savePorts = async () => { + setBusy('ports'); + setError(null); + try { + setServers( + await clientRef.current.put('/fofos-admin/_config/servers', { + development: Number(dev), + production: Number(prod), + }), + ); + flash(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(null); + } + }; + + const act = async (which: string, action: 'start' | 'stop') => { + setBusy(`${which}:${action}`); + setError(null); + setFailed(false); + setOutput(null); + try { + const result = await clientRef.current.post<{ ok: boolean; output: string; servers: Server[] }>( + `/fofos-admin/_config/servers/${which}/${action}`, + {}, + ); + setServers(result.servers); + setOutput(result.output); + setFailed(!result.ok); + } catch (failure) { + setOutput(readableError(failure)); + setFailed(true); + void load(); + } finally { + setBusy(null); + } + }; + + const dirty = + dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '') || + prod !== String(servers?.find((s) => s.name === 'production')?.port ?? ''); + + const row = (server: Server, value: string, onChange: (next: string) => void) => { + const online = server.status === 'online'; + const label = server.name === 'development' ? 'Development' : 'Production'; + + return ( +
+
+

+ {/* pm2's own word, not an interpretation of it. `errored` and `stopped` mean different + things to whoever has to fix it. */} + + {label} + + {server.status ?? 'not started'} + {online && server.uptimeMs !== null ? ` · up ${uptime(server.uptimeMs)}` : ''} + {server.restarts ? ` · ${server.restarts} restarts` : ''} + +

+

+ {server.process} + {online && server.url ? ` · ${server.url}` : ''} +

+
+ + onChange(event.target.value.replace(/[^0-9]/g, ''))} + placeholder="3010" + className="w-24 shrink-0 font-mono text-xs" + inputMode="numeric" + autoComplete="off" + /> + +
+ + +
+
+ ); + }; + + return ( +
+ + + Saved + + ) : undefined + } + > + {!servers ? null : ( + <> + {servers.map((server) => + server.name === 'development' ? row(server, dev, setDev) : row(server, prod, setProd), + )} + + {error &&

{error}

} + + + {dirty && ( +
+ +
+ )} + + )} +
+
+ ); +}; diff --git a/web/sections.ts b/web/sections.ts index 5a1291e..323bc5f 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog, FolderOpen } from 'lucide-react'; +import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog, FolderOpen, Server } from 'lucide-react'; /** * The sections of the fofos admin, in nav order. @@ -29,6 +29,7 @@ import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail export type SectionKey = | 'administration' + | 'servers' | 'media' | 'messages' | 'site' @@ -177,6 +178,27 @@ export const SECTIONS: Section[] = [ // Pinned beside Administration: both are about this machine rather than about the site's pages. pinned: true, }, + { + /* + The two processes that serve the site, under pm2. + + Beside Administration and above it, because it is the same KIND of thing — this machine rather + than the site's content — but it is the one somebody opens most: whether the site is up is a + question with a different urgency from where its files are. + + `requires: 'root'`, not a connection. It needs the checkout to run, and nothing from fofos itself: + a site that is down is exactly when its API cannot be reached, which is precisely when this has to + keep working. + */ + key: 'servers', + label: 'Servers', + icon: Server, + hint: 'Development and production, and whether they are up', + path: null, + built: true, + requires: 'root', + pinned: true, + }, { /* Last, and `path: null` — it reads nothing from fofos. Every other section is a window onto the