Two conditions with similar names got conflated by a search-and-replace. `settled` means the databases are named and exist, which is when the form has done its job. `settledNow` means everything including BACKUPS is configured, which is when the whole block can fold away. The form/rows branch was using the second. That deadlocked it. The component reporting whether backups are configured lives inside the rows branch — so showing the form meant it never mounted, never reported, and the form could never be replaced. The panel went back to asking for names it already had, with no way out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
15 KiB
TypeScript
382 lines
15 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);
|
|
/* null until BackupsRow has actually answered. `false` would be a REPORT of not-ready, and using
|
|
it before the answer exists is what made this block paint open and then shut. */
|
|
const [backupsReady, setBackupsReady] = useState<boolean | null>(null);
|
|
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,
|
|
);
|
|
|
|
/*
|
|
The summary until EVERY local part has answered, then the live value.
|
|
|
|
Both conditions, not either. Switching as soon as `status` arrived meant using `backupsReady` while
|
|
it was still its initial value — an answer nobody had given — so the block decided it was unfinished
|
|
and opened, and shut again a moment later when the real answer came.
|
|
*/
|
|
const answered = status !== null && backupsReady !== null;
|
|
const settledNow = answered ? 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
|
|
}
|
|
>
|
|
{/*
|
|
`settled`, NOT `settledNow`. These answer different questions and conflating them deadlocked
|
|
this block: `settledNow` includes whether BACKUPS are configured, but the component that
|
|
reports that lives inside this branch — so showing the form meant it never mounted, never
|
|
reported, and the form stayed forever.
|
|
|
|
The form is about NAMING. Once both databases are named and exist, it has done its job.
|
|
*/}
|
|
{summaryReady === null || !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>
|
|
);
|
|
};
|