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>
This commit is contained in:
2026-08-29 13:32:42 +01:00
co-authored by Claude Opus 5
parent fb234ede5d
commit af31b57cdf
4 changed files with 190 additions and 9 deletions
+21 -1
View File
@@ -67,7 +67,15 @@ const size = (bytes: number) =>
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null);
export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: string }) => {
export const BackupsRow = ({
prodName,
devName,
onSettled,
}: {
prodName: string;
devName: string;
onSettled?: (settled: boolean) => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
@@ -110,6 +118,18 @@ export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: s
void load();
}, [load]);
/*
One bit upward, so the block above can decide whether the whole section is finished.
In an effect and not during render. Calling the parent's setter while this component renders is an
update to a DIFFERENT component mid-render, which React refuses outright — and the version of this
that did it would have thrown on the first paint.
*/
const settledUp = Boolean(settings?.cron && settings?.sshHost && settings?.sshPath);
useEffect(() => {
onSettled?.(settledUp);
}, [settledUp, onSettled]);
const save = async () => {
setBusy('save');
setError(null);
+80 -4
View File
@@ -31,7 +31,17 @@ import { useFlash } from './useFlash';
* typing a name actually has, and answering it about a different name would be worse than silence.
*/
type Named = { name: string | null; exists: boolean | null; tables: number | null };
/** 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 = () => {
@@ -69,6 +79,29 @@ export const DatabaseBlock = () => {
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.
*
@@ -193,8 +226,21 @@ export const DatabaseBlock = () => {
<p className="truncate font-mono text-xs">{state.name}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground/70">
{state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'}
{/* 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">
@@ -218,17 +264,47 @@ export const DatabaseBlock = () => {
);
};
/*
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
}
>
@@ -241,7 +317,7 @@ export const DatabaseBlock = () => {
<Console output={output} failed={failed} />
{/* Below the two databases, because it is about both of them rather than either. */}
<BackupsRow prodName={status.production.name ?? 'production'} devName={status.development.name ?? 'development'} />
<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">