Two things the panel could not answer. A run's state lives in a Map, so a restart erased it — and with it any record that a file FAILED. That is the gap the manifests structurally cannot cover: a derived folder records the size, mtime and variants it was built from, so provenance is already precise, but a file that did not build has no folder and leaves no trace. Once the watcher is running, a failure at three in the morning would never be discovered. So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than a table because db/schema.ts is emphatic that the connection is the only thing this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside derived/ rather than inside it, so wiping derived does not erase the history of what happened before, which is the comparison it exists for. Written in a finally, so a run that died still records that it did. And the line above the button now says what is TRUE rather than what a run once did: reconcile() walks originals, reads each manifest, and reports current, missing and changed. It uses upToDate — the same predicate the sync uses to skip a file — because two definitions of "current" is how a panel comes to say everything is in step while the next run rebuilds half of it. Missing and changed are separated because only the second means somebody edited something. The ledger is not polled. It reads the whole tree, so it loads on arrival and again when a run stops — the only two moments its answer can change. Sync also takes an optional folder now, so a subtree can be re-synced on its own rather than the whole library. The scope is recorded on each run, because "3 built" means different things for one folder and for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
9.8 KiB
TypeScript
255 lines
9.8 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;
|
|
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;
|
|
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 = () => {
|
|
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);
|
|
|
|
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` : ''}
|
|
{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>
|
|
);
|
|
};
|