Files
fofos-admin/web/ServersBlock.tsx
T
pastilhasandClaude Opus 5 b27969ba57 Servers is a block in Administration, not 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 leading to one card was a whole
screen to reach three lines.

Last of the three, because it needs both of the others: a server has nowhere to
run without the checkout and nothing to read without a database.

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

235 lines
8.7 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.
*
* 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),
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 (
<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>
);
};