Files
fofos-admin/web/StaticBlock.tsx
pastilhasandClaude Opus 5 472fd9cb88 publish promotes development's content, then builds
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>
2026-08-29 21:52:08 +01:00

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>
);
};