The site's own build script says it outright: "The public site is not served by Bun in production — it is a directory of files behind nginx. Output: dist/." So production has no port, nothing to restart, and nothing that can crash at three in the morning. It is built or stale, and served or not served. Showing it as a pm2 process was showing something that does not exist, and its Start button could only ever have failed. It now has Build, Serve and Stop, and the two states are reported separately — a build can succeed while nothing serves it, and a container can serve a directory built weeks ago. They fail independently and are fixed differently. The container exists because nginx could not see the files. NPM mounts only its own /data and /etc/letsencrypt, and there is no other nginx on the box. The alternatives were putting site files inside NPM's own volume, or editing NPM's compose and restarting it — which takes every site on this machine down for a moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with NO published port. NPM reaches it by name, and nothing about the site is exposed except through the proxy. Recreated rather than restarted, for the same reason pm2 is: a container keeps the mounts it was created with, so a root location changed here would be saved, displayed, and still serving the old directory. Domains follows. Production's forward target is a container NAME, not the docker gateway that reaches a host process — and matching what already points at it uses the full target rather than the port, since every other site behind this NPM on port 80 would otherwise be listed as fofos's. The compiler found the dead branches this left in startServer once production could no longer reach it. Removed rather than left to read as though the case were still possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
9.9 KiB
TypeScript
254 lines
9.9 KiB
TypeScript
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<Server[] | null>(null);
|
|
const [dev, setDev] = useState('');
|
|
const [prod, setProd] = useState('');
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [output, setOutput] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
const [flashing, flash] = useFlash();
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const current = await clientRef.current.get<Server[]>('/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<Server[]>('/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 (
|
|
<div key={server.name} className="flex flex-wrap items-center gap-3 border-t border-border pt-3 first:border-0 first:pt-0">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-1.5 text-xs">
|
|
{/* pm2's own word, not an interpretation of it. `errored` and `stopped` mean different
|
|
things to whoever has to fix it. */}
|
|
<CircleDot
|
|
className={`size-3 ${serving ? 'text-success' : server.status ? 'text-destructive' : 'text-muted-foreground/40'}`}
|
|
/>
|
|
{label}
|
|
<span className="text-muted-foreground/70">
|
|
{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` : ''}
|
|
</span>
|
|
</p>
|
|
<p className="font-mono text-xs text-muted-foreground/60">
|
|
{server.process}
|
|
{serving && server.url ? ` · ${server.url}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<Input
|
|
value={value}
|
|
onChange={(event) => 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. */}
|
|
<div className="flex shrink-0 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act(server.name, 'start')}
|
|
// Dirty ports are not in effect until saved, and starting with the old one silently would
|
|
// be the confusing outcome.
|
|
disabled={busy !== null || dirty || !value}
|
|
title={dirty ? 'Save the ports first' : online ? 'Restart with the current settings' : undefined}
|
|
>
|
|
{busy === `${server.name}:start` ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
|
{online ? 'Restart' : 'Start'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act(server.name, 'stop')}
|
|
disabled={busy !== null || !server.status}
|
|
>
|
|
{busy === `${server.name}:stop` ? <Loader2 className="size-3.5 animate-spin" /> : <Square className="size-3.5" />}
|
|
Stop
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Block
|
|
icon={Server}
|
|
title="Servers"
|
|
hint="The site's development and production processes, run under pm2."
|
|
status={
|
|
flashing ? (
|
|
<span className="flex items-center gap-1 text-xs text-success">
|
|
<Check className="size-3.5" />
|
|
Saved
|
|
</span>
|
|
) : 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. */}
|
|
<StaticBlock />
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
<Console output={output} failed={failed} />
|
|
|
|
{dirty && (
|
|
<div className="flex justify-end border-t border-border pt-3">
|
|
<Button size="sm" onClick={() => void savePorts()} disabled={busy !== null || !dev}>
|
|
{busy === 'ports' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
Save ports
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Block>
|
|
|
|
{/* 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 && <ProxiesBlock servers={servers.map((s) => ({ name: s.name, port: s.port }))} />}
|
|
</>
|
|
);
|
|
};
|