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:
+21
-1
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user