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>
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { readableError } from './errors';
|
|
import { Console } from './Console';
|
|
|
|
/**
|
|
* The website checkout under the root location: is it there, clone it, update it.
|
|
*
|
|
* ── It lives inside Root location on purpose ──
|
|
*
|
|
* The path and the thing at the path are one question. Split into its own block, somebody could change
|
|
* the root in one place and read a checkout status about the old one in another — and the two would
|
|
* look equally current.
|
|
*
|
|
* ── git's own output, verbatim, when something fails ──
|
|
*
|
|
* "Permission denied (publickey)" says exactly what is wrong. Anything this could write in its place
|
|
* would be a guess at which of a dozen causes it was, and the guess is what sends somebody looking in
|
|
* the wrong direction.
|
|
*
|
|
* ── The console closes itself on success, and never on failure ──
|
|
*
|
|
* A successful clone or update is worth WATCHING and not worth keeping: "74 packages installed" has
|
|
* been read by the time it finishes, and left on screen it becomes furniture that outlives its own
|
|
* truth — still sitting there after the root has been changed underneath it.
|
|
*
|
|
* A failure is the opposite. It gets no timer at all rather than a long one — the only thing that
|
|
* takes it off the screen is starting the next attempt, which is the one moment it stops being true.
|
|
* That includes the half-done case, which is the most important of the three: a clone that worked
|
|
* followed by an install that did not is a failure, `ok` is false, and the transcript of both stays.
|
|
*
|
|
* ── And it can be copied ──
|
|
*
|
|
* A failure is something somebody pastes — into a search, into a message, back to whoever wrote the
|
|
* thing that broke. Selecting text inside a scrolled box, on a phone, is the point at which people
|
|
* take a screenshot of an error message instead, and a screenshot of a stack trace helps nobody.
|
|
*/
|
|
|
|
type Status = {
|
|
path: string;
|
|
present: boolean;
|
|
isRepo: boolean;
|
|
branch: string | null;
|
|
head: string | null;
|
|
remote: string | null;
|
|
dirty: boolean;
|
|
cloneUrl: string;
|
|
};
|
|
|
|
type RunResult = { ok: boolean; output: string; status: Status };
|
|
|
|
export const WebsiteCheckout = ({
|
|
rootPath,
|
|
onSettled,
|
|
}: {
|
|
rootPath: string;
|
|
onSettled?: (settled: boolean) => void;
|
|
}) => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [status, setStatus] = useState<Status | null>(null);
|
|
const [busy, setBusy] = useState<'clone' | 'update' | null>(null);
|
|
const [output, setOutput] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setStatus(await clientRef.current.get<Status>('/fofos-admin/_config/website'));
|
|
} catch {
|
|
// Owner-only, so a member gets nothing here. The block above already says why, and repeating it
|
|
// would be two refusals for one cause.
|
|
setStatus(null);
|
|
}
|
|
}, []);
|
|
|
|
// Re-checked whenever the ROOT changes, because the answer is about `<root>/website` and a stale one
|
|
// describes a directory nobody is looking at any more.
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load, rootPath]);
|
|
|
|
// One bit upward — see the note in `MediaSync`. A checkout that is present but not a repository is
|
|
// NOT settled: something is in the way, and that is exactly the state worth staying open for.
|
|
useEffect(() => {
|
|
onSettled?.(Boolean(status?.isRepo));
|
|
}, [status?.isRepo, onSettled]);
|
|
|
|
const act = async (what: 'clone' | 'update') => {
|
|
setBusy(what);
|
|
setOutput(null);
|
|
setFailed(false);
|
|
try {
|
|
const result = await clientRef.current.post<RunResult>(`/fofos-admin/_config/website/${what}`, {});
|
|
setStatus(result.status);
|
|
setOutput(result.output || null);
|
|
setFailed(!result.ok);
|
|
} catch (failure) {
|
|
// A 400 carries git's own words in the body; `readableError` surfaces them.
|
|
setOutput(readableError(failure));
|
|
setFailed(true);
|
|
void load();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
if (!status) return null;
|
|
|
|
const Icon = status.isRepo ? CircleCheck : status.present ? CircleAlert : CircleDashed;
|
|
const tone = status.isRepo ? 'text-success' : status.present ? 'text-destructive' : 'text-muted-foreground';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex items-start gap-2">
|
|
<Icon className={`mt-0.5 size-3.5 shrink-0 ${tone}`} />
|
|
<div className="min-w-0 flex-1 space-y-0.5">
|
|
<p className="font-mono text-xs break-all">{status.path}</p>
|
|
|
|
{status.isRepo ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{status.branch ?? 'detached'} · {status.head ?? 'no commits'}
|
|
{status.dirty && <span className="text-destructive"> · uncommitted changes</span>}
|
|
</p>
|
|
) : status.present ? (
|
|
// Present but not a checkout is the one state worth being loud about: cloning will refuse,
|
|
// and the reason is something already sitting there.
|
|
<p className="text-xs text-destructive">Something is already here, and it is not a git checkout.</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">Not cloned yet.</p>
|
|
)}
|
|
|
|
{/* Only when it is NOT what this plugin would clone. Shown always it is noise; shown on a
|
|
mismatch it is the answer to why an update is pulling something unexpected. */}
|
|
{status.isRepo && status.remote && status.remote !== status.cloneUrl && (
|
|
<p className="font-mono text-xs break-all text-destructive">origin: {status.remote}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{status.isRepo ? (
|
|
<Button variant="outline" size="sm" onClick={() => void act('update')} disabled={busy !== null}>
|
|
{busy === 'update' ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
Update
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" onClick={() => void act('clone')} disabled={busy !== null || status.present}>
|
|
{busy === 'clone' ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<DownloadCloud className="size-3.5" />
|
|
)}
|
|
Clone
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* git's own words, in git's own shape — see `Console`, which owns the timing and the copy
|
|
button. Shared with the schema pushes in Database, which need exactly the same thing. */}
|
|
<Console output={output} failed={failed} />
|
|
</div>
|
|
);
|
|
};
|