Files
fofos-admin/web/ServersPanel.tsx
T
pastilhasandClaude Opus 5 ca43f7ef86 the site gets what it needs to boot, and the status stops lying
Three things, and the second is why the panel was confidently wrong.

The site refuses to start without FOFOS_API_TOKEN, ADMIN_SESSION_SECRET,
PUBLIC_URL, API_URL and FOFOS_MEDIA_DIR, and the checkout has no .env at all.
Every one of those is either already known here — the port, the media directory
— or a random secret nobody should be asked to invent. So they are generated
once, kept encrypted under this plugin's own purpose key, and supplied. Once,
not per start: regenerating would invalidate every session and token anybody had
noted down.

pm2 saying `online` is NOT the site being up. `bun --hot` survives a crash at
startup — the process stays alive, pm2 reports online, and nothing is listening.
That is precisely what development did: reported up, served nothing, and this
panel repeated it. The status now also asks whether the port answers, any
response counting, and says "not answering" out loud when the two disagree.

And the buttons are a fixed width, because Start and Restart are different
lengths and rows that size to their own label leave the inputs on different
vertical lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:50:16 +01:00

233 lines
8.6 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';
/**
* 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;
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 ServersPanel = () => {
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),
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) => {
// 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 (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
<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.map((server) =>
server.name === 'development' ? row(server, dev, setDev) : row(server, prod, setProd),
)}
{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 || !prod}>
{busy === 'ports' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save ports
</Button>
</div>
)}
</>
)}
</Block>
</div>
);
};