"use client"; import Link from "next/link"; import { useState } from "react"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { formatDuration } from "yt-dlp-transcript-common/lib/format"; import { JobLogTail } from "../[id]/components/JobLogTail"; import { jobKindLabel } from "../jobKindLabels"; import { formatElapsed } from "../../lib/formatElapsed"; import { CancelJobButton } from "./CancelJobButton"; import { DrainJobButton } from "./DrainJobButton"; import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; import { ReorderJobButtons } from "./ReorderJobButtons"; import { RetryJobButton } from "./RetryJobButton"; import { JobProgressBar, TaskProgressBar, compactProgressText, } from "./JobProgressBars"; // ONE JOB, DRAWN ONE WAY — in three sizes. // // Every surface that shows a job in flight draws a `JobRowView` // (common/views/jobRowView.ts) through this file: the /jobs table (`table`, a // ), the card list a page shows for its own jobs (`card`, an
  • ), and // the dense lines of the monitor widget and the operations board's in-flight // list (`compact`, a
    ). The heading, the status colours, the action // buttons and the progress bars are this file's, once; a variant decides only // the container and which parts it `show`s. // Which parts a row draws. Every flag defaults per variant (see DEFAULTS). export type JobRowShow = { tasks: boolean; // the per-task progress bars jobBar: boolean; // the batch progress bar headingProgress: boolean; // compact: "↓ 5/10" in the heading line eta: boolean; // compact: "~2m left" beside the batch figure actions: boolean; // JobRowActions links: boolean; // Job / channel / video links (compact: plain text when off) log: boolean; // card: the Show log toggle + tail elapsed: boolean; // compact: "3m07s" since startedAt, at the line's end statusPill: boolean; // off where every row is running by construction }; type Variant = "table" | "card" | "compact"; const DEFAULTS: Record = { table: { tasks: true, jobBar: true, headingProgress: false, eta: true, actions: true, links: true, log: false, elapsed: false, statusPill: true, }, card: { tasks: true, jobBar: true, headingProgress: false, eta: true, actions: true, links: true, log: false, elapsed: false, statusPill: true, }, compact: { tasks: true, jobBar: false, headingProgress: false, eta: false, actions: false, links: false, log: false, elapsed: false, statusPill: true, }, }; function fmtDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(1)}s`; const m = s / 60; return `${m.toFixed(1)}m`; } // "next in line" reads better than "1st"; past that, an ordinal. function queuePlace(position: number): string { if (position === 1) return "next in line"; const rem100 = position % 100; const rem10 = position % 10; const suffix = rem100 >= 11 && rem100 <= 13 ? "th" : rem10 === 1 ? "st" : rem10 === 2 ? "nd" : rem10 === 3 ? "rd" : "th"; return `${position}${suffix} in line`; } export function statusColor(status: string): string { switch (status) { case "queued": return "bg-muted text-muted-foreground"; case "running": return "bg-info-soft text-info"; case "done": return "bg-success-soft text-success"; case "failed": return "bg-destructive-soft text-destructive"; case "cancelled": return "bg-warning-soft text-warning"; default: return "bg-muted text-muted-foreground"; } } // WHY A JOB WAS CANCELLED, when no person pressed Cancel: the boot pass's // reason for a job a restart left queued (JobRowView.cancelReason, from the // meta). Drawn wherever the `cancelled` pill is, as plain text under or beside // it; absent on every other row. function cancelReasonOf(job: JobRowView): string | undefined { return job.status === "cancelled" ? job.cancelReason : undefined; } function CancelReason({ job, inline }: { job: JobRowView; inline?: boolean }) { const reason = cancelReasonOf(job); if (!reason) return null; return ( {inline ? `· ${reason}` : reason} ); } // A row with a page at /jobs/. A runner's in-flight unit that is a task // on its runner's job (a transcription, a digest) is not a registry job, so it // does not link to one; a download unit is, and carries its id. function linksToJob(job: JobRowView): boolean { return job.source !== "runner" || Boolean(job.inRegistry); } function videoHref(job: JobRowView): string | null { return job.channelSlug && job.videoId ? `/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}` : null; } // THE ACTIONS, by status — one rule for every surface: Retry a failed // replayable job, Reorder a queued one, Drain a drainable running one, Cancel // anything live, and Force-release anything running or stuck (the escape hatch // for a Cancel that leaves a queue wedged; see registry.forceRelease — a stuck // terminal/evicted row has no Cancel to offer, so it gets this instead). export function JobRowActions({ job, forceRelease = "running-or-stuck", }: { job: JobRowView; // The lane line offers Force-release only on a STUCK runner: for a healthy // one it does what Cancel beside it does, as a second, filled-red button. forceRelease?: "running-or-stuck" | "stuck"; }) { return ( <> {job.status === "failed" && job.replayable && ( )} {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && ( )} {job.status === "running" && (job.drainable || job.draining) && ( )} {(job.status === "running" || job.status === "queued") && ( )} {((forceRelease === "running-or-stuck" && job.status === "running") || Boolean(job.stuck)) && } ); } function AutoBadge({ compact }: { compact?: boolean }) { return ( auto ); } // THE HEADING: status pill · kind · detail · auto badge · links · queue key. // "Fetch window" alone is the least useful row on a page — every window fetch // has that kind — so WHO ASKED (`detail`) sits beside the kind. function JobRowHeading({ job, compact = false, links = true, hideChannelSlug, hideVideoId, headingProgress = false, eta = false, statusPill = true, }: { statusPill?: boolean; job: JobRowView; compact?: boolean; links?: boolean; hideChannelSlug?: boolean; hideVideoId?: boolean; headingProgress?: boolean; eta?: boolean; }) { const linkClass = "font-mono text-xs underline hover:text-foreground"; const video = videoHref(job); return ( <> {statusPill && ( {job.status} )} {job.kind ? jobKindLabel(job.kind) : "—"} {job.detail && (compact ? ( // One line, always: a pinned widget and an in-flight list are // narrow, and "rule N · " would wrap into a paragraph. · {job.detail} ) : ( · {job.detail} ))} {job.background && } {links && linksToJob(job) && ( Job {job.id} )} {!hideChannelSlug && job.channelSlug && (links ? ( {job.channelSlug} ) : ( {job.channelSlug} ))} {links && !hideVideoId && video && ( {job.videoId} )} {headingProgress && job.progress && ( {compactProgressText(job.progress, eta)} )} {!compact && job.status === "queued" && ( queue {job.queueKey || "—"} {job.background && " · waiting behind a manual job"} )} {/* Its counterpart for a cancelled job: why. Compact rows carry it as the pill's title instead — they are one line, always. */} {!compact && } ); } function TaskList({ job, compact }: { job: JobRowView; compact?: boolean }) { if (!job.tasks || job.tasks.length === 0) return null; if (compact) return ( <> {job.tasks.map((t) => ( ))} ); return (
      {job.tasks.map((t) => (
    • ))}
    ); } export function JobRow({ job, variant, hideChannelSlug, hideVideoId, show: showPatch, live = false, now = null, }: { job: JobRowView; variant: Variant; hideChannelSlug?: boolean; hideVideoId?: boolean; show?: Partial; // table: the row came from the live payload (SSR seed or poll) — `data-live`. live?: boolean; // The ticking clock (useNow), for elapsed and running durations. null until // mounted; the table falls back to Date.now() behind suppressHydrationWarning, // as it always has. now?: number | null; }) { const show = { ...DEFAULTS[variant], ...showPatch }; if (variant === "table") return ; if (variant === "card") return ( ); return (
    {(show.actions || show.elapsed) && ( {show.actions && } {show.elapsed && ( {now !== null && typeof job.startedAt === "number" ? formatElapsed(now - job.startedAt) : "—"} )} )}
    {show.jobBar && job.progress && ( )} {show.tasks && }
    ); } // THE CARD: a page's own jobs (channel, video, build, operation pages). function CardRow({ job, show, hideChannelSlug, hideVideoId, }: { job: JobRowView; show: JobRowShow; hideChannelSlug?: boolean; hideVideoId?: boolean; }) { const [showLog, setShowLog] = useState(false); return (
  • {(show.actions || show.log) && (
    {show.actions && } {show.log && ( )}
    )}
    {show.jobBar && job.progress && ( )} {show.tasks && } {showLog && }
  • ); } // THE TABLE ROW: /jobs, one per job, live head and history tail alike. function TableRow({ job: j, live, now, }: { job: JobRowView; live: boolean; now: number | null; }) { const start = j.startedAt ?? j.queuedAt; const dur = j.endedAt ? j.endedAt - (start ?? j.endedAt) : j.startedAt ? (now ?? Date.now()) - j.startedAt : 0; const video = videoHref(j); return ( {j.id} {j.kind ? jobKindLabel(j.kind) : "—"} {j.detail && ( · {j.detail} )} {j.background && } {j.channelSlug ? ( {j.channelSlug} ) : ( "—" )} {video ? ( {j.videoId} ) : ( "—" )} {j.queueKey === undefined ? "—" : j.queueKey === "" ? "(immediate)" : j.queueKey} {j.status === "queued" && typeof j.position === "number" && j.position >= 1 && ( {" "} · {queuePlace(j.position)} )} {/* gap-1, deliberately NOT gap-0.5: the suite climbs from a task's progressbar to its nearest gap-0.5 ancestor to read the elapsed timer, and that ancestor must stay the task's own wrapper. */}
    {j.status} {j.stuck && ( stuck · {j.stuck.reason} )} {j.progress && }
    {start === undefined ? "—" : new Date(start).toLocaleString()} {j.startedAt === undefined && typeof j.heldMs === "number" ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}` : fmtDuration(dur)} {j.stuck && j.lastLogLine ? ( {j.lastLogLine} ) : typeof j.logSize === "number" ? ( `${j.logSize.toLocaleString()} B` ) : ( "—" )}
    ); }