commit 4b391c6f9e8e32883550e235d4dd64861da65a0e
parent fb18dd2ded1ba3fa5de3e44dd5c18e58692d687a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 13:05:08 -0400
jobs: one JobRow (table | card | compact) + JobRowActions
`jobs/components/JobRow.tsx` draws a `JobRowView` in three sizes: the /jobs
<tr> (cells verbatim, with data-kind / data-job-id / data-live / data-stuck,
the min-w-56 status cell and its "In-progress operations" list, the
suppressHydrationWarning dates), the card <li> (heading, actions, Show log)
and the compact <div> the widget and the in-flight list will draw.
`JobRowActions` is the one status rule for Retry / Reorder / Drain / Cancel /
Force-release that JobsTable and RunningJobsList each spelled out;
`JobRowHeading`, `statusColor`, `queuePlace` and `fmtDuration` live beside it.
JobProgressBars gains `compact` (the widget's glyph labels, h-1.5 / h-1) and
uses the shared `useNow`; the full-form aria-labels are unchanged.
`formatElapsed` moves to lib/formatElapsed.ts (dispatch.ts re-exports it).
JobsTable's rows and RunningJobsList's cards are JobRows now. LaneStrip keeps
its lane line (section, h2, state, log link) and draws its runner job's
buttons with JobRowActions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 681 insertions(+), 404 deletions(-)
diff --git a/editor/app/jobs/components/JobProgressBars.tsx b/editor/app/jobs/components/JobProgressBars.tsx
@@ -1,30 +1,23 @@
"use client";
-import { useEffect, useState } from "react";
import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+import { useNow } from "../../lib/usePolledPayload";
import type {
JobProgressMetric,
JobTaskKind,
} from "yt-dlp-transcript-common/jobs/registry";
import type { JobRowTask, JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
-// The progress bars, shared by the card renderer (RunningJobsList) and the
-// /jobs table. The task bar's aria-label and its `gap-0.5` wrapper are what the
-// suite climbs from to read the elapsed timer, so they live in ONE place rather
-// than being restated by whichever surface draws them.
-
-// Live wall-clock that re-renders once a second on the client. Starts null so
-// the SSR and first client render agree (no hydration mismatch from Date.now());
-// the elapsed timers fill in right after mount.
-function useNow(): number | null {
- const [now, setNow] = useState<number | null>(null);
- useEffect(() => {
- setNow(Date.now());
- const id = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(id);
- }, []);
- return now;
-}
+// The progress bars, drawn by every job row (./JobRow.tsx): the /jobs table,
+// the card list, and β `compact` β the monitor widget and the in-flight lists.
+// The task bar's aria-label and its `gap-0.5` wrapper are what the suite climbs
+// from to read the elapsed timer, so they live in ONE place rather than being
+// restated by whichever surface draws them.
+//
+// COMPACT KEEPS THE WIDGET'S GLYPHS. A pinned widget reads "β 5/10", "β id";
+// the full form reads "Downloads: 5 / 10", "Transcribing id", and the suite
+// asserts the full form's names (`/Transcribing slowop/`), so the two label
+// sets are both kept, each on its own variant.
// Records over JobTaskKind, so adding a kind is a compile error here.
const TASK_KIND_VERB: Record<JobTaskKind, string> = {
@@ -43,12 +36,34 @@ const METRIC_FILL_BY_TASK: Record<JobTaskKind, string> = {
relocate: "bg-info/70",
};
-export function TaskProgressBar({ task }: { task: JobRowTask }) {
+// The compact bar's glyph per task kind, and the probing glyph.
+const TASK_KIND_GLYPH: Record<JobTaskKind, string> = {
+ download: "\u2193",
+ transcribe: "\u270e",
+ digest: "\u00b6",
+ backfill: "\u21ba",
+ // A right arrow: a relocate task is a channel's media moving to another disk.
+ relocate: "\u21e2",
+};
+
+export function TaskProgressBar({
+ task,
+ compact = false,
+}: {
+ task: JobRowTask;
+ compact?: boolean;
+}) {
// How long this task has been running. Null until mounted (see useNow);
// formatDuration returns "" for 0, so the just-started case shows "0:00".
const now = useNow();
const probing = task.phase === "probing";
- const verb = probing ? "Probing audio" : TASK_KIND_VERB[task.kind];
+ const verb = compact
+ ? probing
+ ? "π"
+ : TASK_KIND_GLYPH[task.kind]
+ : probing
+ ? "Probing audio"
+ : TASK_KIND_VERB[task.kind];
// While probing, fill against the estimated probe duration (a distinct violet
// "scanning" bar) rather than the frozen download fraction. yt-dlp is paused,
// so the download fraction wouldn't advance anyway. Falls back to an
@@ -83,17 +98,25 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) {
const meta = [
elapsed,
hasFraction ? `${pct}%` : null,
- probing ? "probing audioβ¦" : task.detail,
+ probing ? (compact ? "probing" : "probing audioβ¦") : task.detail,
]
.filter(Boolean)
.join(" Β· ");
return (
- <div className="flex flex-col gap-0.5">
- <div className="flex items-baseline justify-between gap-2 text-xs">
+ <div
+ className={
+ compact
+ ? "flex flex-col gap-0.5 pl-2 border-l-2 border-border"
+ : "flex flex-col gap-0.5"
+ }
+ >
+ <div
+ className={`flex items-baseline justify-between gap-2 ${compact ? "text-[11px]" : "text-xs"}`}
+ >
<span className="truncate">
<span className="text-muted-foreground">{verb} </span>
<span className="font-mono">{task.label}</span>
- {task.workerName && (
+ {!compact && task.workerName && (
<span className="text-muted-foreground">
{" "}
on <span className="font-mono">{task.workerName}</span>
@@ -104,11 +127,11 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) {
</div>
<div
role="progressbar"
- aria-valuemin={0}
- aria-valuemax={100}
+ aria-valuemin={compact ? undefined : 0}
+ aria-valuemax={compact ? undefined : 100}
aria-valuenow={hasFraction ? pct : undefined}
- aria-label={`${verb} ${task.label}`}
- className="relative h-1.5 w-full overflow-hidden rounded bg-muted"
+ aria-label={compact ? task.label : `${verb} ${task.label}`}
+ className={`relative ${compact ? "h-1" : "h-1.5"} w-full overflow-hidden rounded bg-muted`}
>
{hasFraction ? (
<div
@@ -137,6 +160,15 @@ const METRIC_LABELS: Record<JobProgressMetric, string> = {
scans: "Metadata scan",
};
+// Per-metric glyph for the compact line ("β 5/10").
+const METRIC_PREFIX: Record<JobProgressMetric, string> = {
+ downloads: "\u2193 ",
+ transcripts: "",
+ digests: "\u00b6 ",
+ backfills: "\u21ba ",
+ scans: "\u2315 ",
+};
+
const METRIC_FILL: Record<JobProgressMetric, string> = {
downloads: "bg-success/60",
transcripts: "bg-success",
@@ -145,11 +177,64 @@ const METRIC_FILL: Record<JobProgressMetric, string> = {
scans: "bg-info/60",
};
+// One-line textual summary of a job's batch progress, e.g. "β 5/10 Β· ~2m left"
+// β the compact heading (headingProgress) and the compact bar's caption.
+export function compactProgressText(
+ progress: NonNullable<JobRowView["progress"]>,
+ showEta: boolean,
+): string {
+ const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
+ const etaText = compactEta(progress, showEta);
+ return etaText ? `${label} Β· ${etaText}` : label;
+}
+
+function compactEta(
+ progress: NonNullable<JobRowView["progress"]>,
+ showEta: boolean,
+): string | null {
+ const remaining = progress.target - progress.current;
+ return !showEta || remaining <= 0
+ ? null
+ : typeof progress.etaSeconds === "number"
+ ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left`
+ : "β¦";
+}
+
export function JobProgressBar({
progress,
+ compact = false,
+ showEta = true,
}: {
progress: NonNullable<JobRowView["progress"]>;
+ compact?: boolean;
+ // Compact only: the widget's `eta` switch.
+ showEta?: boolean;
}) {
+ if (compact) {
+ const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
+ const etaText = compactEta(progress, showEta);
+ return (
+ <div className="flex flex-col gap-0.5">
+ <div
+ role="progressbar"
+ aria-valuemin={progress.initial}
+ aria-valuemax={progress.target}
+ aria-valuenow={progress.current}
+ aria-label={label}
+ className="relative h-1.5 w-full overflow-hidden rounded bg-muted"
+ >
+ <div
+ className="absolute inset-y-0 left-0 bg-success"
+ style={{ width: `${progress.pct}%` }}
+ />
+ </div>
+ <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground">
+ <span>{label}</span>
+ {etaText && <span className="shrink-0">{etaText}</span>}
+ </div>
+ </div>
+ );
+ }
const label = `${METRIC_LABELS[progress.metric]}: ${progress.current} / ${progress.target}`;
// Append an ETA once the batch has a measured average. formatDuration returns
// "" for 0/falsy, so guard against printing a bare "Β·".
diff --git a/editor/app/jobs/components/JobRow.tsx b/editor/app/jobs/components/JobRow.tsx
@@ -0,0 +1,530 @@
+"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
+// <tr>), the card list a page shows for its own jobs (`card`, an <li>), and
+// the dense lines of the monitor widget and the operations board's in-flight
+// list (`compact`, a <div>). 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
+};
+
+type Variant = "table" | "card" | "compact";
+
+const DEFAULTS: Record<Variant, JobRowShow> = {
+ table: {
+ tasks: true,
+ jobBar: true,
+ headingProgress: false,
+ eta: true,
+ actions: true,
+ links: true,
+ log: false,
+ elapsed: false,
+ },
+ card: {
+ tasks: true,
+ jobBar: true,
+ headingProgress: false,
+ eta: true,
+ actions: true,
+ links: true,
+ log: false,
+ elapsed: false,
+ },
+ compact: {
+ tasks: true,
+ jobBar: false,
+ headingProgress: false,
+ eta: false,
+ actions: false,
+ links: false,
+ log: false,
+ elapsed: false,
+ },
+};
+
+export 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.
+export 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";
+ }
+}
+
+// A row with a page at /jobs/<id>.
+function linksToJob(_job: JobRowView): boolean {
+ return true;
+}
+
+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 }: { job: JobRowView }) {
+ return (
+ <>
+ {job.status === "failed" && job.replayable && (
+ <RetryJobButton jobId={job.id} />
+ )}
+ {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && (
+ <ReorderJobButtons
+ jobId={job.id}
+ canMoveUp={job.canMoveUp}
+ canMoveDown={job.canMoveDown}
+ />
+ )}
+ {job.status === "running" && (job.drainable || job.draining) && (
+ <DrainJobButton jobId={job.id} draining={job.draining} />
+ )}
+ {(job.status === "running" || job.status === "queued") && (
+ <CancelJobButton jobId={job.id} />
+ )}
+ {(job.status === "running" || Boolean(job.stuck)) && (
+ <ForceReleaseJobButton jobId={job.id} />
+ )}
+ </>
+ );
+}
+
+function AutoBadge({ compact }: { compact?: boolean }) {
+ return (
+ <span
+ className={
+ compact
+ ? "uppercase tracking-wide px-1 py-0.5 rounded text-[9px] bg-warning-soft text-warning"
+ : "text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
+ }
+ title="Background work β yields to a manually-triggered sync/download on the same platform"
+ >
+ auto
+ </span>
+ );
+}
+
+// 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.
+export function JobRowHeading({
+ job,
+ compact = false,
+ links = true,
+ hideChannelSlug,
+ hideVideoId,
+ headingProgress = false,
+ eta = false,
+}: {
+ 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 (
+ <>
+ <span
+ className={
+ compact
+ ? `uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusColor(job.status)}`
+ : `text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(job.status)}`
+ }
+ >
+ {job.status}
+ </span>
+ <span
+ className={compact ? "font-mono" : "text-xs font-medium"}
+ title={job.kind}
+ >
+ {job.kind ? jobKindLabel(job.kind) : "β"}
+ </span>
+ {job.detail && (
+ <span className="text-xs text-muted-foreground">Β· {job.detail}</span>
+ )}
+ {job.background && <AutoBadge compact={compact} />}
+ {links && linksToJob(job) && (
+ <Link href={`/jobs/${job.id}`} className={linkClass}>
+ Job {job.id}
+ </Link>
+ )}
+ {!hideChannelSlug &&
+ job.channelSlug &&
+ (links ? (
+ <Link href={`/channels/${job.channelSlug}`} className={linkClass}>
+ {job.channelSlug}
+ </Link>
+ ) : (
+ <span className="font-mono text-muted-foreground truncate max-w-[12rem]">
+ {job.channelSlug}
+ </span>
+ ))}
+ {links && !hideVideoId && video && (
+ <Link href={video} className={linkClass}>
+ {job.videoId}
+ </Link>
+ )}
+ {headingProgress && job.progress && (
+ <span className="font-mono text-muted-foreground shrink-0">
+ {compactProgressText(job.progress, eta)}
+ </span>
+ )}
+ {!compact && job.status === "queued" && (
+ <span className="text-xs text-muted-foreground">
+ queue <code className="font-mono">{job.queueKey || "β"}</code>
+ {job.background && " Β· waiting behind a manual job"}
+ </span>
+ )}
+ </>
+ );
+}
+
+function TaskList({ job, compact }: { job: JobRowView; compact?: boolean }) {
+ if (!job.tasks || job.tasks.length === 0) return null;
+ if (compact)
+ return (
+ <>
+ {job.tasks.map((t) => (
+ <TaskProgressBar key={t.id} task={t} compact />
+ ))}
+ </>
+ );
+ return (
+ <ul
+ aria-label="In-progress operations"
+ className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
+ >
+ {job.tasks.map((t) => (
+ <li key={t.id}>
+ <TaskProgressBar task={t} />
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+export function JobRow({
+ job,
+ variant,
+ hideChannelSlug,
+ hideVideoId,
+ show: showPatch,
+ live = false,
+ now = null,
+}: {
+ job: JobRowView;
+ variant: Variant;
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+ show?: Partial<JobRowShow>;
+ // 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 <TableRow job={job} live={live} now={now} />;
+ if (variant === "card")
+ return (
+ <CardRow
+ job={job}
+ show={show}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ />
+ );
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex flex-wrap items-center gap-1.5 text-xs">
+ <JobRowHeading
+ job={job}
+ compact
+ links={show.links}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ headingProgress={show.headingProgress}
+ eta={show.eta}
+ />
+ {(show.actions || show.elapsed) && (
+ <span className="ml-auto flex items-center gap-2">
+ {show.actions && <JobRowActions job={job} />}
+ {show.elapsed && (
+ <span className="tabular-nums text-xs text-muted-foreground">
+ {now !== null && typeof job.startedAt === "number"
+ ? formatElapsed(now - job.startedAt)
+ : "β"}
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ {show.jobBar && job.progress && (
+ <JobProgressBar progress={job.progress} compact showEta={show.eta} />
+ )}
+ {show.tasks && <TaskList job={job} compact />}
+ </div>
+ );
+}
+
+// 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 (
+ <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0">
+ <div className="flex flex-wrap items-center gap-2 text-sm">
+ <JobRowHeading
+ job={job}
+ links={show.links}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ />
+ {(show.actions || show.log) && (
+ <div className="ml-auto flex items-center gap-2">
+ {show.actions && <JobRowActions job={job} />}
+ {show.log && (
+ <button
+ type="button"
+ onClick={() => setShowLog((s) => !s)}
+ aria-expanded={showLog}
+ aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`}
+ className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90"
+ >
+ {showLog ? "Hide log" : "Show log"}
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ {show.jobBar && job.progress && <JobProgressBar progress={job.progress} />}
+ {show.tasks && <TaskList job={job} />}
+ {showLog && <JobLogTail jobId={job.id} initiallyRunning />}
+ </li>
+ );
+}
+
+// THE TABLE ROW: /jobs, one <tr> 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 (
+ <tr
+ // The kind cell renders a human label (jobKindLabel), so the machine
+ // kind is not matchable from the row text. Expose it here as well as on
+ // the cell's title: asserting on the label text is what rotted 9 e2e
+ // tests when kinds gained labels.
+ data-kind={j.kind || undefined}
+ data-job-id={j.id}
+ // Came from the live payload β the SSR seed or the poll.
+ data-live={live ? "" : undefined}
+ data-stuck={j.stuck?.reason}
+ className="border-t border-border"
+ >
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ <Link href={`/jobs/${j.id}`} className="underline hover:text-foreground">
+ {j.id}
+ </Link>
+ </td>
+ <td className="px-3 py-2 align-top" title={j.kind || undefined}>
+ <span className="flex items-center gap-1.5">
+ {j.kind ? jobKindLabel(j.kind) : "β"}
+ {j.detail && (
+ <span className="text-xs text-muted-foreground">Β· {j.detail}</span>
+ )}
+ {j.background && <AutoBadge />}
+ </span>
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {j.channelSlug ? (
+ <Link
+ href={`/channels/${j.channelSlug}`}
+ className="underline hover:text-foreground"
+ >
+ {j.channelSlug}
+ </Link>
+ ) : (
+ "β"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {video ? (
+ <Link href={video} className="underline hover:text-foreground">
+ {j.videoId}
+ </Link>
+ ) : (
+ "β"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {j.queueKey === undefined
+ ? "β"
+ : j.queueKey === ""
+ ? "(immediate)"
+ : j.queueKey}
+ {j.status === "queued" &&
+ typeof j.position === "number" &&
+ j.position >= 1 && (
+ <span className="text-muted-foreground">
+ {" "}
+ Β· {queuePlace(j.position)}
+ </span>
+ )}
+ </td>
+ <td className="px-3 py-2 align-top min-w-56">
+ {/* 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. */}
+ <div className="flex flex-col gap-1">
+ <span
+ className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
+ >
+ {j.status}
+ </span>
+ {j.stuck && (
+ <span
+ data-stuck={j.stuck.reason}
+ title={j.pid ? `pid ${j.pid} β kill -9 ${j.pid}` : undefined}
+ className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive"
+ >
+ stuck Β· {j.stuck.reason}
+ </span>
+ )}
+ {j.progress && <JobProgressBar progress={j.progress} />}
+ <TaskList job={j} />
+ </div>
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
+ suppressHydrationWarning
+ >
+ {start === undefined ? "β" : new Date(start).toLocaleString()}
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
+ suppressHydrationWarning
+ >
+ {j.startedAt === undefined && typeof j.heldMs === "number"
+ ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}`
+ : fmtDuration(dur)}
+ </td>
+ <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top">
+ {j.stuck && j.lastLogLine ? (
+ <span
+ className="font-mono text-xs text-muted-foreground truncate max-w-64 block"
+ title={j.lastLogLine}
+ >
+ {j.lastLogLine}
+ </span>
+ ) : typeof j.logSize === "number" ? (
+ `${j.logSize.toLocaleString()} B`
+ ) : (
+ "β"
+ )}
+ </td>
+ <td className="px-3 py-2 text-right align-top">
+ <div className="flex items-center justify-end gap-2">
+ <JobRowActions job={j} />
+ </div>
+ </td>
+ </tr>
+ );
+}
diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx
@@ -1,21 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import Link from "next/link";
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 { CancelJobButton } from "./CancelJobButton";
-import { DrainJobButton } from "./DrainJobButton";
-import { ForceReleaseJobButton } from "./ForceReleaseJobButton";
-import { ReorderJobButtons } from "./ReorderJobButtons";
-import { RetryJobButton } from "./RetryJobButton";
import { ReapStuckButton } from "./ReapStuckButton";
import { LaneStrip } from "./LaneStrip";
-import { JobProgressBar, TaskProgressBar } from "./JobProgressBars";
+import { JobRow, statusColor } from "./JobRow";
import { jobKindLabel } from "../jobKindLabels";
-import { formatDuration } from "yt-dlp-transcript-common/lib/format";
import {
clearJobsFilters,
defaultJobsFilters,
@@ -28,32 +21,6 @@ import {
// per-task progress bars still advance smoothly.
const POLL_MS = 1000;
-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`;
-}
-
// 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(
@@ -76,23 +43,6 @@ function healthStat(
);
}
-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";
- }
-}
-
// ONE LIST.
//
// The live head is what the registry and the scheduler say NOW, polled at 1 s
@@ -382,189 +332,14 @@ export function JobsTable({
</tr>
</thead>
<tbody>
- {visible.map((j) => {
- const start = j.startedAt ?? j.queuedAt;
- const dur = j.endedAt
- ? j.endedAt - (start ?? j.endedAt)
- : j.startedAt
- ? Date.now() - j.startedAt
- : 0;
- return (
- <tr
- key={j.id}
- // The kind cell renders a human label (jobKindLabel), so the
- // machine kind is not matchable from the row text. Expose it
- // here as well as on the cell's title: asserting on the label
- // text is what rotted 9 e2e tests when kinds gained labels.
- data-kind={j.kind || undefined}
- data-job-id={j.id}
- // Came from the live payload β the SSR seed or the poll.
- data-live={liveIds.has(j.id) ? "" : undefined}
- data-stuck={j.stuck?.reason}
- className="border-t border-border"
- >
- <td className="px-3 py-2 font-mono text-xs align-top">
- <Link
- href={`/jobs/${j.id}`}
- className="underline hover:text-foreground"
- >
- {j.id}
- </Link>
- </td>
- <td className="px-3 py-2 align-top" title={j.kind || undefined}>
- <span className="flex items-center gap-1.5">
- {j.kind ? jobKindLabel(j.kind) : "β"}
- {/* See RunningJobsList: the kind alone cannot say which
- tool asked for which clip. */}
- {j.detail && (
- <span className="text-xs text-muted-foreground">
- Β· {j.detail}
- </span>
- )}
- {j.background && (
- <span
- className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
- title="Background work β yields to a manually-triggered sync/download on the same platform"
- >
- auto
- </span>
- )}
- </span>
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.channelSlug ? (
- <Link
- href={`/channels/${j.channelSlug}`}
- className="underline hover:text-foreground"
- >
- {j.channelSlug}
- </Link>
- ) : (
- "β"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.channelSlug && j.videoId ? (
- <Link
- href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`}
- className="underline hover:text-foreground"
- >
- {j.videoId}
- </Link>
- ) : (
- "β"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.queueKey === undefined
- ? "β"
- : j.queueKey === ""
- ? "(immediate)"
- : j.queueKey}
- {j.status === "queued" &&
- typeof j.position === "number" &&
- j.position >= 1 && (
- <span className="text-muted-foreground">
- {" "}
- Β· {queuePlace(j.position)}
- </span>
- )}
- </td>
- <td className="px-3 py-2 align-top min-w-56">
- {/* 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. */}
- <div className="flex flex-col gap-1">
- <span
- className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
- >
- {j.status}
- </span>
- {j.stuck && (
- <span
- data-stuck={j.stuck.reason}
- title={
- j.pid ? `pid ${j.pid} β kill -9 ${j.pid}` : undefined
- }
- className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive"
- >
- stuck Β· {j.stuck.reason}
- </span>
- )}
- {j.progress && <JobProgressBar progress={j.progress} />}
- {j.tasks && j.tasks.length > 0 && (
- <ul
- aria-label="In-progress operations"
- className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
- >
- {j.tasks.map((t) => (
- <li key={t.id}>
- <TaskProgressBar task={t} />
- </li>
- ))}
- </ul>
- )}
- </div>
- </td>
- <td
- className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
- suppressHydrationWarning
- >
- {start === undefined ? "β" : new Date(start).toLocaleString()}
- </td>
- <td
- className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
- suppressHydrationWarning
- >
- {j.startedAt === undefined && typeof j.heldMs === "number"
- ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}`
- : fmtDuration(dur)}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top">
- {j.stuck && j.lastLogLine ? (
- <span
- className="font-mono text-xs text-muted-foreground truncate max-w-64 block"
- title={j.lastLogLine}
- >
- {j.lastLogLine}
- </span>
- ) : typeof j.logSize === "number" ? (
- `${j.logSize.toLocaleString()} B`
- ) : (
- "β"
- )}
- </td>
- <td className="px-3 py-2 text-right align-top">
- <div className="flex items-center justify-end gap-2">
- {j.status === "failed" && j.replayable && (
- <RetryJobButton jobId={j.id} />
- )}
- {j.status === "queued" && (j.canMoveUp || j.canMoveDown) && (
- <ReorderJobButtons
- jobId={j.id}
- canMoveUp={j.canMoveUp}
- canMoveDown={j.canMoveDown}
- />
- )}
- {j.status === "running" && (j.drainable || j.draining) && (
- <DrainJobButton jobId={j.id} draining={j.draining} />
- )}
- {(j.status === "running" || j.status === "queued") && (
- <CancelJobButton jobId={j.id} />
- )}
- {(j.status === "running" || Boolean(j.stuck)) && (
- // Escape hatch: force-free the scheduler slot for any
- // running job, for when a Cancel leaves the queue wedged
- // (see registry.forceRelease). A stuck terminal/evicted
- // row has no Cancel to offer, so it gets this instead.
- <ForceReleaseJobButton jobId={j.id} />
- )}
- </div>
- </td>
- </tr>
- );
- })}
+ {visible.map((j) => (
+ <JobRow
+ key={j.id}
+ job={j}
+ variant="table"
+ live={liveIds.has(j.id)}
+ />
+ ))}
</tbody>
</table>
</div>
diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx
@@ -4,8 +4,7 @@ import Link from "next/link";
import type { ActiveLaneView } from "yt-dlp-transcript-common/views/activeJobs";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
import { LANE_DOT, LANE_TEXT, LANE_WORD } from "yt-dlp-transcript-common/views/laneState";
-import { DrainJobButton } from "./DrainJobButton";
-import { CancelJobButton } from "./CancelJobButton";
+import { JobRowActions } from "./JobRow";
// THE LANE STRIP. One line per pipeline, in place of one card per runner.
//
@@ -14,6 +13,11 @@ import { CancelJobButton } from "./CancelJobButton";
// a line instead of a card, and that it now says WHY it is not working. Four
// bordered cards saying "running" above the work with the progress bars is the
// layout this replaces.
+//
+// A LANE IS NOT A JOB, so this is not a JobRow: it keeps its own line (the
+// suite pins the section, the heading and the Drain/Cancel on it). What it
+// shares with every job row is the actions β its runner job's buttons are
+// JobRowActions, the same status rules as /jobs and the card list.
export function LaneStrip({
lanes,
jobs,
@@ -77,10 +81,7 @@ function LaneRow({
>
log
</Link>
- {job.drainable && (
- <DrainJobButton jobId={job.id} draining={job.draining} />
- )}
- <CancelJobButton jobId={job.id} />
+ <JobRowActions job={job} />
</span>
)}
</section>
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -1,15 +1,7 @@
"use client";
-import Link from "next/link";
-import { useState } from "react";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
-import { JobLogTail } from "../[id]/components/JobLogTail";
-import { jobKindLabel } from "../jobKindLabels";
-import { DrainJobButton } from "./DrainJobButton";
-import { CancelJobButton } from "./CancelJobButton";
-import { ForceReleaseJobButton } from "./ForceReleaseJobButton";
-import { ReorderJobButtons } from "./ReorderJobButtons";
-import { JobProgressBar, TaskProgressBar } from "./JobProgressBars";
+import { JobRow } from "./JobRow";
type Props = {
jobs: JobRowView[];
@@ -17,9 +9,9 @@ type Props = {
hideVideoId?: boolean;
};
-// THE CARD RENDERER for a page's own jobs (channel, video, build, operation
-// pages). /jobs draws the same JobRowView as a table row; both read the one
-// type, and both get their rows from the one builder (liveJobRows).
+// THE CARD LIST for a page's own jobs (channel, video, build, operation
+// pages). /jobs draws the same JobRowView as a table row; both are ./JobRow.tsx,
+// and both get their rows from the one builder (liveJobRows).
export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
if (jobs.length === 0) return null;
return (
@@ -35,6 +27,8 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
<JobRow
key={j.id}
job={j}
+ variant="card"
+ show={{ log: true }}
hideChannelSlug={hideChannelSlug}
hideVideoId={hideVideoId}
/>
@@ -43,116 +37,3 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
</section>
);
}
-
-function JobRow({
- job,
- hideChannelSlug,
- hideVideoId,
-}: {
- job: JobRowView;
- hideChannelSlug?: boolean;
- hideVideoId?: boolean;
-}) {
- const [showLog, setShowLog] = useState(false);
- const statusClass =
- job.status === "running"
- ? "bg-info-soft text-info"
- : "bg-muted text-muted-foreground";
- return (
- <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0">
- <div className="flex flex-wrap items-center gap-2 text-sm">
- <span
- className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusClass}`}
- >
- {job.status}
- </span>
- <span className="text-xs font-medium" title={job.kind}>
- {jobKindLabel(job.kind)}
- </span>
- {/* WHO ASKED, beside the kind. "Fetch window" alone is the least
- useful row on the page: every window fetch has that kind, and the
- question is which tool wanted which clip. */}
- {job.detail && (
- <span className="text-xs text-muted-foreground">Β· {job.detail}</span>
- )}
- {job.background && (
- <span
- className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
- title="Background work β yields to a manually-triggered sync/download on the same platform"
- >
- auto
- </span>
- )}
- <Link
- href={`/jobs/${job.id}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- Job {job.id}
- </Link>
- {!hideChannelSlug && job.channelSlug && (
- <Link
- href={`/channels/${job.channelSlug}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- {job.channelSlug}
- </Link>
- )}
- {!hideVideoId && job.channelSlug && job.videoId && (
- <Link
- href={`/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- {job.videoId}
- </Link>
- )}
- {job.status === "queued" && (
- <span className="text-xs text-muted-foreground">
- queue <code className="font-mono">{job.queueKey || "β"}</code>
- {job.background && " Β· waiting behind a manual job"}
- </span>
- )}
- <div className="ml-auto flex items-center gap-2">
- {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && (
- <ReorderJobButtons
- jobId={job.id}
- canMoveUp={job.canMoveUp}
- canMoveDown={job.canMoveDown}
- />
- )}
- {job.status === "running" && (job.drainable || job.draining) && (
- <DrainJobButton jobId={job.id} draining={job.draining} />
- )}
- <CancelJobButton jobId={job.id} />
- {job.status === "running" && (
- // Escape hatch: force-free the scheduler slot for any running job,
- // for when a Cancel leaves the queue wedged (see registry.forceRelease).
- <ForceReleaseJobButton jobId={job.id} />
- )}
- <button
- type="button"
- onClick={() => setShowLog((s) => !s)}
- aria-expanded={showLog}
- aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`}
- className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90"
- >
- {showLog ? "Hide log" : "Show log"}
- </button>
- </div>
- </div>
- {job.progress && <JobProgressBar progress={job.progress} />}
- {job.tasks && job.tasks.length > 0 && (
- <ul
- aria-label="In-progress operations"
- className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
- >
- {job.tasks.map((t) => (
- <li key={t.id}>
- <TaskProgressBar task={t} />
- </li>
- ))}
- </ul>
- )}
- {showLog && <JobLogTail jobId={job.id} initiallyRunning />}
- </li>
- );
-}
diff --git a/editor/app/lib/formatElapsed.ts b/editor/app/lib/formatElapsed.ts
@@ -0,0 +1,14 @@
+// A running clock, compactly: "42s", "3m07s", "2h05m". The operations board's
+// runner header and every in-flight row read it. (Not formatDuration's
+// "3:07": that one is a media duration, and it renders 0 as "".)
+export function formatElapsed(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) {
+ const rem = s % 60;
+ return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`;
+ }
+ const h = Math.floor(m / 60);
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
+}
diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts
@@ -185,17 +185,8 @@ export function formatClock(ms: number): string {
// Compact elapsed time: 42s, 3m21s, 14m, 2h06m. Used for uptime and per-unit
// age, where a full duration string would swamp the line it sits on.
-export function formatElapsed(ms: number): string {
- const s = Math.max(0, Math.floor(ms / 1000));
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- if (m < 60) {
- const rem = s % 60;
- return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`;
- }
- const h = Math.floor(m / 60);
- return `${h}h${String(m % 60).padStart(2, "0")}m`;
-}
+// Moved to lib/formatElapsed.ts, where the job row reads it too.
+export { formatElapsed } from "../../lib/formatElapsed";
// A recency key as a date, marked when it was estimated rather than read.
export function formatRecency(