Sync says what it is doing, file by file

The counters said how MUCH and never which file, so a run that was working normally and one that had
wedged produced the same screen: `done` stops moving either way. A photograph is around thirty encodes
and a video is three plus a poster ladder, and none of that was visible from outside.

So the run keeps a transcript and the button opens it.

Every rung reports itself by CONSTRUCTION. Ten `variants.push` call sites became one `add`, which is
the point — a rung added later is described without anybody remembering to describe it. The slow
stages that produce nothing until they finish say so BEFORE they start instead: the HEIC decode, each
video rung, the poster frame. Those are the pauses that read as a hang.

`?since=<n>` is what keeps it cheap. The transcript is thousands of lines and polls once a second, so
sending all of it every tick would be the same megabyte over and over; the client keeps what it has
and asks for the rest. No `since` means everything, which is what a modal opened mid-run wants.

The transcript lives in the shared slot, not the modal, so closing it does not throw away the run —
somebody who looks, closes, and looks again is watching one run rather than two. `startedAt` is what
identifies a run, because `n` restarts at 1 and appending across a boundary would interleave two.

Polling, not a socket. Each line transfers once and the events are seconds apart, so a second
transport to open, authenticate and reconnect would buy nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 12:16:52 +01:00
co-authored by Claude Opus 5
parent 78db209ea1
commit ddcba1c95d
6 changed files with 442 additions and 40 deletions
+33 -18
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SyncModal } from './SyncModal';
import { useMediaSync, useMediaSyncFeed } from './useMediaSync';
import { useReady } from './useReady';
@@ -22,6 +24,13 @@ import { useReady } from './useReady';
* anything in this plugin is open and the Administration card is mounted only sometimes. The thing
* that is always there should be the thing that asks.
*
* ── Pressing it OPENS rather than starts ──
*
* A sync is minutes of work on a folder somebody may not have looked at in a while, and starting it
* from a bar gave no way to see what it was doing — the counters said how much and never which file.
* So the button opens the transcript and the transcript has the verb in it. One press to look, one to
* commit, and the second one is next to the thing it will fill.
*
* ── The label is the state ──
*
* `Sync 3` rather than a badge or a dot: the number IS the reason to press it, and a button that says
@@ -30,8 +39,9 @@ import { useReady } from './useReady';
* is exactly the case somebody needs it for.
*/
export const SyncButton = () => {
const { running, percent, outstanding, busy, start } = useMediaSync();
const { running, percent, outstanding } = useMediaSync();
const { rootSet } = useReady();
const [open, setOpen] = useState(false);
useMediaSyncFeed();
@@ -45,22 +55,27 @@ export const SyncButton = () => {
if (!rootSet) return null;
return (
<Button
size="sm"
variant="outline"
className="h-6 shrink-0 gap-1.5 px-2 text-xs"
onClick={() => void start()}
disabled={busy || running}
title={
running
? 'Building web-ready copies of everything in originals'
: outstanding > 0
? `${outstanding} file${outstanding === 1 ? '' : 's'} in originals have no current copy in derived`
: 'Rebuild anything in derived that is missing or out of date'
}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync'}
</Button>
<>
<SyncModal open={open} onClose={() => setOpen(false)} />
<Button
size="sm"
variant="outline"
className="h-6 shrink-0 gap-1.5 px-2 text-xs"
onClick={() => setOpen(true)}
// Never disabled. It opens a window onto a run — which is most worth opening WHILE one is going,
// and a button that locks itself exactly then is the opposite of what it is for.
title={
running
? 'Building web-ready copies of everything in originals'
: outstanding > 0
? `${outstanding} file${outstanding === 1 ? '' : 's'} in originals have no current copy in derived`
: 'Rebuild anything in derived that is missing or out of date'
}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync'}
</Button>
</>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useFlash } from './useFlash';
import { useMediaSync, type LogLine } from './useMediaSync';
/**
* What the sync is doing, line by line, while it does it.
*
* ── Why a transcript and not a progress bar ──
*
* The bar answers "how much" and it was the only answer available. It cannot say WHICH file, and the
* question people actually have during a long run is "is it stuck?" — which looks identical to "it is
* decoding a 40-megapixel HEIC" from outside. A photograph is around thirty encodes and a video is
* three plus a poster ladder; none of that was visible, so a run that was working normally and one
* that had wedged produced the same screen.
*
* ── The lines come from the poll, not a socket ──
*
* The state is already polled once a second while a run is going, and `?since=` means each line is
* transferred once. So this is a stream in the only sense that matters here — lines appear as they
* happen — without a second transport to open, authenticate and reconnect. Sub-second arrival would
* need SSE and would buy nothing: the events themselves are seconds apart.
*
* ── It is not the only place a failure is recorded ──
*
* Closing this loses nothing. The transcript lives in the shared slot and the ledger in Administration
* outlives the run entirely — this is the live view, not the record.
*/
const COLOURS: Record<LogLine['level'], string> = {
info: 'text-muted-foreground',
// The busiest level by far, and the least interesting line at a time. Dimmer than `info` so the
// structure of a run — its starts and its finishes — stays readable as the rungs scroll past.
step: 'text-muted-foreground/60',
done: 'text-foreground',
warn: 'text-amber-500',
error: 'text-destructive',
};
const clock = (iso: string) =>
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => {
const { log, state, running, percent, error, start, busy } = useMediaSync();
const [copied, flashCopied] = useFlash(1500);
/*
Follow the tail, unless somebody has scrolled away from it.
A transcript that always jumps to the bottom is unreadable while it is being written: the line
somebody is reading leaves the screen a second after they find it. So this follows only while the
view is already AT the bottom, which is the state it starts in and returns to on its own.
*/
const box = useRef<HTMLDivElement | null>(null);
const [following, setFollowing] = useState(true);
useLayoutEffect(() => {
const node = box.current;
if (!node || !following) return;
node.scrollTop = node.scrollHeight;
}, [log.length, following]);
// Reset to following whenever it opens, so reopening mid-run lands on the newest line rather than
// wherever the last visit left off.
useEffect(() => {
if (open) setFollowing(true);
}, [open]);
const transcript = log.map((line) => `${clock(line.at)} ${line.file ? `${line.file} — ` : ''}${line.text}`).join('\n');
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{running && <Loader2 className="size-3.5 animate-spin" />}
Sync
<span className="font-normal text-muted-foreground">
{running
? state?.total
? `${state.done} of ${state.total}${percent === null ? '' : ` · ${percent}%`}`
: 'reading originals…'
: state?.finishedAt
? 'finished'
: 'idle'}
</span>
</DialogTitle>
</DialogHeader>
{running && (
<div className="h-1 w-full shrink-0 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>
)}
<div
ref={box}
// `onScroll` rather than a button: following is a thing somebody stops by scrolling, which is
// what they were already doing, and resumes by scrolling back down.
onScroll={(event) => {
const node = event.currentTarget;
setFollowing(node.scrollHeight - node.scrollTop - node.clientHeight < 24);
}}
className="min-h-[16rem] flex-1 overflow-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-[11px] leading-relaxed"
>
{log.length === 0 ? (
<p className="text-muted-foreground">
Nothing yet. Press Sync to build web-ready copies of everything in originals.
</p>
) : (
log.map((line) => (
<div key={line.n} className={`flex gap-2 ${COLOURS[line.level]}`}>
<span className="shrink-0 opacity-50">{clock(line.at)}</span>
{/* The file is not repeated on every rung of the same file — the eye follows a column
of identical names badly, and the interesting part is what changed. */}
{line.file && <span className="min-w-0 shrink-0 max-w-[45%] truncate">{line.file}</span>}
<span className="min-w-0 flex-1 break-words">{line.text}</span>
</div>
))
)}
</div>
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
<div className="flex shrink-0 items-center justify-between gap-2">
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
disabled={log.length === 0}
onClick={() => {
void copyToClipboard(transcript);
flashCopied();
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy'}
</Button>
{/* Start from in here too. Opening the modal to watch and then having to close it to press
the button is the sort of thing that only reads as obvious to whoever built it. */}
<Button
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => void start()}
disabled={busy || running}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? 'Syncing' : 'Sync'}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
+59 -3
View File
@@ -31,6 +31,17 @@ import { readableError } from './errors';
export type SyncError = { file: string; message: string };
/** One line of the run's transcript. Mirrors LogLine in api/sync.ts. */
export type LogLine = {
/** Monotonic within a run. What `?since=` is asked with, so each line transfers once. */
n: number;
at: string;
level: 'info' | 'step' | 'done' | 'warn' | 'error';
/** The file this is about, or blank for the run's own lines. */
file: string;
text: string;
};
export type SyncState = {
running: boolean;
total: number | null;
@@ -43,6 +54,8 @@ export type SyncState = {
pruned: number;
errors: SyncError[];
current: string[];
/** Only the lines the server had not already sent — see the `since` note in the feed. */
log: LogLine[];
startedAt: string | null;
finishedAt: string | null;
fatal: string | null;
@@ -75,12 +88,21 @@ const KEY = ['fofos-admin', 'media-sync'];
type Shared = {
state: SyncState | null;
ledger: Ledger | null;
/**
* The transcript, ACCUMULATED — the server sends deltas and this is the whole of it.
*
* Kept here rather than in the modal so closing the modal does not throw away the run's history.
* Somebody who opens it, watches, closes it and opens it again is watching one run, not two.
*/
log: LogLine[];
/** Which run `log` belongs to, so a new one clears it. See the note where it is compared. */
logRun: string | null;
/** A start request is in flight — distinct from `state.running`, which is the SERVER's answer. */
busy: boolean;
error: string | null;
};
const EMPTY: Shared = { state: null, ledger: null, busy: false, error: null };
const EMPTY: Shared = { state: null, ledger: null, log: [], logRun: null, busy: false, error: null };
export type MediaSync = Shared & {
/** A pass is under way. */
@@ -147,10 +169,44 @@ export const useMediaSyncFeed = (): void => {
const setRef = useRef(setShared);
setRef.current = setShared;
// The latest value, for the poll to read WITHOUT being rebuilt when it changes — an interval whose
// callback identity changed every line would be torn down and restarted on every line.
const sharedRef = useRef(shared);
sharedRef.current = shared;
const load = useCallback(async () => {
const held = sharedRef.current;
const since = held.log.at(-1)?.n;
try {
const state = await clientRef.current.get<SyncState>('/fofos-admin/_config/media/sync');
setRef.current((was) => ({ ...was, state }));
/*
Ask only for what we do not have.
A long run's transcript is thousands of lines and this polls every second, so without `since`
the same megabyte would arrive over and over. On the first poll of a run there is nothing held,
so `since` is absent and the server sends the lot — which is what a modal opened mid-run wants.
*/
const state = await clientRef.current.get<SyncState>(
since === undefined
? '/fofos-admin/_config/media/sync'
: `/fofos-admin/_config/media/sync?since=${since}`,
);
setRef.current((was) => {
/*
A NEW run resets the transcript.
`n` restarts at 1 each run, so appending across a boundary would interleave two runs and put
the numbers out of order. `startedAt` is what identifies a run — comparing it is cheaper and
more honest than watching `running` go false then true, which misses a run that began and
ended between two polls.
*/
const run = state.startedAt;
const fresh = run !== was.logRun;
const log = fresh ? state.log : [...was.log, ...state.log];
return { ...was, state, log, logRun: run };
});
} catch {
// Owner-only. Root Location has already said so once if that is the reason.
setRef.current((was) => ({ ...was, state: null }));