diff --git a/app/api/jobs/[id]/events/route.ts b/app/api/jobs/[id]/events/route.ts index 1623d3d..a63e350 100644 --- a/app/api/jobs/[id]/events/route.ts +++ b/app/api/jobs/[id]/events/route.ts @@ -7,6 +7,8 @@ function sseLine(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } +const TERMINAL_STATUSES = new Set(["completed", "failed", "paused"]); + export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -23,6 +25,23 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: start(controller) { controller.enqueue(encoder.encode(sseLine("job", job))); + // If the job already finished before this connection was made (fast job, + // immediate failure, or a page refresh after the fact), the live + // onDone/onProgress events already fired and are gone — synthesize a + // "done" event from the persisted row instead of waiting forever. + if (TERMINAL_STATUSES.has(job.status)) { + controller.enqueue( + encoder.encode( + sseLine("done", { + status: job.status, + counters: job.counters, + }) + ) + ); + controller.close(); + return; + } + offProgress = onProgress(id, (event) => { controller.enqueue(encoder.encode(sseLine("progress", event))); }); diff --git a/app/migrate/[jobId]/page.tsx b/app/migrate/[jobId]/page.tsx index 94b8099..ead826c 100644 --- a/app/migrate/[jobId]/page.tsx +++ b/app/migrate/[jobId]/page.tsx @@ -24,6 +24,12 @@ interface DoneEvent { error?: string; } +interface JobEvent { + status: string; + cursor: { currentScope: string; itemsProcessedInScope: number } | null; + counters: JobCounters; +} + const emptyCounters: JobCounters = { matched: 0, alreadyPresent: 0, queuedForReview: 0, failed: 0 }; const SCOPE_LABELS: Record = { @@ -39,15 +45,31 @@ export default function MigrationProgressPage({ params }: { params: Promise<{ jo const [processed, setProcessed] = useState(0); const [counters, setCounters] = useState(emptyCounters); const [done, setDone] = useState(null); + const [lastUpdateAt, setLastUpdateAt] = useState(null); + const [secondsSinceUpdate, setSecondsSinceUpdate] = useState(0); useEffect(() => { const source = new EventSource(`/api/jobs/${jobId}/events`); + // Seeds current state immediately on connect — matters if the job made + // progress (or already finished) before this connection was established, + // since any live "progress"/"done" events that already fired are gone. + source.addEventListener("job", (e) => { + const data = JSON.parse(e.data) as JobEvent; + if (data.cursor) { + setScope(data.cursor.currentScope); + setProcessed(data.cursor.itemsProcessedInScope); + } + setCounters(data.counters); + setLastUpdateAt(Date.now()); + }); + source.addEventListener("progress", (e) => { const data = JSON.parse(e.data) as ProgressEvent; setScope(data.scope); setProcessed(data.processed); setCounters(data.counters); + setLastUpdateAt(Date.now()); }); source.addEventListener("done", (e) => { @@ -60,6 +82,18 @@ export default function MigrationProgressPage({ params }: { params: Promise<{ jo return () => source.close(); }, [jobId]); + // A ticking "quiet for Ns" readout — since a single track match/write can + // legitimately take a while (network calls, retries on rate limits), this + // tells the user the connection is alive and how long since the last + // update, rather than leaving them guessing whether it's frozen. + useEffect(() => { + if (done || lastUpdateAt === null) return; + const interval = setInterval(() => { + setSecondsSinceUpdate(Math.floor((Date.now() - lastUpdateAt) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, [lastUpdateAt, done]); + function cancel() { fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" }); } @@ -107,6 +141,14 @@ export default function MigrationProgressPage({ params }: { params: Promise<{ jo + {!done && lastUpdateAt !== null && ( +

+ {secondsSinceUpdate < 2 + ? "Live — updating now" + : `No update for ${secondsSinceUpdate}s — a single track match/write (or a rate-limit retry) can take a while; the connection is still alive.`} +

+ )} + {done?.error && (

{done.error}

)}