"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 : (
<>