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>
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useGlobal } from 'hooks/useGlobal';
|
|
import { readableError } from './errors';
|
|
|
|
/**
|
|
* The state of `derived/` — what is running, what is outstanding, and how to start a pass.
|
|
*
|
|
* ── Why this is shared at all ──
|
|
*
|
|
* The button moved to the panel's action bar, beside Build and Publish, because syncing is one of the
|
|
* things somebody does to a site rather than a detail of one card. The rest of it — the progress bar,
|
|
* the failures, the run history — stayed in Administration, where there is room to read it.
|
|
*
|
|
* So one subject is drawn in two places at once, and they must not disagree. A header saying "Sync
|
|
* now" beside a card saying "3 changed" is two components each deciding what is true.
|
|
*
|
|
* ── `useGlobal`, not a context ──
|
|
*
|
|
* A slot in the app-wide query cache, keyed by name: whoever reads the key gets the value, whoever
|
|
* writes it re-renders every reader. No provider, so nothing has to wrap the bar and the panel
|
|
* together, and a third place that wants this later just reads the key.
|
|
*
|
|
* ── One reader FETCHES, everyone else reads ──
|
|
*
|
|
* `useGlobal` shares the value, not the work: mount the same effects twice and the endpoint is polled
|
|
* twice a second. So the fetching is a separate hook with exactly one caller — `SyncButton`, which
|
|
* lives in the bar and is therefore mounted whenever anything in this plugin is open. The
|
|
* Administration card is mounted only sometimes, which is precisely why it must not be the one asking.
|
|
*/
|
|
|
|
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;
|
|
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[];
|
|
/** 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;
|
|
};
|
|
|
|
export 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;
|
|
};
|
|
|
|
export type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] };
|
|
|
|
export 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;
|
|
|
|
/** The slot. One name, so a third reader is a one-line addition rather than a refactor. */
|
|
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, log: [], logRun: null, busy: false, error: null };
|
|
|
|
export type MediaSync = Shared & {
|
|
/** A pass is under way. */
|
|
running: boolean;
|
|
/** How far through, or null while the walk is still counting — nothing honest to divide by yet. */
|
|
percent: number | null;
|
|
/** Files with no ladder plus files whose ladder is stale. Zero means `derived/` is current. */
|
|
outstanding: number;
|
|
/** Whether the ledger has been read at all. Before that `outstanding` is 0 because it is unknown. */
|
|
known: boolean;
|
|
start: () => Promise<void>;
|
|
};
|
|
|
|
/** Read the sync, and start one. Any number of callers. */
|
|
export const useMediaSync = (): MediaSync => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [shared, setShared] = useGlobal<Shared>(KEY, EMPTY);
|
|
|
|
const start = useCallback(async () => {
|
|
setShared((was) => ({ ...was, busy: true, error: null }));
|
|
try {
|
|
const result = await clientRef.current.post<{ started: boolean; state: SyncState }>(
|
|
'/fofos-admin/_config/media/sync',
|
|
{},
|
|
);
|
|
setShared((was) => ({ ...was, state: result.state, busy: false }));
|
|
} catch (caught) {
|
|
setShared((was) => ({ ...was, busy: false, error: readableError(caught) }));
|
|
}
|
|
// `setShared` is rebuilt every render by useGlobal — see the note in SitePreview about verbs from
|
|
// useClient. Left out of the deps on purpose, and it closes over nothing that changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const total = shared.state?.total ?? null;
|
|
const done = shared.state?.done ?? 0;
|
|
const reconciliation = shared.ledger?.reconciliation ?? null;
|
|
|
|
return {
|
|
...shared,
|
|
running: shared.state?.running ?? false,
|
|
percent: total && total > 0 ? Math.round((done / total) * 100) : null,
|
|
outstanding: (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0),
|
|
known: reconciliation !== null,
|
|
start,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Keep the slot current. EXACTLY ONE caller — `SyncButton`.
|
|
*
|
|
* Separate from reading because `useGlobal` shares the value and not the work: two components running
|
|
* these effects would poll the same endpoint twice a second and re-read the ledger twice per run.
|
|
*/
|
|
export const useMediaSyncFeed = (): void => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [shared, setShared] = useGlobal<Shared>(KEY, EMPTY);
|
|
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 {
|
|
/*
|
|
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 }));
|
|
}
|
|
}, []);
|
|
|
|
/*
|
|
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 {
|
|
const ledger = await clientRef.current.get<Ledger>('/fofos-admin/_config/media/ledger');
|
|
setRef.current((was) => ({ ...was, ledger }));
|
|
} catch {
|
|
setRef.current((was) => ({ ...was, ledger: null }));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
void loadLedger();
|
|
}, [load, loadLedger]);
|
|
|
|
const running = shared.state?.running ?? false;
|
|
|
|
useEffect(() => {
|
|
if (!running) return;
|
|
const timer = setInterval(() => void load(), POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [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(() => {
|
|
if (wasRunning.current && !running) void loadLedger();
|
|
wasRunning.current = running;
|
|
}, [running, loadLedger]);
|
|
};
|