Files
fofos-admin/web/DatabaseBlock.tsx
T
pastilhasandClaude Opus 5 bda59bdf03 one spinner per action, one button for both, and the shared console
Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:47:58 +01:00

299 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, DatabaseBackup, 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 { 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.
*/
type Named = { name: string | null; exists: boolean | null; tables: number | 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);
/**
* 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) => {
// "Schema pushed" is simply: something is in it. See the note on `tables`.
const pushed = (state.tables ?? 0) > 0;
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>
<span className="shrink-0 text-xs text-muted-foreground/70">
{state.tables === null ? '' : pushed ? `${state.tables} tables` : 'empty'}
</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>
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against
an empty database fails on every insert, and that failure would read as a bad backup.
Still disabled beyond that: what a backup IS here has not been decided, and a button that
guesses at the source of a restore is not a thing to put near a client's data. */}
<Button
variant="outline"
size="sm"
disabled
title={pushed ? 'Not built yet — no backup source chosen' : 'Push the schema first'}
>
<DatabaseBackup className="size-3.5" />
Restore from backup
</Button>
</div>
</div>
);
};
return (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : 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} />
{/* 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>
);
};