Moving a file into a folder made the sync look at a new path, find nothing, and convert it again — minutes of work to produce bytes that already existed a directory away — while the old folder stayed behind forever, because nothing ever deleted one. A move preserves size and mtime, and both are already in the manifest and in the stat. So the sync indexes what is already derived on that pair, and a file that has moved has its ladder RENAMED across. An ambiguous match is refused rather than guessed. Two originals can share a size and an mtime — a camera dump can write a whole card in one second — and rebuilding is merely slow where attaching the wrong photographs to a name is wrong. Orphans are removed afterwards, and only on a WHOLE-TREE sync. A scoped run knows about one folder, so everything outside it looks abandoned; deleting on that basis would take out the library the first time somebody re-synced a subfolder. Safe otherwise because derived/ is reproducible by definition, which is what its name has meant since these two folders stopped being called `user` and `treated`. Both are counted, recorded in the ledger and shown only when they happened, so an ordinary run stays one short line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { CircleAlert, Loader2, RefreshCw } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { readableError } from './errors';
|
|
|
|
/**
|
|
* Building `derived/` from `originals/`, shown where those two folders are.
|
|
*
|
|
* ── Why it is not its own section ──
|
|
*
|
|
* It was, briefly. But a section called "Derived media" sitting beside the block that lists
|
|
* `originals/` and `derived/` is the same subject described twice, and the reader has to work out that
|
|
* the two are about one thing. The action belongs to the folders it acts on, so it lives under them.
|
|
*
|
|
* ── It only appears once there is somewhere to put the output ──
|
|
*
|
|
* No `media/`, or a partial one, means the button would fail on a missing directory. The Create button
|
|
* above it is the thing to press first, and offering both at once is offering a choice where there is
|
|
* an order.
|
|
*
|
|
* ── It polls, and only while something is running ──
|
|
*
|
|
* A full pass takes minutes, so the request that starts it returns immediately and this asks how it is
|
|
* going. The interval is torn down when the answer says it has stopped, so an idle panel makes no
|
|
* requests — which is what keeps a screen somebody left open from being a request a second all
|
|
* afternoon.
|
|
*
|
|
* ── Built and skipped are counted separately ──
|
|
*
|
|
* Re-running is normal and cheap: everything already correct is skipped, because each derived folder
|
|
* carries the size and mtime it was built from. "412 already current" is the system saying it had
|
|
* nothing to do, which is a different and more useful answer than "412 done".
|
|
*
|
|
* ── Failures stay ──
|
|
*
|
|
* One bad file in four hundred is exactly what a progress bar hides, and it is the only part of this
|
|
* anybody has to act on.
|
|
*/
|
|
|
|
type SyncError = { file: string; message: string };
|
|
|
|
type SyncState = {
|
|
running: boolean;
|
|
total: number | null;
|
|
done: number;
|
|
built: number;
|
|
skipped: number;
|
|
/** Ladders renamed into place because their original moved, rather than rebuilt. */
|
|
moved: number;
|
|
/** Derived folders removed because their original is gone. Whole-tree syncs only. */
|
|
pruned: number;
|
|
errors: SyncError[];
|
|
current: string[];
|
|
startedAt: string | null;
|
|
finishedAt: string | null;
|
|
fatal: string | null;
|
|
};
|
|
|
|
type Run = {
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
trigger: 'manual' | 'watcher';
|
|
scope: string;
|
|
total: number;
|
|
built: number;
|
|
skipped: number;
|
|
moved: number;
|
|
pruned: number;
|
|
errors: SyncError[];
|
|
fatal: string | null;
|
|
};
|
|
|
|
type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] };
|
|
|
|
type Ledger = { runs: Run[]; reconciliation: Reconciliation };
|
|
|
|
/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */
|
|
const POLL_MS = 1000;
|
|
|
|
/** Recent runs worth showing. Enough to see a pattern, few enough to stay a footnote. */
|
|
const SHOWN_RUNS = 5;
|
|
|
|
const clock = (iso: string) =>
|
|
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
|
|
const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
|
|
|
/** `1m 12s`, or `8s`. Minutes matter here and hours have never happened. */
|
|
const took = (from: string, to: string) => {
|
|
const seconds = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 1000));
|
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
};
|
|
|
|
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [state, setState] = useState<SyncState | null>(null);
|
|
const [ledger, setLedger] = useState<Ledger | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setState(await clientRef.current.get<SyncState>('/fofos-admin/_config/media/sync'));
|
|
} catch {
|
|
// Owner-only. Root Location above has already said so once if that is the reason.
|
|
setState(null);
|
|
}
|
|
}, []);
|
|
|
|
/*
|
|
The ledger is NOT polled.
|
|
|
|
It walks the whole tree reading manifests, so asking for it once a second beside a progress bar
|
|
would have every tick re-read the library. It is read on arrival and again when a run stops, which
|
|
are the only two moments its answer can have changed.
|
|
*/
|
|
const loadLedger = useCallback(async () => {
|
|
try {
|
|
setLedger(await clientRef.current.get<Ledger>('/fofos-admin/_config/media/ledger'));
|
|
} catch {
|
|
setLedger(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
void loadLedger();
|
|
}, [load, loadLedger]);
|
|
|
|
useEffect(() => {
|
|
if (!state?.running) return;
|
|
const timer = setInterval(() => void load(), POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [state?.running, load]);
|
|
|
|
// Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what
|
|
// is left to do without the panel being reopened.
|
|
const wasRunning = useRef(false);
|
|
useEffect(() => {
|
|
const running = state?.running ?? false;
|
|
if (wasRunning.current && !running) void loadLedger();
|
|
wasRunning.current = running;
|
|
}, [state?.running, loadLedger]);
|
|
|
|
const start = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result = await clientRef.current.post<{ started: boolean; state: SyncState }>(
|
|
'/fofos-admin/_config/media/sync',
|
|
{},
|
|
);
|
|
setState(result.state);
|
|
} catch (caught) {
|
|
setError(readableError(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const running = state?.running ?? false;
|
|
const total = state?.total ?? null;
|
|
const done = state?.done ?? 0;
|
|
// Null total means the walk has not finished, so there is nothing honest to divide by yet.
|
|
const percent = total && total > 0 ? Math.round((done / total) * 100) : null;
|
|
const reconciliation = ledger?.reconciliation ?? null;
|
|
const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
|
|
|
|
/*
|
|
One bit, upward: is there anything left to do?
|
|
|
|
Reported rather than lifted, because everything else about this — the polling, the run history, the
|
|
errors — belongs here and would only be harder to read in a parent. What the parent needs is the
|
|
single fact it cannot compute: whether this part of the setup is finished.
|
|
*/
|
|
useEffect(() => {
|
|
if (reconciliation) onSettled?.(outstanding === 0);
|
|
}, [reconciliation, outstanding, onSettled]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<div className="flex items-center justify-between gap-3">
|
|
{/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads
|
|
a file, and "62 built" three days ago says nothing about whether derived/ is current. This is
|
|
recomputed from the filesystem, using the same freshness test the sync itself uses. */}
|
|
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{running
|
|
? total === null
|
|
? 'Reading originals…'
|
|
: `${done} of ${total}${state?.current.length ? ` · ${state.current.join(', ')}` : ''}`
|
|
: reconciliation
|
|
? outstanding === 0
|
|
? `${reconciliation.current} of ${reconciliation.originals} current`
|
|
: [
|
|
reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '',
|
|
reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')
|
|
: 'Web-ready copies of everything in originals.'}
|
|
</p>
|
|
|
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void start()} disabled={busy || running}>
|
|
{running ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync now'}
|
|
</Button>
|
|
</div>
|
|
|
|
{running && (
|
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse than
|
|
one that has not started. */}
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
|
style={{ width: `${percent ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{state?.fatal && (
|
|
<p className="flex items-start gap-1.5 text-xs text-destructive">
|
|
<CircleAlert className="mt-px size-3.5 shrink-0" />
|
|
{state.fatal}
|
|
</p>
|
|
)}
|
|
|
|
{state && state.errors.length > 0 && (
|
|
<ul className="max-h-32 space-y-1 overflow-auto rounded border border-border/60 p-2">
|
|
{state.errors.map((failure) => (
|
|
<li key={failure.file} className="text-xs">
|
|
<span className="font-medium">{failure.file}</span>
|
|
<span className="text-muted-foreground"> — {failure.message}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{/*
|
|
The ledger. Survives a restart, which the live state above does not — and it is the only place a
|
|
failure from a run nobody watched is ever recorded, since a file that did not build leaves no
|
|
folder and no manifest behind it.
|
|
*/}
|
|
{ledger && ledger.runs.length > 0 && (
|
|
<ul className="space-y-0.5 pt-1">
|
|
{ledger.runs.slice(0, SHOWN_RUNS).map((run) => (
|
|
<li key={run.finishedAt} className="flex items-baseline gap-2 text-xs text-muted-foreground/70">
|
|
<span className="shrink-0 font-mono">
|
|
{day(run.finishedAt)} {clock(run.finishedAt)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{run.scope ? `${run.scope} · ` : ''}
|
|
{run.built} built
|
|
{run.skipped ? `, ${run.skipped} current` : ''}
|
|
{/* Only when they happened, so an ordinary run stays one short line. */}
|
|
{run.moved ? `, ${run.moved} moved` : ''}
|
|
{run.pruned ? `, ${run.pruned} orphans removed` : ''}
|
|
{run.errors.length ? `, ${run.errors.length} failed` : ''}
|
|
{run.fatal ? ` — ${run.fatal}` : ''}
|
|
{run.trigger === 'watcher' ? ' · on upload' : ''}
|
|
</span>
|
|
<span className="shrink-0 font-mono">{took(run.startedAt, run.finishedAt)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|