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>
363 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|