"use client"; import { useEffect, useMemo, useState } from "react"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import { usePolledPayload } from "../../lib/usePolledPayload"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { isLive, mergeJobRows } from "yt-dlp-transcript-common/views/jobRows"; import { ReapStuckButton } from "./ReapStuckButton"; import { LaneStrip } from "./LaneStrip"; import { JobRow, statusColor } from "./JobRow"; import { jobKindLabel } from "../jobKindLabels"; import { clearJobsFilters, defaultJobsFilters, loadJobsFilters, saveJobsFilters, type JobsFilterState, } from "../jobsFilterStorage"; // The live head's poll interval — the same 1 s the Active Jobs screen used, so // per-task progress bars still advance smoothly. const POLL_MS = 1000; // One figure on the health line. `data-stat` is the hook; the label text is the // contract (the suite matches "stuck" and "active queues" exactly). function healthStat( key: string, label: string, value: string, emphasize?: boolean, ) { return (
{value} {label}
); } // ONE LIST. // // The live head is what the registry and the scheduler say NOW, polled at 1 s // while anything is non-terminal; the history tail is the paged directory // listing the server rendered, refreshed by the global pulse like every other // page. They meet in one , one per job, merged by id with the live // row winning. Nothing is a mode: a queued job and a job that failed a week ago // are the same kind of row with different cells filled in. // // Hydration safety: the server renders every job (filters === null), and stored // filters are only applied after mount in an effect — so the first client paint // matches the server HTML and refresh-report rows don't flicker in before being // hidden. The filter applies to the LIVE rows too: a running refresh-report is // hidden by the same chip that hides its finished siblings. export function JobsTable({ initial, history, total, embedded, }: { initial: ActiveJobsPayload; history: JobRowView[]; total: number; // The dashboard's PipelineBand: strip + rows only — no filter bar, no health // line, no empty paragraph; the band states running · queued itself. embedded?: boolean; }) { // null until mount → render everything (matches server HTML). const [filters, setFilters] = useState(null); useEffect(() => { setFilters(loadJobsFilters()); }, []); // Whether the head poll runs. It is `anyLive` below, carried in state // because the poll's result feeds the rows `anyLive` is derived from — the // hook has to be called before the rows exist. Seeded from the props, which // is exactly what `anyLive` is on the first render (no poll has landed). const [polling, setPolling] = useState( () => initial.jobs.some(isLive) || initial.recent.some(isLive) || history.some(isLive), ); const { data: polled } = usePolledPayload( "/api/view/activeJobs", polling, POLL_MS, null, ); // FRESHEST SNAPSHOT WINS. A /jobs render served from the router cache // (staleTimes.dynamic) can be OLDER than the client's last poll, so naively // adopting a new `initial` prop would show a finished job as running again. const live = polled && polled.builtAt >= initial.builtAt ? polled : initial; // The head is already ordered by the server: stuck, running, queued in queue // order, then whatever finished in the last half-minute. const head = useMemo(() => [...live.jobs, ...live.recent], [live]); const rows = useMemo(() => mergeJobRows(head, history), [head, history]); const liveIds = useMemo(() => new Set(head.map((r) => r.id)), [head]); // THE NowLive RULE (umtool's NowLive.tsx): "an idle page makes no requests". // The poll runs only while something is non-terminal, and `anyLive` is derived // from the props as well as the poll — so a refresh (the pulse, a navigation) // that brings a non-terminal row back restarts it. `polled` is never reset: // the last poll's `recent` rows are what keeps a job that just finished on // screen until the paged tail catches up. const anyLive = head.some(isLive) || history.some(isLive); // Adjust-state-during-render (React's documented pattern for state derived // from the previous render): React re-renders before committing, so the poll // starts and stops on the same render the old `useEffect(…, [anyLive])` did. if (polling !== anyLive) setPolling(anyLive); // Channel-less jobs by kind, so each lane's line can carry its own runner's // log link and controls. const byKind = useMemo(() => { const map = new Map(); for (const job of live.jobs) { if (job.channelSlug) continue; const list = map.get(job.kind) ?? []; list.push(job); map.set(job.kind, list); } return map; }, [live]); function update(next: JobsFilterState): void { setFilters(next); saveJobsFilters(next); } function toggleKind(kind: string): void { if (!filters) return; const hidden = filters.hiddenKinds.includes(kind) ? filters.hiddenKinds.filter((k) => k !== kind) : [...filters.hiddenKinds, kind]; update({ ...filters, hiddenKinds: hidden }); } function toggleStatus(status: string): void { if (!filters) return; const hidden = filters.hiddenStatuses.includes(status) ? filters.hiddenStatuses.filter((s) => s !== status) : [...filters.hiddenStatuses, status]; update({ ...filters, hiddenStatuses: hidden }); } function reset(): void { clearJobsFilters(); setFilters(defaultJobsFilters()); } const kinds = useMemo(() => { const set = new Set(); for (const j of rows) if (j.kind) set.add(j.kind); return Array.from(set).sort(); }, [rows]); const statuses = useMemo(() => { const set = new Set(); for (const j of rows) set.add(j.status); return Array.from(set).sort(); }, [rows]); const visible = useMemo(() => { if (!filters) return rows; const q = filters.search.trim().toLowerCase(); return rows.filter((j) => { if (j.kind && filters.hiddenKinds.includes(j.kind)) return false; if (filters.hiddenStatuses.includes(j.status)) return false; if (q) { const hay = [j.id, j.channelSlug, j.videoId] .filter((x): x is string => !!x) .join(" ") .toLowerCase(); if (!hay.includes(q)) return false; } return true; }); }, [rows, filters]); const hiddenCount = rows.length - visible.length; // A LANE IS NOT A JOB, so an empty work list is not an empty page: the strip // renders whether or not anything is running. const strip = ; // THE SCHEDULER'S HEALTH, in five figures, where the queue-diagnostics page // had them. "Reap stuck" rides this line rather than the header because its // visibility is a live fact from the poll, not something the SSR knew. const summary = live.summary; const health = embedded ? null : ( <>
{healthStat("queues", "active queues", String(summary.queues))} {healthStat("running", "running", String(summary.running))} {healthStat("queued", "queued", String(summary.queued))} {healthStat("stuck", "stuck", String(summary.stuck), summary.stuck > 0)} {healthStat( "workers", summary.workersPaused ? "workers (paused)" : "workers", String(summary.workerCount), summary.workersPaused, )}
{summary.stuck > 0 && }

{summary.stuck} stuck {summary.stuck === 1 ? "slot" : "slots"}

); if (!embedded && rows.length === 0 && total === 0) { return (
{strip} {health}

No jobs have run yet.

); } return (
{strip} {health} {!embedded && (
{kinds.length > 0 && (
kind {kinds.map((kind) => { const hidden = filters?.hiddenKinds.includes(kind) ?? false; return ( ); })}
)} {statuses.length > 0 && (
status {statuses.map((status) => { const hidden = filters?.hiddenStatuses.includes(status) ?? false; return ( ); })}
)}
filters && update({ ...filters, search: e.target.value }) } placeholder="Search id / channel / video…" aria-label="Search jobs" className="rounded border border-border bg-card px-2 py-1 text-sm" /> Showing {visible.length} of {rows.length} {hiddenCount > 0 && ` · ${hiddenCount} hidden`}
)}
{visible.map((j) => ( ))}
ID Kind Channel Video Queue Status Started Duration Log size
); }