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
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { useFlash } from './useFlash';
/**
* The output of something that ran, in the shape of the thing that ran it.
*
* A console, so a transcript from git or drizzle looks like a transcript rather than like a paragraph
* of the form around it. Scrolled, not truncated: the useful line of a failure is usually the last one.
*
* ── A success goes away; a failure never does ──
*
* Nobody reads a successful transcript twice, and one that stays turns every panel into a log. A
* FAILURE has no timer at all rather than a long one — nothing should be able to take it off the screen
* except the next attempt, because it is the only thing here somebody has to act on.
*
* ── The copy button is pinned to the box, not to the text ──
*
* A button that scrolls away with a long transcript is missing at exactly the moment the transcript is
* worth copying. `pr-10` on the `pre` keeps the text out from under it.
*
* ── Re-running with identical output ──
*
* The effect keys on `output`, so pressing a button twice and getting the same text back would not
* re-show a console that had timed out. Callers clear it to `null` before starting, which they do
* anyway to avoid showing the previous run's words next to a spinner.
*/
export const Console = ({ output, failed, ms = 6000 }: { output: string | null; failed: boolean; ms?: number }) => {
const [visible, setVisible] = useState(false);
const [copied, flashCopied] = useFlash(1500);
useEffect(() => {
if (!output) {
setVisible(false);
return;
}
setVisible(true);
if (failed) return;
const timer = setTimeout(() => setVisible(false), ms);
return () => clearTimeout(timer);
}, [output, failed, ms]);
if (!output || !visible) return null;
return (
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
);
};
+71 -20
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { readableError } from './errors'; import { readableError } from './errors';
import { Block } from './Block'; import { Block } from './Block';
import { Console } from './Console';
import { useFlash } from './useFlash'; import { useFlash } from './useFlash';
/** /**
@@ -40,7 +41,10 @@ export const DatabaseBlock = () => {
const [status, setStatus] = useState<Status | null>(null); const [status, setStatus] = useState<Status | null>(null);
const [production, setProduction] = useState(''); const [production, setProduction] = useState('');
const [development, setDevelopment] = 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 [error, setError] = useState<string | null>(null);
const [flashing, flash] = useFlash(); const [flashing, flash] = useFlash();
@@ -62,6 +66,7 @@ export const DatabaseBlock = () => {
}, [load]); }, [load]);
const [output, setOutput] = useState<string | null>(null); const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
/** /**
* Push the site's schema into one of the two. * 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 * and a plugin that reimplemented either would be a second definition of the site's database going
* stale on its own schedule. * stale on its own schedule.
*/ */
const push = async (target: 'production' | 'development') => { const pushOne = async (target: 'production' | 'development'): Promise<string> => {
setBusy(true);
setError(null);
setOutput(null);
try {
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>( const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>(
`/fofos-admin/_config/databases/${target}/push-schema`, `/fofos-admin/_config/databases/${target}/push-schema`,
{}, {},
); );
setStatus(result.status); setStatus(result.status);
setOutput(result.output); return result.output;
};
const push = async (target: 'production' | 'development' | 'both') => {
setBusy(target);
setError(null);
setFailed(false);
setOutput(null);
try {
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) { } catch (failure) {
setError(readableError(failure)); setError(readableError(failure));
setFailed(true);
} finally { } 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. */ /** Records the names and creates whichever is missing. The server does both, in that order. */
const apply = async () => { const apply = async () => {
setBusy(true); setBusy('apply');
setError(null); setError(null);
try { try {
const result = await clientRef.current.post<{ created: string[]; status: Status }>( const result = await clientRef.current.post<{ created: string[]; status: Status }>(
@@ -102,7 +127,7 @@ export const DatabaseBlock = () => {
} catch (failure) { } catch (failure) {
setError(readableError(failure)); setError(readableError(failure));
} finally { } finally {
setBusy(false); setBusy(null);
} }
}; };
@@ -175,14 +200,31 @@ export const DatabaseBlock = () => {
</span> </span>
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => void push(target)} disabled={busy}> {/* Spins only when THIS row is the one running, or when both are. */}
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowUpFromLine className="size-3.5" />} <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 Push schema
</Button> </Button>
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against {/* 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. */} an empty database fails on every insert, and that failure would read as a bad backup.
<Button variant="outline" size="sm" disabled title={pushed ? undefined : 'Push the schema first'}> 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" /> <DatabaseBackup className="size-3.5" />
Restore from backup Restore from backup
</Button> </Button>
@@ -209,12 +251,21 @@ export const DatabaseBlock = () => {
<> <>
{row('Production', 'production', status.production)} {row('Production', 'production', status.production)}
{row('Development', 'development', status.development)} {row('Development', 'development', status.development)}
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
{output && ( <Console output={output} failed={failed} />
<pre className="max-h-48 overflow-auto rounded bg-black p-3 font-mono text-[11px] whitespace-pre-wrap text-white/90">
{output} {/* The common case is both. Doing them one at a time is still there for when it is not. */}
</pre> <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 <Button
size="sm" size="sm"
onClick={() => void apply()} 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" /> <Loader2 className="size-3.5 animate-spin" />
) : missing.length ? ( ) : missing.length ? (
<Plus className="size-3.5" /> <Plus className="size-3.5" />
+5 -34
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleAlert, CircleCheck, CircleDashed, Copy, DownloadCloud, Loader2, RefreshCw } from 'lucide-react'; import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { readableError } from './errors'; import { readableError } from './errors';
import { useFlash } from './useFlash'; import { Console } from './Console';
/** /**
* The website checkout under the root location: is it there, clone it, update it. * The website checkout under the root location: is it there, clone it, update it.
@@ -67,8 +66,6 @@ export const WebsiteCheckout = ({
const [busy, setBusy] = useState<'clone' | 'update' | null>(null); const [busy, setBusy] = useState<'clone' | 'update' | null>(null);
const [output, setOutput] = useState<string | null>(null); const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [showing, show] = useFlash();
const [copied, flashCopied] = useFlash(1500);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -101,9 +98,6 @@ export const WebsiteCheckout = ({
setStatus(result.status); setStatus(result.status);
setOutput(result.output || null); setOutput(result.output || null);
setFailed(!result.ok); setFailed(!result.ok);
// Only on the way out. A failure has no timer at all rather than a long one — nothing should be
// able to take it off the screen except the next attempt.
if (result.ok) show();
} catch (failure) { } catch (failure) {
// A 400 carries git's own words in the body; `readableError` surfaces them. // A 400 carries git's own words in the body; `readableError` surfaces them.
setOutput(readableError(failure)); setOutput(readableError(failure));
@@ -165,32 +159,9 @@ export const WebsiteCheckout = ({
</div> </div>
</div> </div>
{/* git's own words, in git's own shape — a console, so it looks like the thing it is quoting {/* git's own words, in git's own shape — see `Console`, which owns the timing and the copy
rather than like a paragraph of the form. Scrolled, not truncated: the useful line of a button. Shared with the schema pushes in Database, which need exactly the same thing. */}
failure is usually the last one. */} <Console output={output} failed={failed} />
{output && (failed || showing) && (
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
{/* Pinned to the corner of the BOX, not scrolled with the text — a copy button that leaves
the viewport when the transcript is long is missing exactly when the transcript is worth
copying. `pr-10` above keeps the text out from under it. Its own dark background because
it sits over the output rather than beside it. */}
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
)}
</div> </div>
); );
}; };