The site's own build script says it outright: "The public site is not served by Bun in production — it is a directory of files behind nginx. Output: dist/." So production has no port, nothing to restart, and nothing that can crash at three in the morning. It is built or stale, and served or not served. Showing it as a pm2 process was showing something that does not exist, and its Start button could only ever have failed. It now has Build, Serve and Stop, and the two states are reported separately — a build can succeed while nothing serves it, and a container can serve a directory built weeks ago. They fail independently and are fixed differently. The container exists because nginx could not see the files. NPM mounts only its own /data and /etc/letsencrypt, and there is no other nginx on the box. The alternatives were putting site files inside NPM's own volume, or editing NPM's compose and restarting it — which takes every site on this machine down for a moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with NO published port. NPM reaches it by name, and nothing about the site is exposed except through the proxy. Recreated rather than restarted, for the same reason pm2 is: a container keeps the mounts it was created with, so a root location changed here would be saved, displayed, and still serving the old directory. Domains follows. Production's forward target is a container NAME, not the docker gateway that reaches a host process — and matching what already points at it uses the full target rather than the port, since every other site behind this NPM on port 80 would otherwise be listed as fofos's. The compiler found the dead branches this left in startServer once production could no longer reach it. Removed rather than left to read as though the case were still possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { readableError } from './errors';
|
|
import { Console } from './Console';
|
|
|
|
/**
|
|
* Production: the built site, and the nginx serving it.
|
|
*
|
|
* ── Two states, not one ──
|
|
*
|
|
* A build can succeed while nothing serves it, and a container can serve a directory built weeks ago.
|
|
* They fail independently and they are fixed differently, so they are reported separately rather than
|
|
* collapsed into "production is up" — which would be true in one of those cases and misleading in the
|
|
* other.
|
|
*
|
|
* ── Build takes minutes ──
|
|
*
|
|
* It renders every route through the real app and copies every referenced file. Said in the button,
|
|
* because a spinner sitting for three minutes with no explanation is indistinguishable from a hang.
|
|
*/
|
|
|
|
type Static = {
|
|
path: string;
|
|
built: boolean;
|
|
entries: number | null;
|
|
builtAt: string | null;
|
|
container: { name: string; state: string | null; forward: string };
|
|
};
|
|
|
|
const when = (iso: string) =>
|
|
new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
|
|
export const StaticBlock = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [status, setStatus] = useState<Static | null>(null);
|
|
const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null);
|
|
const [output, setOutput] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setStatus(await clientRef.current.get<Static>('/fofos-admin/_config/static'));
|
|
} catch {
|
|
setStatus(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const act = async (what: 'build' | 'start' | 'stop') => {
|
|
setBusy(what);
|
|
setFailed(false);
|
|
setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null);
|
|
try {
|
|
const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`;
|
|
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {});
|
|
setStatus(result.status);
|
|
setOutput(result.output);
|
|
setFailed(!result.ok);
|
|
} catch (failure) {
|
|
setOutput(readableError(failure));
|
|
setFailed(true);
|
|
void load();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
if (!status) return null;
|
|
|
|
const serving = status.container.state === 'running';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-1.5 text-xs">
|
|
<CircleDot
|
|
className={`size-3 ${serving ? 'text-success' : status.container.state ? 'text-destructive' : 'text-muted-foreground/40'}`}
|
|
/>
|
|
Production
|
|
<span className="text-muted-foreground/70">
|
|
{status.built ? `${status.entries} files` : 'not built'}
|
|
{status.builtAt ? ` · built ${when(status.builtAt)}` : ''}
|
|
{' · '}
|
|
{status.container.state ?? 'no server'}
|
|
</span>
|
|
</p>
|
|
{/* The forward target, because it is what a domain has to point at and it is not a port. */}
|
|
<p className="font-mono text-xs text-muted-foreground/60">
|
|
{status.container.name} · {serving ? status.container.forward : 'static files, no port'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act('build')}
|
|
disabled={busy !== null}
|
|
>
|
|
{busy === 'build' ? <Loader2 className="size-3.5 animate-spin" /> : <Hammer className="size-3.5" />}
|
|
{busy === 'build' ? 'Building' : 'Build'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act('start')}
|
|
// Nothing to serve until something is built, and starting on an empty directory produces a
|
|
// container that returns 404 for everything — which reads as a broken site, not a missing build.
|
|
disabled={busy !== null || !status.built}
|
|
title={status.built ? undefined : 'Build it first'}
|
|
>
|
|
{busy === 'start' ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
|
{serving ? 'Restart' : 'Serve'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act('stop')}
|
|
disabled={busy !== null || !status.container.state}
|
|
>
|
|
<Square className="size-3.5" />
|
|
Stop
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Console output={output} failed={failed} />
|
|
</div>
|
|
);
|
|
};
|