one spinner per action, one button for both, and the shared console

Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:47:58 +01:00
co-authored by Claude Opus 5
parent f7df5f8b10
commit bda59bdf03
3 changed files with 144 additions and 57 deletions
+74 -23
View File
@@ -5,6 +5,7 @@ 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';
/**
@@ -40,7 +41,10 @@ export const DatabaseBlock = () => {
const [status, setStatus] = useState<Status | null>(null);
const [production, setProduction] = useState('');
const [development, setDevelopment] = useState('');
const [busy, setBusy] = useState(false);
/* WHICH action is running, not merely that one is. A single boolean put both rows into a
spinner while only the pressed one was doing anything — the other looked like it had been
started too, and then finished without having done a thing. */
const [busy, setBusy] = useState<'production' | 'development' | 'both' | 'apply' | null>(null);
const [error, setError] = useState<string | null>(null);
const [flashing, flash] = useFlash();
@@ -62,6 +66,7 @@ export const DatabaseBlock = () => {
}, [load]);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
/**
* Push the site's schema into one of the two.
@@ -70,27 +75,47 @@ export const DatabaseBlock = () => {
* and a plugin that reimplemented either would be a second definition of the site's database going
* stale on its own schedule.
*/
const push = async (target: 'production' | 'development') => {
setBusy(true);
const pushOne = async (target: 'production' | 'development'): Promise<string> => {
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>(
`/fofos-admin/_config/databases/${target}/push-schema`,
{},
);
setStatus(result.status);
return result.output;
};
const push = async (target: 'production' | 'development' | 'both') => {
setBusy(target);
setError(null);
setFailed(false);
setOutput(null);
try {
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>(
`/fofos-admin/_config/databases/${target}/push-schema`,
{},
);
setStatus(result.status);
setOutput(result.output);
const transcript =
target === 'both'
? // Production first, then development. Sequential rather than parallel: they run the same
// drizzle against one Postgres, and two at once buys nothing but an interleaved transcript.
[await pushOne('production'), await pushOne('development')].join('\n\n')
: await pushOne(target);
setOutput(transcript);
} catch (failure) {
setError(readableError(failure));
setFailed(true);
} finally {
setBusy(false);
setBusy(null);
/*
Re-read regardless.
The push route returns the new status, but only on the path where it succeeded — a failure
answers 400 and throws, and a push that failed halfway can still have changed the count. This is
the difference between the panel being right and the panel being right when nothing went wrong.
*/
void load();
}
};
/** Records the names and creates whichever is missing. The server does both, in that order. */
const apply = async () => {
setBusy(true);
setBusy('apply');
setError(null);
try {
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
@@ -102,7 +127,7 @@ export const DatabaseBlock = () => {
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
setBusy(null);
}
};
@@ -175,14 +200,31 @@ export const DatabaseBlock = () => {
</span>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => void push(target)} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowUpFromLine className="size-3.5" />}
{/* Spins only when THIS row is the one running, or when both are. */}
<Button
variant="outline"
size="sm"
onClick={() => void push(target)}
disabled={busy !== null}
>
{busy === target || busy === 'both' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ArrowUpFromLine className="size-3.5" />
)}
Push schema
</Button>
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against
an empty database fails on every insert, and the failure would read as a bad backup. */}
<Button variant="outline" size="sm" disabled title={pushed ? undefined : 'Push the schema first'}>
an empty database fails on every insert, and that failure would read as a bad backup.
Still disabled beyond that: what a backup IS here has not been decided, and a button that
guesses at the source of a restore is not a thing to put near a client's data. */}
<Button
variant="outline"
size="sm"
disabled
title={pushed ? 'Not built yet — no backup source chosen' : 'Push the schema first'}
>
<DatabaseBackup className="size-3.5" />
Restore from backup
</Button>
@@ -209,12 +251,21 @@ export const DatabaseBlock = () => {
<>
{row('Production', 'production', status.production)}
{row('Development', 'development', status.development)}
{error && <p className="text-xs text-destructive">{error}</p>}
{output && (
<pre className="max-h-48 overflow-auto rounded bg-black p-3 font-mono text-[11px] whitespace-pre-wrap text-white/90">
{output}
</pre>
)}
<Console output={output} failed={failed} />
{/* The common case is both. Doing them one at a time is still there for when it is not. */}
<div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void push('both')} disabled={busy !== null}>
{busy === 'both' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ArrowUpFromLine className="size-3.5" />
)}
Push both schemas
</Button>
</div>
</>
) : (
<>
@@ -228,9 +279,9 @@ export const DatabaseBlock = () => {
<Button
size="sm"
onClick={() => void apply()}
disabled={busy || (!missing.length && !dirty) || !production.trim() || !development.trim()}
disabled={busy !== null || (!missing.length && !dirty) || !production.trim() || !development.trim()}
>
{busy ? (
{busy === 'apply' ? (
<Loader2 className="size-3.5 animate-spin" />
) : missing.length ? (
<Plus className="size-3.5" />