Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/app/jobs/components/JobProgressBars.tsx | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Aeditor/app/jobs/components/JobRow.tsx | 530+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/JobsTable.tsx | 243+++----------------------------------------------------------------------------
Meditor/app/jobs/components/LaneStrip.tsx | 13+++++++------
Meditor/app/jobs/components/RunningJobsList.tsx | 131++++---------------------------------------------------------------------------
Aeditor/app/lib/formatElapsed.ts | 14++++++++++++++
Meditor/app/operations/components/dispatch.ts | 13++-----------
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(