It talks to the proxy-manager PLUGIN, from the browser, with the user's own
session. Not to NPM, and not from this plugin's server: NPM's credential belongs
to proxy-manager and its skill is explicit that the pass-through route is the
sanctioned path. Calling it this way means this plugin never holds that
credential, and two plugins do not acquire a server-to-server dependency that
each would then have to know the installed state of.
forward_host is copied from what already works here. NPM runs in a container, so
127.0.0.1 there is NPM itself rather than this machine — a host pointed at it
resolves, serves nothing, and looks exactly like the site being down. The
correct value is the Docker gateway, which is a fact about this machine's
networking that neither this plugin nor a person should have to know. So the
default is taken by majority from the hosts NPM already serves, since those are
by construction addresses that work. It is shown in an editable field, because a
guess made from other people's configuration should be visible before it is used.
Creating is offered and deleting is not. A new host is additive and reversible;
removing or repointing one is a live domain changing where it goes, and this
panel would be offering that with a fraction of the context the Proxy Manager
screen has.
Certificates are two calls, because NPM issues one as its own object and a host
merely references it. The body is exactly {provider, domain_names} —
letsencrypt_email and letsencrypt_agree are what every tutorial shows and NPM
2.15 rejects the request for including them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
9.1 KiB
TypeScript
243 lines
9.1 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';
|
|
|
|
/**
|
|
* 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>
|
|
|
|
{/* 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 }))} />}
|
|
</>
|
|
);
|
|
};
|