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>
235 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
};
|