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. */} + ); };