Files
fofos-admin/web/DatabaseBlock.tsx
T
pastilhasandClaude Opus 5 1b398121b7 backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button
with a tooltip explaining it is not built is worse than no button.

In its place, backups. `cron` was already a dependency and used nowhere, so the
schedule is a real cron expression — a backup wants to happen at an HOUR, and an
interval counted from whenever somebody pressed save drifts across the clock
until it runs mid-afternoon.

Cron alone is not enough, and this is the part worth writing down: it does not
run a job it was down for. A daily backup at three on a machine that restarts
overnight is skipped silently — the schedule is still there, the next fire is
still in the future, and a month later there is one file. So `lastBackupAt` is
in the database, and both the startup and a fifteen-minute tick compare it
against the schedule's own period, computed from two successive fires. A backup
that should already have happened happens now.

The job handle is module-level and stopped before a new one is made. Two saves
leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks
later, with nothing connecting it to a settings form.

Back up now exists because a scheduled job nobody has ever run by hand is a
guess — it is how you learn the location is writable and pg_dump can reach the
databases without waiting until three to find out it cannot. The list underneath
is a readdir of the location rather than a record of what should be there: a
schedule that has been failing quietly looks exactly like a working one until
you count the files.

pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's
own, which is not this plugin's to copy around. And a failed run still records
the timestamp, or one undumpable database would have the catch-up tick retrying
every fifteen minutes for the rest of the week.

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

287 lines
11 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.
*/
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) => {
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 ? '' : state.tables > 0 ? `${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>
</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} />
{/* Below the two databases, because it is about both of them rather than either. */}
<BackupsRow />
{/* 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>
);
};