The admin edits development and the build renders production, so nothing written in the panel could reach the live site. Build was only half of publishing, and the missing half was invisible: it succeeded, against the content somebody last promoted, and a successful build of yesterday's site reads as a broken renderer. Publish copies the site's content tables across and then builds. Two things it will not copy: `users`, because production's logins are not development's and nobody should be locked out of the live site to publish a photograph; and `contact_submissions`, the legacy inbox, because development's is empty and production's is not — copying it would destroy real messages in order to publish nothing. The table list is written out rather than derived. A table added later should be considered rather than swept along, and an empty development table quietly overwriting production-only data is discovered far too late to fix. Order is load-bearing in two places. Production is dumped before anything is destroyed, and the publish refuses to start if that dump fails. Development is read BEFORE production is cleared, so a failure to read leaves production untouched rather than empty for a reason that had nothing to do with it. `TRUNCATE ... RESTART IDENTITY`, so the sequences the copy sets are not merely ahead of the rows it inserts — otherwise the collision arrives on the client's next save, hours later, somewhere else. `backupAfterPublish` has been sitting in backups.ts without a caller since the day "On publish" became a backup schedule. This is the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { CircleDot, Hammer, Loader2, Play, Square, Upload } 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.
|
|
*
|
|
* ── Publish and Build are different things, and Publish is the one to press ──
|
|
*
|
|
* The admin edits DEVELOPMENT. The build renders PRODUCTION. So Build alone renders whatever was last
|
|
* promoted, which after a morning of edits is yesterday's site — a successful build of the wrong
|
|
* content, which reads as a broken renderer and is the reason this needed a second button rather than
|
|
* a note somewhere.
|
|
*
|
|
* Publish copies the content across and then builds. Build stays because rebuilding production without
|
|
* promoting anything is a real thing to want — a template change, a failed build to retry — but it is
|
|
* the secondary action here and looks like one.
|
|
*/
|
|
|
|
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<'publish' | '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: 'publish' | 'build' | 'start' | 'stop') => {
|
|
setBusy(what);
|
|
setFailed(false);
|
|
setOutput(
|
|
what === 'publish'
|
|
? 'Copying content to production, then rendering every page. This takes a few minutes.'
|
|
: what === 'build'
|
|
? 'Rendering every page. This takes a few minutes.'
|
|
: null,
|
|
);
|
|
try {
|
|
const path = `/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
|
|
size="sm"
|
|
className="w-24 justify-center"
|
|
onClick={() => void act('publish')}
|
|
disabled={busy !== null}
|
|
title="Copy the content you have been editing into production, then build"
|
|
>
|
|
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
|
{busy === 'publish' ? 'Publishing' : 'Publish'}
|
|
</Button>
|
|
<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>
|
|
);
|
|
};
|