"use client"; import { useEffect, useRef, useState } from "react"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { cancelJobAction } from "../../jobs/actions"; type LaneStatus = | "starting" | "queued" | "running" | "done" | "failed" | "cancelled" | "error"; // How a lane ENDS: its job's terminal status, or "error" when the trigger // refused (or threw) before any job existed. export type LaneOutcome = "done" | "failed" | "cancelled" | "error"; type Props = { title: string; subtitle: string; // Launched once on mount. Returns the managed-job descriptor (or an error). trigger: () => Promise; // Called ONCE, when the lane reaches its terminal state, with that state — // e.g. to refresh a server-rendered "built " line after a build. Not // called when the lane is unmounted first (another launch replaced it). onSettled?: (outcome: LaneOutcome) => void; // What Cancel calls with the lane's job id (default: that one job). A // publish run's lanes pass cancelPublishRunAction: the run's every stage. cancelAction?: (jobId: string) => Promise<{ ok: boolean }>; }; const CHIP: Record = { starting: { label: "Starting…", cls: "bg-muted text-muted-foreground" }, queued: { label: "Queued", cls: "bg-muted text-muted-foreground" }, running: { label: "Running", cls: "bg-info-soft text-info" }, done: { label: "Done", cls: "bg-success-soft text-success" }, failed: { label: "Failed", cls: "bg-destructive-soft text-destructive" }, cancelled: { label: "Cancelled", cls: "bg-muted text-muted-foreground" }, error: { label: "Error", cls: "bg-destructive-soft text-destructive" }, }; // A compact, auto-starting lane for one site's build (or build+deploy). Reuses // the managed-job stream + done promise: the stream feeds the log, `done` gives // the terminal status. A light poll surfaces queued/running + queue position // before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in // miniature so several lanes can run at once on the batch panel. export function JobLane({ title, subtitle, trigger, onSettled, cancelAction }: Props) { const [status, setStatus] = useState("starting"); const [log, setLog] = useState(""); const [error, setError] = useState(null); const [jobId, setJobId] = useState(null); const [queuePos, setQueuePos] = useState(null); const [cancelling, setCancelling] = useState(false); const preRef = useRef(null); const startedRef = useRef(false); // Whether the lane is mounted, kept by its OWN effect. It used to be a flag // local to the launch effect below, set by that effect's cleanup — and // Strict Mode (on under `next dev`) runs mount → cleanup → mount: the // cleanup set the flag, the second run returned early on startedRef, and // every update of the one launch there is was dropped. The job ran; the lane // sat on "Starting…" forever. Declared first, so on the remount it is true // again before anything reads it. Production mounts once and never saw it. const aliveRef = useRef(false); useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []); // The latest onSettled, read when the lane ends: the launch effect below runs // once, so a prop captured by its closure would be the first render's. And // settle() fires it at most once — the launch is one-shot (startedRef), but // "once" is this prop's contract, so it is guarded here rather than implied // by the effect's shape (Strict Mode's mount → cleanup → mount included). const onSettledRef = useRef(onSettled); useEffect(() => { onSettledRef.current = onSettled; }); const settledRef = useRef(false); useEffect(() => { // Strict-mode mounts effects twice in dev; guard so the job launches once. if (startedRef.current) return; startedRef.current = true; const unmounted = () => !aliveRef.current; let stopped = false; let pollTimer: ReturnType | null = null; // The lane's terminal state: its chip, then the caller (once). function settle(outcome: LaneOutcome) { if (unmounted()) return; setStatus(outcome); if (settledRef.current) return; settledRef.current = true; onSettledRef.current?.(outcome); } async function poll(id: string) { try { const res = await fetch( `/api/jobs/${encodeURIComponent(id)}/log?from=999999999`, { cache: "no-store" }, ); if (res.ok) { const data = (await res.json()) as { status: string; queuePosition?: number; }; if (!unmounted()) { if (data.status === "queued") { setStatus("queued"); setQueuePos( typeof data.queuePosition === "number" && data.queuePosition > 0 ? data.queuePosition : null, ); } else if (data.status === "running") { setStatus("running"); setQueuePos(null); } } } } catch { /* transient; keep polling */ } if (!unmounted() && !stopped) pollTimer = setTimeout(() => poll(id), 1000); } (async () => { let result: StreamActionResult; try { result = await trigger(); } catch (e) { if (!unmounted()) setError((e as Error).message); settle("error"); return; } if (unmounted()) return; if (!result.ok) { setError(result.error); settle("error"); return; } setJobId(result.jobId); setStatus("running"); poll(result.jobId); const reader = result.stream.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; if (value && !unmounted()) setLog((prev) => prev + value); } } catch { /* stream tear-down */ } const term = await result.done; stopped = true; if (pollTimer) clearTimeout(pollTimer); if (!unmounted()) setQueuePos(null); settle( term.status === "done" ? "done" : term.status === "failed" ? "failed" : "cancelled", ); })(); return () => { if (pollTimer) clearTimeout(pollTimer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Keep the log scrolled to the bottom as it grows. useEffect(() => { const el = preRef.current; if (el) el.scrollTop = el.scrollHeight; }, [log]); async function handleCancel() { if (!jobId) return; setCancelling(true); try { await (cancelAction ?? cancelJobAction)(jobId); } finally { setCancelling(false); } } const chip = CHIP[status]; const live = status === "queued" || status === "running" || status === "starting"; return (
{status === "queued" && queuePos ? `Queued · ${queuePos}` : chip.label}
{title}
{subtitle}
{live && jobId && ( )}
{error && (
{error}
)} {(log || live) && (
Log
            {log || "Waiting for output…"}
          
)}
); }