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 = () => {
-
- {/* 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. */}
- {
- 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 ? : }
-
-
- )}
+ {/* 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. */}
+
);
};