From bda59bdf035247821f47394724f323a1ca7a29e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 12:47:58 +0100 Subject: [PATCH] one spinner per action, one button for both, and the shared console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/Console.tsx | 65 +++++++++++++++++++++++++++ web/DatabaseBlock.tsx | 97 +++++++++++++++++++++++++++++++---------- web/WebsiteCheckout.tsx | 39 +++-------------- 3 files changed, 144 insertions(+), 57 deletions(-) create mode 100644 web/Console.tsx diff --git a/web/Console.tsx b/web/Console.tsx new file mode 100644 index 0000000..801d81b --- /dev/null +++ b/web/Console.tsx @@ -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 ( +
+
+        {output}
+      
+ + +
+ ); +}; diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx index 8f2ee62..280627e 100644 --- a/web/DatabaseBlock.tsx +++ b/web/DatabaseBlock.tsx @@ -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(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(null); const [flashing, flash] = useFlash(); @@ -62,6 +66,7 @@ export const DatabaseBlock = () => { }, [load]); const [output, setOutput] = useState(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 => { + 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 = () => {
- {/* 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. */} - @@ -209,12 +251,21 @@ export const DatabaseBlock = () => { <> {row('Production', 'production', status.production)} {row('Development', 'development', status.development)} + {error &&

{error}

} - {output && ( -
-              {output}
-            
- )} + + + {/* The common case is both. Doing them one at a time is still there for when it is not. */} +
+ +
) : ( <> @@ -228,9 +279,9 @@ export const DatabaseBlock = () => {
- {/* git's own words, in git's own shape — a console, so it looks like the thing it is quoting - rather than like a paragraph of the form. Scrolled, not truncated: the useful line of a - failure is usually the last one. */} - {output && (failed || showing) && ( -
-
-            {output}
-          
- - {/* 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. */} - -
- )} + {/* git's own words, in git's own shape — see `Console`, which owns the timing and the copy + button. Shared with the schema pushes in Database, which need exactly the same thing. */} + ); };