import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react'; import { useClient } from 'hooks/useClient'; 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 { BackupsRow } from './BackupsRow'; import { useFlash } from './useFlash'; /** * Where the fofos site's own data lives — two databases, named here. * * ── ONE button ── * * It shipped with two — Save, then Create — on the reasoning that naming and creating are different * decisions. They are not, from the outside: you type two names because you want two databases. Worse, * Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not appear at * all, and the hint explaining why was behind the same condition. Two filled fields, one Save button, * and no way to tell what came next. * * So one button that records the names and creates whatever is not there, labelled with whichever of * those it is actually about to do. Recording the name you just used to make a database is not a side * effect worth a second click. * * ── It reports what is THERE, against what is TYPED ── * * The tick beside each field asks `pg_database` whether a database of that name exists, and it asks * about the text in the box rather than the text that was last saved. That is the question somebody * typing a name actually has, and answering it about a different name would be worse than silence. */ /** Short, because it sits in a corner of a row: `29 Aug, 13:08`. */ const shortWhen = (iso: string) => new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); type Named = { name: string | null; exists: boolean | null; tables: number | null; rows: number | null; lastWrite: string | null; }; type Status = { production: Named; development: Named; available: string[] }; export const DatabaseBlock = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [status, setStatus] = useState(null); const [production, setProduction] = useState(''); const [development, setDevelopment] = useState(''); /* 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(); const load = useCallback(async () => { try { const current = await clientRef.current.get('/fofos-admin/_config/databases'); setStatus(current); // Only seed the fields from the server when they are untouched, so a reload landing mid-typing // cannot replace what somebody is in the middle of writing. setProduction((typed) => typed || (current.production.name ?? '')); setDevelopment((typed) => typed || (current.development.name ?? '')); } catch { setStatus(null); } }, []); useEffect(() => { void load(); }, [load]); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); /* Finished, and therefore worth folding away — the same treatment as Root location, for the same reason: everything here is in order most of the time, and a block that is three paragraphs of "fine" pushes whatever is not fine off the screen. Seeded from `/summary` so the first paint is already right, then kept live by what this block and `BackupsRow` actually know. See RootLocationBlock for why both sources are needed. */ const [summaryReady, setSummaryReady] = useState(null); const [backupsReady, setBackupsReady] = useState(false); const [collapsed, setCollapsed] = useState(null); useEffect(() => { void (async () => { try { const summary = await clientRef.current.get<{ databasesReady: boolean }>('/fofos-admin/_config/summary'); setSummaryReady(summary.databasesReady); } catch { setSummaryReady(false); } })(); }, []); /** * Push the site's schema into one of the two. * * Runs the WEBSITE's own `db:push` — the schema and the drizzle version belong to that repository, * and a plugin that reimplemented either would be a second definition of the site's database going * stale on its own schedule. */ 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 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(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('apply'); setError(null); try { const result = await clientRef.current.post<{ created: string[]; status: Status }>( '/fofos-admin/_config/databases/create', { production, development }, ); setStatus(result.status); flash(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(null); } }; const field = (label: string, hint: string, value: string, onChange: (next: string) => void) => { // Against the typed text and the real list from `pg_database`. Nothing to say about an empty box. const typed = value.trim(); const exists = typed ? (status?.available.includes(typed) ?? false) : null; const Icon = exists ? CircleCheck : CircleDashed; return (
{hint}
onChange(event.target.value)} placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'} className="w-full font-mono text-xs" autoComplete="off" spellCheck={false} /> {exists === null ? null : ( <> {exists ? 'exists' : 'not yet'} )}
); }; const dirty = production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? ''); // Typed, non-empty, and not on the instance. What the one button is about to make. const missing = [production.trim(), development.trim()].filter( (name) => name && !(status?.available.includes(name) ?? false), ); const label = missing.length ? `Create ${missing.length === 1 ? missing[0] : 'databases'}` : 'Save names'; /* Settled: both named, both there. The fields have nothing left to do — a text box invites an edit nobody is here to make, and the two things somebody DOES want at this point are per-database actions rather than a form. */ const settled = Boolean( status?.production.exists && status?.development.exists && !dirty && production.trim() && development.trim(), ); const row = (label: string, target: 'production' | 'development', state: Named) => { return (

{label}

{state.name}

{/* Tables say the schema is there; rows say the site has content. Two databases with identical structure look the same until you count what is in them. */} {state.tables === null ? ( '' ) : state.tables === 0 ? ( 'empty' ) : ( <> {state.tables} tables · {state.rows ?? 0} rows {state.lastWrite ? ( last write {shortWhen(state.lastWrite)} ) : null} )}
{/* Spins only when THIS row is the one running, or when both are. */}
); }; /* The summary answers on arrival; this block and BackupsRow answer from then on. Taking the summary while nothing local has resolved, and the live value once it has, is what makes the first paint correct without freezing the block on a snapshot. */ const live = Boolean( status?.production.exists && status?.development.exists && (status.production.tables ?? 0) > 0 && (status.development.tables ?? 0) > 0 && backupsReady, ); const settledNow = status ? live : summaryReady === true; useEffect(() => { setCollapsed((chosen) => { // Leave `null` alone while unsettled — spending the sentinel before anything has resolved is what // stopped Root location from ever folding itself away. See the note there. if (!settledNow) return chosen === null ? null : false; return chosen === null ? true : chosen; }); }, [settledNow]); return ( Saved ) : settledNow ? ( ) : undefined } > {!status ? null : settled ? ( <> {row('Production', 'production', status.production)} {row('Development', 'development', status.development)} {error &&

{error}

} {/* Below the two databases, because it is about both of them rather than either. */} {/* The common case is both. Doing them one at a time is still there for when it is not. */}
) : ( <> {field('Production', 'what the live site reads', production, setProduction)} {field('Development', 'what you work against', development, setDevelopment)} {error &&

{error}

}
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
)}
); };