Files
fofos-admin/web/DatabaseBlock.tsx
T
pastilhasandClaude Opus 5 af31b57cdf the databases say what is in them, and the block folds away when it is done
Two databases with identical structure look identical until you count what is in
them — one is the live site and one is a copy. So a row now reads "18 tables ·
155 rows" with the time anything was last written, rather than only reporting
that a schema exists.

Counted with query_to_xml over information_schema, which is exact. reltuples
from the planner would be free and is an ESTIMATE: it reads as zero on a table
nobody has analysed, which is precisely the freshly restored database this is
meant to reassure somebody about. That is not a hypothetical — it is why
pg_stat_user_tables reported an empty database earlier today that had 155 rows
in it.

And the block collapses with a check once everything is in order, on the same
terms as Root location: both databases existing with a schema, and backups
configured. Not backups having RUN — a site set up an hour ago has taken none,
and calling that incomplete is telling somebody to fix what is not broken.

Seeded from /summary so the first paint is already right, then kept live by what
the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone
while unsettled, which is the bug that stopped Root location folding at all.

BackupsRow reports its bit from an EFFECT, not during render: calling the
parent's setter mid-render is an update to a different component, which React
refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:32:42 +01:00

363 lines
14 KiB
TypeScript

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<Status | null>(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<string | null>(null);
const [flashing, flash] = useFlash();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Status>('/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<string | null>(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<boolean | null>(null);
const [backupsReady, setBackupsReady] = useState(false);
const [collapsed, setCollapsed] = useState<boolean | null>(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<string> => {
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 (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
<label className="text-xs font-medium">{label}</label>
<span className="text-xs text-muted-foreground/70">{hint}</span>
</div>
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'}
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
{exists === null ? null : (
<>
<Icon className={`size-3.5 ${exists ? 'text-success' : ''}`} />
{exists ? 'exists' : 'not yet'}
</>
)}
</span>
</div>
</div>
);
};
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 (
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate font-mono text-xs">{state.name}</p>
</div>
{/* 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. */}
<span className="shrink-0 text-right text-xs text-muted-foreground/70">
{state.tables === null ? (
''
) : state.tables === 0 ? (
'empty'
) : (
<>
{state.tables} tables · {state.rows ?? 0} rows
{state.lastWrite ? (
<span className="block text-muted-foreground/50">last write {shortWhen(state.lastWrite)}</span>
) : null}
</>
)}
</span>
<div className="flex shrink-0 gap-2">
{/* Spins only when THIS row is the one running, or when both are. */}
<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
</Button>
</div>
</div>
);
};
/*
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 (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
// Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to
// hide the thing you came here to fix.
collapsible={settledNow}
collapsed={settledNow && collapsed !== false}
onCollapsedChange={setCollapsed}
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : settledNow ? (
<CircleCheck className="size-6 text-success" />
) : undefined
}
>
{!status ? null : settled ? (
<>
{row('Production', 'production', status.production)}
{row('Development', 'development', status.development)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Console output={output} failed={failed} />
{/* Below the two databases, because it is about both of them rather than either. */}
<BackupsRow onSettled={setBackupsReady} prodName={status.production.name ?? 'production'} devName={status.development.name ?? 'development'} />
{/* The common case is both. Doing them one at a time is still there for when it is not. */}
<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>
</>
) : (
<>
{field('Production', 'what the live site reads', production, setProduction)}
{field('Development', 'what you work against', development, setDevelopment)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end border-t border-border pt-3">
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
<Button
size="sm"
onClick={() => void apply()}
disabled={busy !== null || (!missing.length && !dirty) || !production.trim() || !development.trim()}
>
{busy === 'apply' ? (
<Loader2 className="size-3.5 animate-spin" />
) : missing.length ? (
<Plus className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
{label}
</Button>
</div>
</>
)}
</Block>
);
};