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:
+74
-23
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user