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'; import { ProxiesBlock } from './ProxiesBlock'; import { StaticBlock } from './StaticBlock'; /** * How the site is served: a development process, and a production build. * * ── They are not two of the same thing, and this stopped pretending ── * * Development is `bun --hot` under pm2 — a process with a port, an uptime and a restart count. * Production is a directory of files behind nginx, which the site's own build script says outright: it * has no port and nothing to restart. It is built or stale, served or not served. * * So production gets Build and a container. Showing it as a process was showing something that does not * exist, and its Start button could only ever have failed. * * A block inside Administration rather than a section of its own: it is the same KIND of thing as the * root location and the databases — this machine, not the site's content — and a nav row for one card * was a whole screen to reach three lines. * * ── 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; reachable: boolean | 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 ServersBlock = () => { 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), // Still sent because the API validates that the two differ. Nothing listens on it. production: Number(prod || 3010), }), ); 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); } }; // Only development has a port now. Production is a container on a docker network, publishing none. const dirty = dev !== String(servers?.find((s) => s.name === 'development')?.port ?? ''); const row = (server: Server, value: string, onChange: (next: string) => void) => { // pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a // startup crash and serves nothing. The dot follows what ANSWERS; the words report both. const online = server.status === 'online'; const serving = server.reachable === true; 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'} {/* Said out loud, because it is the case that misleads: the process is up and the site is not there. */} {online && !serving ? ' · not answering' : ''} {online && server.uptimeMs !== null ? ` · up ${uptime(server.uptimeMs)}` : ''} {server.restarts ? ` · ${server.restarts} restarts` : ''}

{server.process} {serving && 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" /> {/* Fixed widths, because "Start" and "Restart" are different lengths and a row that sizes to its own label leaves the two rows' inputs and buttons on different vertical lines. */}
); }; return ( <> Saved ) : undefined } > {!servers ? null : ( <> {servers.filter((server) => server.name === 'development').map((server) => row(server, dev, setDev))} {/* Production, which is a build rather than a process — see StaticBlock. */} {error &&

{error}

} {dirty && (
)} )}
{/* Directly under the servers, and given their ports rather than fetching them again: a domain is only meaningful as the front door to one of these two, and the block above already knows which port each is on. */} {servers && ({ name: s.name, port: s.port }))} />} ); };