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>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user