Archilyzer · Source

archilyzer

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

commit e67b93cc417f615789bc6d0ed3140cfafef696ee
parent 1a25532587488bbc2fee55caaa475a1b21bd70cf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 30 Aug 2026 11:20:24 -0400

jobs: one list — the live head is polled, the history tail is paged

/jobs and /jobs/active were the same jobs twice: the directory listing had no
idea what was running, and the live screen filtered to running and queued and
therefore hid everything that had just finished. Opening one meant guessing
which of the two knew.

They are one table now. The head is what the registry says NOW, polled at a
second while any row is non-terminal and idle otherwise; the tail is the paged
directory listing the pulse already refreshes. They merge by id, live wins, one
<tr> per job. A running row carries its progress bars, its ETA and its per-task
timers in the Status cell; a queued row says where it is in line.

Because a page served from the router cache can be older than the client's last
poll, the payload carries builtAt and the table renders whichever snapshot is
newer — so a job that just finished never regresses to running.

The lane strip and the progress bars move out into components of their own,
byte for byte: the card renderer and the table draw the same ones. /jobs/active
redirects, the sidebar's Active entry goes with it, and the dashboard's pipeline
band embeds the same list with no tail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/pauseGates.ts | 4++--
Mcommon/ytdlp/downloadOneManaged.ts | 2+-
Meditor/app/api/jobs/active/route.ts | 4++--
Meditor/app/components/SidebarBadges.tsx | 21+++++----------------
Meditor/app/components/dashboard/PipelineBand.tsx | 9+++++++--
Meditor/app/components/lanes/pauseControl.tsx | 2+-
Deditor/app/jobs/active/page.tsx | 46----------------------------------------------
Deditor/app/jobs/components/ActiveJobsLive.tsx | 207-------------------------------------------------------------------------------
Aeditor/app/jobs/components/JobProgressBars.tsx | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/JobsTable.tsx | 398++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Aeditor/app/jobs/components/LaneStrip.tsx | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/RunningJobsList.tsx | 177++-----------------------------------------------------------------------------
Meditor/app/jobs/jobRows.ts | 6+++---
Meditor/app/jobs/page.tsx | 64+++++++++++++++++++++++++++++++++++++---------------------------
Meditor/app/layout.tsx | 8++------
Meditor/app/lib/nav.ts | 8+++-----
Meditor/e2e/audio-check-scenarios.spec.ts | 2+-
Meditor/e2e/auto-queue.spec.ts | 18++++++++++--------
Meditor/e2e/jobs-active-order.spec.ts | 26++++++++++----------------
Meditor/e2e/jobs-batch-tasks-drain.spec.ts | 72++++++++++++++++++++++++++++++++++++++++++++----------------------------
Meditor/e2e/jobs-reorder.spec.ts | 32++++++++++++++++----------------
Meditor/e2e/navigation.spec.ts | 9+++++++++
Meditor/e2e/parakeet-partial.spec.ts | 4++--
Meditor/e2e/workers.spec.ts | 31+++++++++++++++++--------------
Meditor/next.config.ts | 11+++++++++--
25 files changed, 744 insertions(+), 686 deletions(-)

diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts @@ -29,8 +29,8 @@ import { // * the flag is read in exactly two places — the boot hook, and the action's // "did this change anything" check before it writes; // * NO UI SURFACE MAY READ THE FLAG for "is it held". Every one of them -// (the dashboard deck, /workers, /jobs/active, the widget, the queue view, -// /api/pulse, /api/worker/health) reads the pool. The e2e harness rewrites +// (the dashboard deck, /workers, /jobs, the widget, /api/pulse, +// /api/worker/health) reads the pool. The e2e harness rewrites // test-settings.json wholesale between tests while the pool keeps its // pausedSnapshot, so a surface reading the flag would disagree with the // machine it is describing. diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -70,7 +70,7 @@ const ARCHIVE_MARKER = "DLOM_ARCHIVE"; // full output and emit a structured, throttled progress line we can parse // directly into the per-video progress bars (human log readability is secondary // to reliable progress parsing). --progress-delta keeps it to ~1 line/sec, -// matching the /jobs/active poll. +// matching the /jobs poll. const FULL_LOG_PROGRESS_ARGS = [ "--no-quiet", "--progress", diff --git a/editor/app/api/jobs/active/route.ts b/editor/app/api/jobs/active/route.ts @@ -3,8 +3,8 @@ import { buildActiveJobsPayload } from "../../../jobs/active/buildActiveJobs"; export const dynamic = "force-dynamic"; -// Backs the ~1s client poll on the Active Jobs screen so per-task progress -// bars advance live without a full RSC refresh. +// Backs the ~1s client poll on the /jobs head, the dashboard and the widget, +// so per-task progress bars advance live without a full RSC refresh. export async function GET() { const payload = await buildActiveJobsPayload(); return NextResponse.json(payload); diff --git a/editor/app/components/SidebarBadges.tsx b/editor/app/components/SidebarBadges.tsx @@ -3,7 +3,7 @@ import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { usePulse } from "./pulse"; -// The sidebar's live pills — active/running job counts and reclaimable disk. +// The sidebar's live pills — the active-job count and reclaimable disk. // // These used to be plain server-rendered numbers, which meant the ONLY way to // update them was to re-render the entire page tree. That is what made the @@ -14,27 +14,16 @@ import { usePulse } from "./pulse"; // Each is seeded from the server render so the first paint is correct and there // is no flash of zero before the first poll lands. -export function JobsBadge({ - seed, - metric, -}: { - seed: number; - metric: "active" | "running"; -}) { +export function JobsBadge({ seed }: { seed: number }) { const pulse = usePulse(); // Null until the first poll after mount — until then the server seed IS the // truth, and rendering anything else would be a hydration mismatch. - const count = !pulse - ? seed - : metric === "active" - ? pulse.activeJobs - : pulse.runningJobs; + const count = pulse ? pulse.activeJobs : seed; if (count <= 0) return null; - const noun = metric === "running" ? "running job" : "active job"; return ( <span - data-testid={`badge-${metric}`} - aria-label={`${count} ${noun}${count === 1 ? "" : "s"}`} + data-testid="badge-active" + aria-label={`${count} active job${count === 1 ? "" : "s"}`} className="ml-auto text-xs rounded-full border border-brand/30 bg-brand-soft text-brand px-2 py-0.5 leading-none" > {count} diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx @@ -6,7 +6,7 @@ import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs"; import type { WorkersPayload } from "../../workers/components/WorkersView"; import type { WidgetSyncPayload } from "../../api/widget/sync/route"; import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; -import { ActiveJobsLive } from "../../jobs/components/ActiveJobsLive"; +import { JobsTable } from "../../jobs/components/JobsTable"; import { LaneDeck } from "../lanes/LaneDeck"; import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions"; import { fmtTime } from "../../widget/lib/relativeTime"; @@ -136,7 +136,12 @@ export function PipelineBand({ </div> {jobList.length > 0 && jobs ? ( - <ActiveJobsLive initial={jobs} /> + // The same one list /jobs draws, with no tail: the band is the head. + // The cockpit above already polls /api/jobs/active + // (DashboardCockpit.tsx) and this component polls it again while + // anything is live — the double poll predates this change and is out of + // scope here. + <JobsTable initial={jobs} history={[]} total={0} embedded /> ) : ( <p aria-label="pipeline idle" diff --git a/editor/app/components/lanes/pauseControl.tsx b/editor/app/components/lanes/pauseControl.tsx @@ -6,7 +6,7 @@ import type { LaneControl } from "./LaneCard"; import { pauseLaneAction, resumeLaneAction } from "../../operations/actions"; // ONE PAUSE CONTROL, drawn wherever a lane is: the dashboard band, the monitor -// widget's rail, /workers, /jobs/active, both sweep panels and the runner pages. +// widget's rail, /workers, /jobs, both sweep panels and the runner pages. // There were three copies of this button and four inline descriptors, and they // had already drifted — one disabled the RESUME side, one used the opposite // emphasis, one had no importers at all. diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx @@ -1,46 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; -import { buildActiveJobsPayload } from "./buildActiveJobs"; -import { buildQueueView } from "../queue/buildQueueView"; -import { ActiveJobsLive } from "../components/ActiveJobsLive"; -import { DrainAllButton } from "../components/DrainAllButton"; -import { PauseLaneButton } from "../../components/lanes/pauseControl"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { title: "Active jobs" }; - -export default async function ActiveJobsPage() { - const [initial, queueView] = await Promise.all([ - buildActiveJobsPayload(), - buildQueueView(), - ]); - const paused = getWorkerPool().isPaused(); - const stuck = queueView.summary.stuck; - return ( - <div className="flex flex-col gap-4"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-semibold">Active jobs</h1> - <div className="flex items-center gap-2"> - <Link - href="/jobs/queue" - className="relative px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted" - > - Queue - {stuck > 0 && ( - <span className="ml-1.5 inline-flex items-center justify-center rounded-full bg-destructive text-destructive-foreground text-xs font-semibold px-1.5 min-w-5"> - {stuck} - </span> - )} - </Link> - {/* No onChange, so the button falls back to router.refresh() — this - page is force-dynamic, so that re-reads the live pool. */} - <PauseLaneButton lane="transcription" held={paused} /> - <DrainAllButton /> - </div> - </div> - <ActiveJobsLive initial={initial} /> - </div> - ); -} diff --git a/editor/app/jobs/components/ActiveJobsLive.tsx b/editor/app/jobs/components/ActiveJobsLive.tsx @@ -1,207 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; -import type { - ActiveJobsPayload, - ActiveLaneView, -} from "../active/buildActiveJobs"; -import { jobKindLabel } from "../jobKindLabels"; -import { RunningJobsList } from "./RunningJobsList"; -import type { JobRowView } from "../jobRowView"; -import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; -import { DrainJobButton } from "./DrainJobButton"; -import { CancelJobButton } from "./CancelJobButton"; - -// Polls /api/jobs/active so per-task progress bars advance live (the server -// component only provides the initial paint). Replaces the coarser 2.5s -// router.refresh for this screen. -const POLL_MS = 1000; - -export function ActiveJobsLive({ initial }: { initial: ActiveJobsPayload }) { - const [payload, setPayload] = useState<ActiveJobsPayload>(initial); - - useEffect(() => { - let cancelled = false; - let timer: ReturnType<typeof setTimeout> | null = null; - async function tick() { - try { - const res = await fetch("/api/jobs/active", { cache: "no-store" }); - if (res.ok && !cancelled) { - setPayload((await res.json()) as ActiveJobsPayload); - } - } catch { - // transient — keep polling - } finally { - if (!cancelled) timer = setTimeout(tick, POLL_MS); - } - } - timer = setTimeout(tick, POLL_MS); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, []); - - const { jobs, lanes } = payload; - - const jobsBySlug = new Map<string, JobRowView[]>(); - // Channel-less jobs (e.g. the cross-channel auto-queue runners) are grouped by - // kind so each gets its own labeled section instead of a generic "Other". - const jobsByKind = new Map<string, JobRowView[]>(); - for (const job of jobs) { - if (job.channelSlug) { - const list = jobsBySlug.get(job.channelSlug) ?? []; - list.push(job); - jobsBySlug.set(job.channelSlug, list); - } else { - const list = jobsByKind.get(job.kind) ?? []; - list.push(job); - jobsByKind.set(job.kind, list); - } - } - - // The payload's `channels` field went with the one row type (slice 8c commit - // 1) — this screen is the only thing that read it, and it is deleted in the - // next commit. Until then the slug stands in for the display name. - const channels = [...jobsBySlug.keys()].map((slug) => ({ - slug, - displayName: slug, - })); - - // A LANE IS NOT A JOB, so an empty work list is not an empty page. The strip - // still renders: "every lane is idle and here is why" is the answer this - // screen was previously unable to give at all. - return ( - <div className="flex flex-col gap-4"> - <LaneStrip lanes={lanes} jobs={jobsByKind} /> - {jobs.length === 0 && ( - <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"> - No active jobs. - </p> - )} - {channels.map(({ slug, displayName }) => { - const channelJobs = jobsBySlug.get(slug) ?? []; - if (channelJobs.length === 0) return null; - return ( - <section - key={slug} - aria-label={`Active jobs for ${displayName}`} - className="flex flex-col gap-3 border border-border rounded-md p-3 bg-card" - > - <div className="flex flex-wrap items-baseline justify-between gap-2"> - <Link - href={`/channels/${slug}`} - className="text-base font-medium underline hover:text-foreground" - > - {displayName} - </Link> - <span className="font-mono text-xs text-muted-foreground">{slug}</span> - </div> - <RunningJobsList jobs={channelJobs} hideChannelSlug /> - </section> - ); - })} - {/* Channel-less jobs that are NOT lanes keep their own labelled card — - they are real units of work, not always-on daemons, and the "Other" - bucket they used to fall into is what this section exists to prevent. - Lane kinds are filtered out because the strip above already carries - them, one line each. */} - {[...jobsByKind.entries()] - .filter(([kind]) => !lanes.some((l) => l.kind === kind)) - .map(([kind, kindJobs]) => { - const label = jobKindLabel(kind); - return ( - <section - key={kind} - aria-label={`System jobs: ${label}`} - className="flex flex-col gap-3 border border-border rounded-md p-3 bg-card" - > - <h2 className="text-base font-medium">{label}</h2> - <RunningJobsList jobs={kindJobs} /> - </section> - ); - })} - </div> - ); -} - -// THE LANE STRIP. One line per pipeline, in place of one card per runner. -// -// Each lane keeps its <section aria-label="System jobs: …"> and its <h2>, so a -// runner is still addressable exactly as it was — what changed is that it costs -// 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. -function LaneStrip({ - lanes, - jobs, -}: { - lanes: ActiveLaneView[]; - // kind -> the channel-less jobs of that kind. A lane has at most one (its own - // long-lived runner or sweep job); its controls ride on the lane's line. - jobs: Map<string, JobRowView[]>; -}) { - if (lanes.length === 0) return null; - return ( - <div className="rounded-md border border-border bg-card"> - <p className="border-b border-border px-3 py-1.5 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - Lanes - </p> - <div className="divide-y divide-border"> - {lanes.map((lane) => ( - <LaneRow key={lane.kind} lane={lane} job={jobs.get(lane.kind)?.[0]} /> - ))} - </div> - </div> - ); -} - -function LaneRow({ - lane, - job, -}: { - lane: ActiveLaneView; - job: JobRowView | undefined; -}) { - return ( - <section - aria-label={`System jobs: ${lane.label}`} - className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" - > - <span - aria-hidden="true" - className={`size-2 shrink-0 rounded-full ${LANE_DOT[lane.state]}`} - /> - {/* Kept an <h2>: the suite finds this runner by heading, and a lane is - still the thing a screen reader should be able to jump to. */} - <h2 className="min-w-40 text-sm font-medium">{lane.label}</h2> - <span className={`text-xs ${LANE_TEXT[lane.state]}`}> - {LANE_WORD[lane.state]} - {lane.note ? ( - <span className="text-muted-foreground">: {lane.note}</span> - ) : null} - </span> - {lane.inFlight > 0 && ( - <span className="text-xs text-muted-foreground"> - <span className="tabular-nums text-foreground">{lane.inFlight}</span>{" "} - in flight - </span> - )} - {job && ( - <span className="ml-auto flex items-center gap-2"> - <Link - href={`/jobs/${job.id}`} - className="text-xs underline underline-offset-2 text-muted-foreground hover:text-foreground" - > - log - </Link> - {job.drainable && ( - <DrainJobButton jobId={job.id} draining={job.draining} /> - )} - <CancelJobButton jobId={job.id} /> - </span> - )} - </section> - ); -} diff --git a/editor/app/jobs/components/JobProgressBars.tsx b/editor/app/jobs/components/JobProgressBars.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import type { + JobProgressMetric, + JobTaskKind, +} from "yt-dlp-transcript-common/jobs/registry"; +import type { JobRowTask, JobRowView } from "../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; +} + +// Records over JobTaskKind, so adding a kind is a compile error here. +const TASK_KIND_VERB: Record<JobTaskKind, string> = { + download: "Downloading", + transcribe: "Transcribing", + digest: "Digesting", + backfill: "Backfilling", +}; + +const METRIC_FILL_BY_TASK: Record<JobTaskKind, string> = { + download: "bg-success/60", + transcribe: "bg-success", + digest: "bg-info", + backfill: "bg-warning", +}; + +export function TaskProgressBar({ task }: { task: JobRowTask }) { + // 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]; + // 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 + // indeterminate violet pulse on the first probe (no duration estimate yet). + const probeFraction = + probing && + now !== null && + typeof task.probeStartedAt === "number" && + typeof task.probeEtaSeconds === "number" && + task.probeEtaSeconds > 0 + ? Math.max( + 0, + Math.min(1, (now - task.probeStartedAt) / (task.probeEtaSeconds * 1000)), + ) + : undefined; + const hasFraction = probing + ? typeof probeFraction === "number" + : typeof task.fraction === "number"; + const fraction = probing ? probeFraction : task.fraction; + const pct = hasFraction ? Math.round((fraction as number) * 100) : 0; + const fillClass = probing + ? "bg-violet-400 dark:bg-violet-500" + : METRIC_FILL_BY_TASK[task.kind]; + const pulseClass = probing + ? "bg-violet-400 dark:bg-violet-500" + : "bg-warning"; + const elapsed = + now === null + ? null + : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || + "0:00"; + const meta = [ + elapsed, + hasFraction ? `${pct}%` : null, + 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"> + <span className="truncate"> + <span className="text-muted-foreground">{verb} </span> + <span className="font-mono">{task.label}</span> + {task.workerName && ( + <span className="text-muted-foreground"> + {" "} + on <span className="font-mono">{task.workerName}</span> + </span> + )} + </span> + <span className="font-mono text-muted-foreground shrink-0">{meta}</span> + </div> + <div + role="progressbar" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={hasFraction ? pct : undefined} + aria-label={`${verb} ${task.label}`} + className="relative h-1.5 w-full overflow-hidden rounded bg-muted" + > + {hasFraction ? ( + <div + className={`absolute inset-y-0 left-0 ${fillClass}`} + style={{ width: `${pct}%` }} + /> + ) : ( + // Indeterminate: no parseable progress yet (e.g. before whisper's + // header line or yt-dlp's first percent, or the first audio probe). + // A full-width pulse reads as "working, no progress number" — distinct + // from the fill color so it can't be mistaken for a stalled ~33%. + <div className={`absolute inset-0 animate-pulse ${pulseClass}`} /> + )} + </div> + </div> + ); +} + +// One place per metric, so adding a metric to JobProgressMetric is a compile +// error here (Record over the union) rather than a silently-wrong label. +const METRIC_LABELS: Record<JobProgressMetric, string> = { + downloads: "Downloads", + transcripts: "Transcripts", + digests: "Digests", + backfills: "Backfill", +}; + +const METRIC_FILL: Record<JobProgressMetric, string> = { + downloads: "bg-success/60", + transcripts: "bg-success", + digests: "bg-info", + backfills: "bg-warning", +}; + +export function JobProgressBar({ + progress, +}: { + progress: NonNullable<JobRowView["progress"]>; +}) { + 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 "·". + const remaining = progress.target - progress.current; + const etaText = + remaining <= 0 + ? null + : typeof progress.etaSeconds === "number" + ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` + : "estimating…"; + const fillClass = METRIC_FILL[progress.metric]; + return ( + <div className="flex flex-col gap-1"> + <div + role="progressbar" + aria-valuemin={progress.initial} + aria-valuemax={progress.target} + aria-valuenow={progress.current} + aria-label={label} + className="relative h-2 w-full overflow-hidden rounded bg-muted" + > + <div + className={`absolute inset-y-0 left-0 ${fillClass}`} + style={{ width: `${progress.pct}%` }} + /> + </div> + <div className="flex items-baseline justify-between gap-2 text-xs text-muted-foreground"> + <span>{label}</span> + {etaText ? <span className="shrink-0">{etaText}</span> : null} + </div> + </div> + ); +} diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -2,9 +2,16 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import type { JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs"; +import type { ActiveJobsPayload } from "../active/buildActiveJobs"; +import type { JobRowView } from "../jobRowView"; +import { isLive, mergeJobRows } from "../jobRows"; import { CancelJobButton } from "./CancelJobButton"; +import { DrainJobButton } from "./DrainJobButton"; +import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; +import { ReorderJobButtons } from "./ReorderJobButtons"; import { RetryJobButton } from "./RetryJobButton"; +import { LaneStrip } from "./LaneStrip"; +import { JobProgressBar, TaskProgressBar } from "./JobProgressBars"; import { jobKindLabel } from "../jobKindLabels"; import { clearJobsFilters, @@ -14,6 +21,10 @@ import { 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; + function fmtDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const s = ms / 1000; @@ -22,6 +33,24 @@ function fmtDuration(ms: number): string { 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`; +} + function statusColor(status: string): string { switch (status) { case "queued": @@ -39,18 +68,97 @@ function statusColor(status: string): string { } } -// The jobs list table with a persistent, client-side filter bar. 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. -export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { +// 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 <table>, one <tr> 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<JobsFilterState | null>(null); + const [polled, setPolled] = useState<ActiveJobsPayload | null>(null); useEffect(() => { setFilters(loadJobsFilters()); }, []); + // 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); + + useEffect(() => { + if (!anyLive) return; + let cancelled = false; + let timer: ReturnType<typeof setTimeout> | null = null; + async function tick() { + try { + const res = await fetch("/api/jobs/active", { cache: "no-store" }); + if (res.ok && !cancelled) { + setPolled((await res.json()) as ActiveJobsPayload); + } + } catch { + // transient — keep polling + } finally { + if (!cancelled) timer = setTimeout(tick, POLL_MS); + } + } + void tick(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [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<string, JobRowView[]>(); + 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); @@ -79,20 +187,20 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { const kinds = useMemo(() => { const set = new Set<string>(); - for (const j of jobs) if (j.kind) set.add(j.kind); + for (const j of rows) if (j.kind) set.add(j.kind); return Array.from(set).sort(); - }, [jobs]); + }, [rows]); const statuses = useMemo(() => { const set = new Set<string>(); - for (const j of jobs) set.add(j.status); + for (const j of rows) set.add(j.status); return Array.from(set).sort(); - }, [jobs]); + }, [rows]); const visible = useMemo(() => { - if (!filters) return jobs; + if (!filters) return rows; const q = filters.search.trim().toLowerCase(); - return jobs.filter((j) => { + return rows.filter((j) => { if (j.kind && filters.hiddenKinds.includes(j.kind)) return false; if (filters.hiddenStatuses.includes(j.status)) return false; if (q) { @@ -104,86 +212,104 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { } return true; }); - }, [jobs, filters]); + }, [rows, filters]); - const hiddenCount = jobs.length - visible.length; + 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 = <LaneStrip lanes={live.lanes} jobs={byKind} />; + + if (!embedded && rows.length === 0 && total === 0) { + return ( + <div className="flex flex-col gap-3"> + {strip} + <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"> + No jobs have run yet. + </p> + </div> + ); + } return ( <div className="flex flex-col gap-3"> - <div className="flex flex-col gap-2 text-sm"> - {kinds.length > 0 && ( - <div className="flex flex-wrap items-center gap-2"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - kind - </span> - {kinds.map((kind) => { - const hidden = filters?.hiddenKinds.includes(kind) ?? false; - return ( - <button - key={kind} - type="button" - onClick={() => toggleKind(kind)} - className={`px-2 py-0.5 rounded text-xs border ${ - hidden - ? "border-border text-muted-foreground line-through" - : "border-border-strong bg-muted text-foreground" - }`} - title={kind} - > - {jobKindLabel(kind)} - </button> - ); - })} - </div> - )} - {statuses.length > 0 && ( - <div className="flex flex-wrap items-center gap-2"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - status + {strip} + {!embedded && ( + <div className="flex flex-col gap-2 text-sm"> + {kinds.length > 0 && ( + <div className="flex flex-wrap items-center gap-2"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + kind + </span> + {kinds.map((kind) => { + const hidden = filters?.hiddenKinds.includes(kind) ?? false; + return ( + <button + key={kind} + type="button" + onClick={() => toggleKind(kind)} + className={`px-2 py-0.5 rounded text-xs border ${ + hidden + ? "border-border text-muted-foreground line-through" + : "border-border-strong bg-muted text-foreground" + }`} + title={kind} + > + {jobKindLabel(kind)} + </button> + ); + })} + </div> + )} + {statuses.length > 0 && ( + <div className="flex flex-wrap items-center gap-2"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + status + </span> + {statuses.map((status) => { + const hidden = filters?.hiddenStatuses.includes(status) ?? false; + return ( + <button + key={status} + type="button" + onClick={() => toggleStatus(status)} + className={`px-2 py-0.5 rounded text-xs uppercase tracking-wide border ${ + hidden + ? "border-border text-muted-foreground line-through" + : statusColor(status) + }`} + > + {status} + </button> + ); + })} + </div> + )} + <div className="flex flex-wrap items-center gap-3"> + <input + type="search" + value={filters?.search ?? ""} + onChange={(e) => + filters && update({ ...filters, search: e.target.value }) + } + placeholder="Search id / channel / video…" + aria-label="Search jobs" + className="rounded border border-border bg-card px-2 py-1 text-sm" + /> + <span className="text-xs text-muted-foreground"> + Showing {visible.length} of {rows.length} + {hiddenCount > 0 && ` · ${hiddenCount} hidden`} </span> - {statuses.map((status) => { - const hidden = filters?.hiddenStatuses.includes(status) ?? false; - return ( - <button - key={status} - type="button" - onClick={() => toggleStatus(status)} - className={`px-2 py-0.5 rounded text-xs uppercase tracking-wide border ${ - hidden - ? "border-border text-muted-foreground line-through" - : statusColor(status) - }`} - > - {status} - </button> - ); - })} + <button + type="button" + onClick={reset} + className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted" + > + Reset filters + </button> </div> - )} - <div className="flex flex-wrap items-center gap-3"> - <input - type="search" - value={filters?.search ?? ""} - onChange={(e) => - filters && update({ ...filters, search: e.target.value }) - } - placeholder="Search id / channel / video…" - aria-label="Search jobs" - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> - <span className="text-xs text-muted-foreground"> - Showing {visible.length} of {jobs.length} - {hiddenCount > 0 && ` · ${hiddenCount} hidden`} - </span> - <button - type="button" - onClick={reset} - className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted" - > - Reset filters - </button> </div> - </div> + )} <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"> @@ -194,7 +320,7 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { <th className="text-left font-medium px-3 py-2">Channel</th> <th className="text-left font-medium px-3 py-2">Video</th> <th className="text-left font-medium px-3 py-2">Queue</th> - <th className="text-left font-medium px-3 py-2">Status</th> + <th className="text-left font-medium px-3 py-2 min-w-56">Status</th> <th className="text-left font-medium px-3 py-2">Started</th> <th className="text-left font-medium px-3 py-2">Duration</th> <th className="text-right font-medium px-3 py-2 whitespace-nowrap">Log size</th> @@ -203,9 +329,9 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { </thead> <tbody> {visible.map((j) => { - const startedAt = j.startedAt ?? j.queuedAt; + const start = j.startedAt ?? j.queuedAt; const dur = j.endedAt - ? j.endedAt - startedAt + ? j.endedAt - (start ?? j.endedAt) : j.startedAt ? Date.now() - j.startedAt : 0; @@ -216,10 +342,13 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { // 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-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} className="border-t border-border" > - <td className="px-3 py-2 font-mono text-xs"> + <td className="px-3 py-2 font-mono text-xs align-top"> <Link href={`/jobs/${j.id}`} className="underline hover:text-foreground" @@ -227,10 +356,20 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { {j.id} </Link> </td> - <td className="px-3 py-2" title={j.kind ?? undefined}> - {j.kind ? jobKindLabel(j.kind) : "—"} + <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.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"> + <td className="px-3 py-2 font-mono text-xs align-top"> {j.channelSlug ? ( <Link href={`/channels/${j.channelSlug}`} @@ -242,7 +381,7 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { "—" )} </td> - <td className="px-3 py-2 font-mono text-xs"> + <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)}`} @@ -254,43 +393,88 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { "—" )} </td> - <td className="px-3 py-2 font-mono text-xs"> + <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"> - <span - className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`} - > - {j.status} - </span> + <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.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" + className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" suppressHydrationWarning > - {new Date(startedAt).toLocaleString()} + {start === undefined ? "—" : new Date(start).toLocaleString()} </td> <td - className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap" + className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" suppressHydrationWarning > {fmtDuration(dur)} </td> - <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap"> - {j.logSize.toLocaleString()} B + <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top"> + {typeof j.logSize === "number" + ? `${j.logSize.toLocaleString()} B` + : "—"} </td> - <td className="px-3 py-2 text-right"> + <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" && ( + // Escape hatch: force-free the scheduler slot for any + // running job, for when a Cancel leaves the queue wedged + // (see registry.forceRelease). + <ForceReleaseJobButton jobId={j.id} /> + )} </div> </td> </tr> diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx @@ -0,0 +1,88 @@ +"use client"; + +import Link from "next/link"; +import type { ActiveLaneView } from "../active/buildActiveJobs"; +import type { JobRowView } from "../jobRowView"; +import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; +import { DrainJobButton } from "./DrainJobButton"; +import { CancelJobButton } from "./CancelJobButton"; + +// THE LANE STRIP. One line per pipeline, in place of one card per runner. +// +// Each lane keeps its <section aria-label="System jobs: …"> and its <h2>, so a +// runner is still addressable exactly as it was — what changed is that it costs +// 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. +export function LaneStrip({ + lanes, + jobs, +}: { + lanes: ActiveLaneView[]; + // kind -> the channel-less jobs of that kind. A lane has at most one (its own + // long-lived runner or sweep job); its controls ride on the lane's line. + jobs: Map<string, JobRowView[]>; +}) { + if (lanes.length === 0) return null; + return ( + <div className="rounded-md border border-border bg-card"> + <p className="border-b border-border px-3 py-1.5 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> + Lanes + </p> + <div className="divide-y divide-border"> + {lanes.map((lane) => ( + <LaneRow key={lane.kind} lane={lane} job={jobs.get(lane.kind)?.[0]} /> + ))} + </div> + </div> + ); +} + +function LaneRow({ + lane, + job, +}: { + lane: ActiveLaneView; + job: JobRowView | undefined; +}) { + return ( + <section + aria-label={`System jobs: ${lane.label}`} + className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" + > + <span + aria-hidden="true" + className={`size-2 shrink-0 rounded-full ${LANE_DOT[lane.state]}`} + /> + {/* Kept an <h2>: the suite finds this runner by heading, and a lane is + still the thing a screen reader should be able to jump to. */} + <h2 className="min-w-40 text-sm font-medium">{lane.label}</h2> + <span className={`text-xs ${LANE_TEXT[lane.state]}`}> + {LANE_WORD[lane.state]} + {lane.note ? ( + <span className="text-muted-foreground">: {lane.note}</span> + ) : null} + </span> + {lane.inFlight > 0 && ( + <span className="text-xs text-muted-foreground"> + <span className="tabular-nums text-foreground">{lane.inFlight}</span>{" "} + in flight + </span> + )} + {job && ( + <span className="ml-auto flex items-center gap-2"> + <Link + href={`/jobs/${job.id}`} + className="text-xs underline underline-offset-2 text-muted-foreground hover:text-foreground" + > + log + </Link> + {job.drainable && ( + <DrainJobButton jobId={job.id} draining={job.draining} /> + )} + <CancelJobButton jobId={job.id} /> + </span> + )} + </section> + ); +} diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx @@ -1,19 +1,15 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { formatDuration } from "yt-dlp-transcript-common/lib/format"; -import type { - JobProgressMetric, - JobTaskKind, -} from "yt-dlp-transcript-common/jobs/registry"; -import type { JobRowTask, JobRowView } from "../jobRowView"; +import { useState } from "react"; +import type { JobRowView } from "../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"; type Props = { jobs: JobRowView[]; @@ -154,170 +150,3 @@ function JobRow({ </li> ); } - -// 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; -} - -// Records over JobTaskKind, so adding a kind is a compile error here. -const TASK_KIND_VERB: Record<JobTaskKind, string> = { - download: "Downloading", - transcribe: "Transcribing", - digest: "Digesting", - backfill: "Backfilling", -}; - -const METRIC_FILL_BY_TASK: Record<JobTaskKind, string> = { - download: "bg-success/60", - transcribe: "bg-success", - digest: "bg-info", - backfill: "bg-warning", -}; - -function TaskProgressBar({ task }: { task: JobRowTask }) { - // 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]; - // 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 - // indeterminate violet pulse on the first probe (no duration estimate yet). - const probeFraction = - probing && - now !== null && - typeof task.probeStartedAt === "number" && - typeof task.probeEtaSeconds === "number" && - task.probeEtaSeconds > 0 - ? Math.max( - 0, - Math.min(1, (now - task.probeStartedAt) / (task.probeEtaSeconds * 1000)), - ) - : undefined; - const hasFraction = probing - ? typeof probeFraction === "number" - : typeof task.fraction === "number"; - const fraction = probing ? probeFraction : task.fraction; - const pct = hasFraction ? Math.round((fraction as number) * 100) : 0; - const fillClass = probing - ? "bg-violet-400 dark:bg-violet-500" - : METRIC_FILL_BY_TASK[task.kind]; - const pulseClass = probing - ? "bg-violet-400 dark:bg-violet-500" - : "bg-warning"; - const elapsed = - now === null - ? null - : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || - "0:00"; - const meta = [ - elapsed, - hasFraction ? `${pct}%` : null, - 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"> - <span className="truncate"> - <span className="text-muted-foreground">{verb} </span> - <span className="font-mono">{task.label}</span> - {task.workerName && ( - <span className="text-muted-foreground"> - {" "} - on <span className="font-mono">{task.workerName}</span> - </span> - )} - </span> - <span className="font-mono text-muted-foreground shrink-0">{meta}</span> - </div> - <div - role="progressbar" - aria-valuemin={0} - aria-valuemax={100} - aria-valuenow={hasFraction ? pct : undefined} - aria-label={`${verb} ${task.label}`} - className="relative h-1.5 w-full overflow-hidden rounded bg-muted" - > - {hasFraction ? ( - <div - className={`absolute inset-y-0 left-0 ${fillClass}`} - style={{ width: `${pct}%` }} - /> - ) : ( - // Indeterminate: no parseable progress yet (e.g. before whisper's - // header line or yt-dlp's first percent, or the first audio probe). - // A full-width pulse reads as "working, no progress number" — distinct - // from the fill color so it can't be mistaken for a stalled ~33%. - <div className={`absolute inset-0 animate-pulse ${pulseClass}`} /> - )} - </div> - </div> - ); -} - -// One place per metric, so adding a metric to JobProgressMetric is a compile -// error here (Record over the union) rather than a silently-wrong label. -const METRIC_LABELS: Record<JobProgressMetric, string> = { - downloads: "Downloads", - transcripts: "Transcripts", - digests: "Digests", - backfills: "Backfill", -}; - -const METRIC_FILL: Record<JobProgressMetric, string> = { - downloads: "bg-success/60", - transcripts: "bg-success", - digests: "bg-info", - backfills: "bg-warning", -}; - -function JobProgressBar({ - progress, -}: { - progress: NonNullable<JobRowView["progress"]>; -}) { - 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 "·". - const remaining = progress.target - progress.current; - const etaText = - remaining <= 0 - ? null - : typeof progress.etaSeconds === "number" - ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` - : "estimating…"; - const fillClass = METRIC_FILL[progress.metric]; - return ( - <div className="flex flex-col gap-1"> - <div - role="progressbar" - aria-valuemin={progress.initial} - aria-valuemax={progress.target} - aria-valuenow={progress.current} - aria-label={label} - className="relative h-2 w-full overflow-hidden rounded bg-muted" - > - <div - className={`absolute inset-y-0 left-0 ${fillClass}`} - style={{ width: `${progress.pct}%` }} - /> - </div> - <div className="flex items-baseline justify-between gap-2 text-xs text-muted-foreground"> - <span>{label}</span> - {etaText ? <span className="shrink-0">{etaText}</span> : null} - </div> - </div> - ); -} diff --git a/editor/app/jobs/jobRows.ts b/editor/app/jobs/jobRows.ts @@ -192,9 +192,9 @@ function liveRank(r: JobRowView): number { // waiting IN THE ORDER IT WILL RUN, then what just finished. // // Queued rows sort by (queue name, position) rather than by recency — finding 8 -// of plans/editor-ia-slice-8c.md. That is a change: /jobs/active ordered queued -// jobs by the registry's queuedAt, which put the job that will run next -// anywhere in the list. "2nd in line" in the Queue cell only means anything if +// of plans/editor-ia-slice-8c.md. That is a change: the Active Jobs screen +// ordered queued jobs by the registry's queuedAt, which put the job that will +// run next anywhere in the list. "2nd in line" in the Queue cell only means anything if // the rows are in line. export function orderLiveRows(rows: JobRowView[]): JobRowView[] { return [...rows].sort((a, b) => { diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -1,19 +1,29 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { - listAllJobs, - DEFAULT_JOBS_LIMIT, -} from "yt-dlp-transcript-common/jobs/listJobs"; +import { DEFAULT_JOBS_LIMIT } from "yt-dlp-transcript-common/jobs/listJobs"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + buildActiveJobsPayload, + listJobRows, +} from "./active/buildActiveJobs"; import { ClearLogsMenu } from "./components/ClearLogsMenu"; +import { DrainAllButton } from "./components/DrainAllButton"; import { JobsTable } from "./components/JobsTable"; import { RetryAllFailedButton } from "./components/RetryAllFailedButton"; +import { PauseLaneButton } from "../components/lanes/pauseControl"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Jobs" }; +// ONE LIST, TWO SOURCES. The HEAD is the live payload — what the registry and +// the scheduler say is running, queued, stuck or freshly finished — and the +// client polls it. The TAIL is a page of the `.jobs` directory listing, which +// rides the global pulse like every other server-rendered page. `Load more` +// grows the TAIL only: the head is never paged, because it is always small. + // Clamp so a hand-edited ?limit= can't ask the server to hydrate an unbounded // page. "Load more" grows the limit a page at a time. const MAX_JOBS_LIMIT = 2000; @@ -30,12 +40,16 @@ export default async function JobsPage({ searchParams: Promise<{ limit?: string | string[] }>; }) { const limit = parseLimit((await searchParams).limit); - const { entries, hasMore, total } = await listAllJobs(getPaths(), { limit }); + const [{ rows, hasMore, total }, initial] = await Promise.all([ + listJobRows(getPaths(), { limit }), + buildActiveJobsPayload(), + ]); // Base the Retry-all affordance on the registry (what retryAllFailedAction // actually acts on), not just the loaded page. const hasRetryableFailed = getRegistry() .list() .some((j) => j.status === "failed" && Boolean(j.spec)); + const paused = getWorkerPool().isPaused(); return ( <div className="flex flex-col gap-4"> <div className="flex items-center justify-between"> @@ -43,30 +57,26 @@ export default async function JobsPage({ <div className="flex items-center gap-2"> {hasRetryableFailed && <RetryAllFailedButton />} <ClearLogsMenu /> + {/* No onChange, so the button falls back to router.refresh() — this + page is force-dynamic, so that re-reads the live pool. */} + <PauseLaneButton lane="transcription" held={paused} /> + <DrainAllButton /> </div> </div> - {total === 0 ? ( - <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"> - No jobs have run yet. - </p> - ) : ( - <> - <JobsTable jobs={entries} /> - {hasMore && ( - <div className="flex items-center justify-center gap-3 text-sm"> - <span className="text-muted-foreground"> - Showing {entries.length} of {total} - </span> - <Link - href={`/jobs?limit=${limit + DEFAULT_JOBS_LIMIT}`} - scroll={false} - className="px-3 py-2 rounded-md border border-border hover:bg-muted" - > - Load more - </Link> - </div> - )} - </> + <JobsTable initial={initial} history={rows} total={total} /> + {hasMore && ( + <div className="flex items-center justify-center gap-3 text-sm"> + <span className="text-muted-foreground"> + Showing {rows.length} of {total} + </span> + <Link + href={`/jobs?limit=${limit + DEFAULT_JOBS_LIMIT}`} + scroll={false} + className="px-3 py-2 rounded-md border border-border hover:bg-muted" + > + Load more + </Link> + </div> )} </div> ); diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -56,7 +56,6 @@ export default async function RootLayout({ const activeJobs = allJobs.filter( (j) => j.status === "running" || j.status === "queued", ).length; - const runningJobs = allJobs.filter((j) => j.status === "running").length; const latestChangelogDate = readChangelogLatestDate(); const sites = listSites().map((s) => ({ siteId: s.siteId, @@ -103,15 +102,12 @@ export default async function RootLayout({ // render so the first paint is correct; after that they move on their own, // which is what makes it safe to skip the tree refresh on routes that // don't need one. - if (link.badgeKey === "jobs" || link.badgeKey === "running") { + if (link.badgeKey === "jobs") { return ( <Link key={link.href} href={link.href} className={navItemClass}> <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> <span>{link.label}</span> - <JobsBadge - seed={link.badgeKey === "jobs" ? activeJobs : runningJobs} - metric={link.badgeKey === "jobs" ? "active" : "running"} - /> + <JobsBadge seed={activeJobs} /> </Link> ); } diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts @@ -1,6 +1,5 @@ import type { LucideIcon } from "lucide-react"; import { - Activity, ChartColumnBig, ClipboardCheck, Cpu, @@ -40,9 +39,9 @@ import { // // So: FOLD, DO NOT ADD, and every retired route REDIRECTS rather than 404s // (see editor/next.config.ts). The entries marked "interim" below are the ones -// later slices fold into the page above them; the end state is twelve. +// later slices fold into the page above them; the end state is eleven. -export type NavBadgeKey = "jobs" | "running" | "changelog" | "cleanable"; +export type NavBadgeKey = "jobs" | "changelog" | "cleanable"; export type NavLink = { href: string; @@ -99,8 +98,7 @@ export const NAV_GROUPS: NavGroup[] = [ { label: "Machine", links: [ - { href: "/jobs", label: "Jobs", icon: ListChecks, badgeKey: "jobs", keywords: "queue tasks" }, - { href: "/jobs/active", label: "Active", icon: Activity, badgeKey: "running", keywords: "monitor running live" }, + { href: "/jobs", label: "Jobs", icon: ListChecks, badgeKey: "jobs", keywords: "queue tasks active running live monitor stuck" }, { href: "/workers", label: "Workers", icon: Cpu, keywords: "transcription whisper gpu" }, { href: "/cleanup", label: "Cleanup", icon: Trash2, badgeKey: "cleanable", keywords: "clean reclaim disk space prune audio" }, { href: "/saved-videos", label: "Saved videos", icon: Bookmark, keywords: "backup" }, diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts @@ -578,7 +578,7 @@ test.describe("audio-checked download scenarios", () => { ); }); - test("integrity probe surfaces a 'probing' phase on /jobs/active and in the log", async ({ + test("integrity probe surfaces a 'probing' phase on /api/jobs/active and in the log", async ({ page, request, }) => { diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -513,7 +513,7 @@ test("status: reports the runner running and snapshot-derived pending counts", a expect(status.transcription.picks.length).toBe(0); }); -test("Active Jobs: the runner shows in its own labeled section, not Other", async ({ +test("/jobs: the runner shows in its own labeled section, not Other", async ({ page, }) => { await resetData(null); @@ -537,7 +537,7 @@ test("Active Jobs: the runner shows in its own labeled section, not Other", asyn data: { kind: "transcription", action: "start" }, }); - await page.goto("/jobs/active"); + await page.goto("/jobs"); await expect( page.getByRole("heading", { name: "Auto-transcribe" }), ).toBeVisible(); @@ -566,7 +566,7 @@ async function runnerRunning( return (await getStatus(request)).transcription.runner.running; } -test("Active Jobs: Drain and Cancel buttons work on the runner job", async ({ +test("/jobs: Drain and Cancel buttons work on the runner job", async ({ page, request, }) => { @@ -575,7 +575,7 @@ test("Active Jobs: Drain and Cancel buttons work on the runner job", async ({ await writeSettings(IDLE_SETTINGS(ALPHA_ROOT)); await startRunner(request); - await page.goto("/jobs/active"); + await page.goto("/jobs"); const section = page.locator( "section[aria-label='System jobs: Auto-transcribe']", ); @@ -689,8 +689,10 @@ test("Drain completes when an auto-transcribe unit is parked behind a busy worke await expect.poll(() => runnerRunning(request), { timeout: 15_000 }).toBe(false); // Cleanup: hard-cancel the lingering slowop manual job so the worker frees and - // doesn't bleed into the next spec (afterEach only stops runners). - await page.goto("/jobs/active"); + // doesn't bleed into the next spec (afterEach only stops runners). The + // runner's own table row now carries a Cancel as well; the loop already + // swallows a button that vanished under it. + await page.goto("/jobs"); for (const btn of await page.getByRole("button", { name: /^Cancel$/ }).all()) { await btn.click().catch(() => {}); } @@ -1270,7 +1272,7 @@ test("UI: Reach is disabled until an order is chosen, and it persists", async ({ expect(after.digest?.sweepEnabled ?? false).toBe(false); }); -test("Active jobs: a runner is a lane on a strip, not a card", async ({ +test("/jobs: a runner is a lane on a strip, not a card", async ({ page, request, }) => { @@ -1279,7 +1281,7 @@ test("Active jobs: a runner is a lane on a strip, not a card", async ({ await writeSettings(IDLE_SETTINGS(ALPHA_ROOT)); await startRunner(request); - await page.goto("/jobs/active"); + await page.goto("/jobs"); const lane = page.locator( "section[aria-label='System jobs: Auto-transcribe']", ); diff --git a/editor/e2e/jobs-active-order.spec.ts b/editor/e2e/jobs-active-order.spec.ts @@ -21,7 +21,7 @@ async function setQueueViaCustom( .click(); } -test("active jobs page sorts channels with running jobs before queued-only channels", async ({ +test("the live head lists running before queued, oldest running first", async ({ page, }) => { await resetData("two-slow-channels"); @@ -44,22 +44,16 @@ test("active jobs page sorts channels with running jobs before queued-only chann timeout: 10_000, }); - await page.goto("/jobs/active"); + await page.goto("/jobs"); - const sections = page.locator("section[aria-label^='Active jobs for ']"); - await expect(sections).toHaveCount(2); - const labels = await sections.evaluateAll((els) => - els.map((e) => e.getAttribute("aria-label") ?? ""), - ); - expect(labels[0]).toContain("Slow A"); - expect(labels[1]).toContain("Slow B"); + const rows = page.locator("tbody tr[data-live]"); + await expect(rows).toHaveCount(2, { timeout: 15_000 }); + await expect(rows.nth(0)).toContainText("slow-a"); + await expect(rows.nth(1)).toContainText("slow-b"); + await expect(rows.nth(0).getByText("running", { exact: true })).toBeVisible(); + await expect(rows.nth(1).getByText("queued", { exact: true })).toBeVisible(); - const slowASection = sections.filter({ hasText: "Slow A" }); - const slowBSection = sections.filter({ hasText: "Slow B" }); - await expect(slowASection.getByText("running", { exact: true })).toBeVisible(); - await expect(slowBSection.getByText("queued", { exact: true })).toBeVisible(); - - // Nav badges: Jobs counts running + queued; Active counts running only. + // Nav badge: Jobs counts running + queued; the separate Active badge is gone + // with the page. await expect(page.getByLabel("2 active jobs")).toHaveText("2"); - await expect(page.getByLabel("1 running job")).toHaveText("1"); }); diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts @@ -1,4 +1,4 @@ -// Per-operation progress bars on the Active Jobs screen, and the "Drain" +// Per-operation progress bars on /jobs, and the "Drain" // (soft-cancel) control: a drained batch finishes its in-flight sub-operations // without starting new ones, then completes (done, not cancelled) and releases // the queue for the next batch. The fake whisper/yt-dlp binaries emit slow, @@ -63,7 +63,7 @@ test("active jobs shows a per-operation progress bar that advances", async ({ await generateReport(page, "tasks-one"); await page.goto(channelStage("tasks-one", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); + await page.goto("/jobs"); // A per-task "Transcribing" bar shows up for the in-flight operation(s). const taskBar = page @@ -111,11 +111,15 @@ test("active jobs shows an estimated time remaining once a task completes", asyn await generateReport(page, "eta-one"); await page.goto(channelStage("eta-one", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); + await page.goto("/jobs"); - const section = page.locator("section[aria-label='Active jobs for Eta One']"); + const row = page + .getByRole("row") + .filter({ hasText: "eta-one" }) + .filter({ hasText: "Transcribe all" }) + .first(); // The batch-level progress bar is present (proves setProgress ran). - await expect(section.getByText(/Transcripts: \d+ \/ 6/)).toBeVisible({ + await expect(row.getByText(/Transcripts: \d+ \/ 6/)).toBeVisible({ timeout: 15_000, }); @@ -123,7 +127,7 @@ test("active jobs shows an estimated time remaining once a task completes", asyn // finishes a concrete "~m:ss left" estimate appears. Poll for the concrete // form (it implies we passed through, or skipped straight past, estimating). await expect - .poll(async () => (await section.innerText()).replace(/\s+/g, " "), { + .poll(async () => (await row.innerText()).replace(/\s+/g, " "), { timeout: 60_000, }) .toMatch(/~\d+:\d{2} left/); @@ -150,14 +154,20 @@ test("draining a batch finishes in-flight work, skips the rest, and releases the await page.goto(channelStage("drain-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); - const sectionA = page.locator("section[aria-label='Active jobs for Drain A']"); - await expect(sectionA.getByText("running", { exact: true })).toBeVisible({ + await page.goto("/jobs"); + // A bare slug filter would also match the channel's automatic refresh-report + // row once it lands (see the same note further down), so pin the batch. + const rowA = page + .getByRole("row") + .filter({ hasText: "drain-a" }) + .filter({ hasText: "Transcribe all" }) + .first(); + await expect(rowA.getByText("running", { exact: true })).toBeVisible({ timeout: 15_000, }); // Drain A: let the in-flight transcriptions finish, start no new ones. - await sectionA.getByRole("button", { name: "Drain" }).click(); + await rowA.getByRole("button", { name: "Drain" }).click(); // B (previously queued) eventually runs to completion — proof the queue was // released by A finishing normally. @@ -197,11 +207,13 @@ test("'Drain all' drains the running batch and cancels the queued one", async ({ await page.goto(channelStage("drainall-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); - const sectionA = page.locator( - "section[aria-label='Active jobs for DrainAll A']", - ); - await expect(sectionA.getByText("running", { exact: true })).toBeVisible({ + await page.goto("/jobs"); + const rowA = page + .getByRole("row") + .filter({ hasText: "drainall-a" }) + .filter({ hasText: "Transcribe all" }) + .first(); + await expect(rowA.getByText("running", { exact: true })).toBeVisible({ timeout: 15_000, }); @@ -251,15 +263,17 @@ test("a queued job can be cancelled directly from its row without opening the lo await page.goto(channelStage("qcancel-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); - const sectionB = page.locator( - "section[aria-label='Active jobs for QCancel B']", - ); + await page.goto("/jobs"); + const rowB = page + .getByRole("row") + .filter({ hasText: "qcancel-b" }) + .filter({ hasText: "Transcribe all" }) + .first(); // B's row shows as queued, with a Cancel button right there (no "Show log"). - await expect(sectionB.getByText("queued", { exact: true })).toBeVisible({ + await expect(rowB.getByText("queued", { exact: true })).toBeVisible({ timeout: 15_000, }); - await sectionB.getByRole("button", { name: /^Cancel$/ }).click(); + await rowB.getByRole("button", { name: /^Cancel$/ }).click(); // B ends cancelled and never transcribes; A keeps running and finishes. await page.goto("/jobs"); @@ -288,17 +302,19 @@ test("hard Cancel during a drain ends the job cancelled without stream errors", await page.goto(channelStage("cancel-drain", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); - await page.goto("/jobs/active"); - const section = page.locator( - "section[aria-label='Active jobs for Cancel Drain']", - ); - await expect(section.getByText("running", { exact: true })).toBeVisible({ + await page.goto("/jobs"); + const liveRow = page + .getByRole("row") + .filter({ hasText: "cancel-drain" }) + .filter({ hasText: "Transcribe all" }) + .first(); + await expect(liveRow.getByText("running", { exact: true })).toBeVisible({ timeout: 15_000, }); // Drain first, then hard-cancel before the in-flight work finishes. - await section.getByRole("button", { name: "Drain" }).click(); - await section.getByRole("button", { name: /^Cancel$/ }).click(); + await liveRow.getByRole("button", { name: "Drain" }).click(); + await liveRow.getByRole("button", { name: /^Cancel$/ }).click(); // The job ends cancelled (hard cancel wins over the in-progress drain). // Target the whisper-all row specifically: finishing the batch now also diff --git a/editor/e2e/jobs-reorder.spec.ts b/editor/e2e/jobs-reorder.spec.ts @@ -1,7 +1,7 @@ -// Granular queue control: the ↑/↓/Promote buttons on a QUEUED job's row in -// Active Jobs reorder it within its queue. The visual list is sorted by recency, -// not queue position, so this asserts the EFFECT: a promoted job runs next when -// the head is cancelled, ahead of the job that was FIFO-first. +// Granular queue control: the ↑/↓/Promote buttons on a QUEUED job's row on +// /jobs reorder it within its queue. This asserts the EFFECT rather than the +// listing order: a promoted job runs next when the head is cancelled, ahead of +// the job that was FIFO-first. import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; @@ -84,26 +84,26 @@ test("Promote makes a queued job run next, ahead of the FIFO-first one", async ( timeout: 10_000, }); - await page.goto("/jobs/active"); - const sectionA = page.locator("section[aria-label='Active jobs for Reorder A']"); - const sectionB = page.locator("section[aria-label='Active jobs for Reorder B']"); - const sectionC = page.locator("section[aria-label='Active jobs for Reorder C']"); - await expect(sectionA.getByText("running", { exact: true })).toBeVisible({ + await page.goto("/jobs"); + const rowA = page.getByRole("row").filter({ hasText: "reorder-a" }); + const rowB = page.getByRole("row").filter({ hasText: "reorder-b" }); + const rowC = page.getByRole("row").filter({ hasText: "reorder-c" }); + await expect(rowA.getByText("running", { exact: true })).toBeVisible({ timeout: 15_000, }); - await expect(sectionB.getByText("queued", { exact: true })).toBeVisible(); - await expect(sectionC.getByText("queued", { exact: true })).toBeVisible(); + await expect(rowB.getByText("queued", { exact: true })).toBeVisible(); + await expect(rowC.getByText("queued", { exact: true })).toBeVisible(); // C is last in the queue, so its row offers Promote; B (first queued) does not. await expect( - sectionB.getByRole("button", { name: /^promote job/ }), + rowB.getByRole("button", { name: /^promote job/ }), ).toHaveCount(0); - await sectionC.getByRole("button", { name: /^promote job/ }).click(); + await rowC.getByRole("button", { name: /^promote job/ }).click(); // Cancel the running A; the PROMOTED C runs next, not the FIFO-first B. - await sectionA.getByRole("button", { name: /^Cancel$/ }).click(); - await expect(sectionC.getByText("running", { exact: true })).toBeVisible({ + await rowA.getByRole("button", { name: /^Cancel$/ }).click(); + await expect(rowC.getByText("running", { exact: true })).toBeVisible({ timeout: 15_000, }); - await expect(sectionB.getByText("queued", { exact: true })).toBeVisible(); + await expect(rowB.getByText("queued", { exact: true })).toBeVisible(); }); diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts @@ -106,6 +106,7 @@ test.describe("navigation", () => { // umtool (see editor/app/lib/nav.ts). /actionable was four answers to "what // needs doing" on one page; the board is where a bookmark lands now. // /scheduler was the sync operation's page before sync was catalogued. + // /jobs/active and /jobs/queue were two thirds of /jobs. test("a retired route redirects, never 404s", async ({ page }) => { await page.goto("/actionable"); await expect(page).toHaveURL(/\/operations$/); @@ -118,6 +119,14 @@ test.describe("navigation", () => { await expect( page.getByRole("heading", { name: "Sync", level: 1 }), ).toBeVisible(); + + for (const retired of ["/jobs/active", "/jobs/queue"]) { + await page.goto(retired); + await expect(page).toHaveURL(/\/jobs$/); + await expect( + page.getByRole("heading", { name: "Jobs", level: 1 }), + ).toBeVisible(); + } }); test("a missing resource returns 404, not 200", async ({ page }) => { diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -129,7 +129,7 @@ test("Pause Transcriptions stops a parakeet run after the current segment, cache expect(await pathExists(cachedWindow)).toBe(false); }); -test("the Active Jobs screen has a Pause Transcriptions toggle", async ({ +test("/jobs has a Pause Transcriptions toggle", async ({ page, }) => { await writeSettings({ @@ -137,7 +137,7 @@ test("the Active Jobs screen has a Pause Transcriptions toggle", async ({ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, ], }); - await page.goto("/jobs/active"); + await page.goto("/jobs"); const pause = page.getByRole("button", { name: "Pause Transcriptions" }); await expect(pause).toBeVisible(); await pause.click(); diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -129,7 +129,7 @@ test("pausing all workers pauses a running batch instead of failing it; resume c await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until the batch is running with its first transcription in flight. - await page.goto("/jobs/active"); + await page.goto("/jobs"); await expect( page.getByRole("progressbar", { name: /Transcribing slowop/ }).first(), ).toBeVisible({ timeout: 15_000 }); @@ -145,13 +145,14 @@ test("pausing all workers pauses a running batch instead of failing it; resume c const whilePaused = await transcriptCount("pause-batch", ids); expect(whilePaused).toBeLessThan(ids.length); - // The batch is still running (paused), not failed/done: the Active Jobs page - // only lists non-terminal jobs, so the pause-batch section is still present. - await page.goto("/jobs/active"); - const section = page.locator( - "section[aria-label='Active jobs for pause-batch']", - ); - await expect(section.getByText("running", { exact: true })).toBeVisible({ + // The batch is still running (paused), not failed/done: /jobs lists it live. + await page.goto("/jobs"); + const row = page + .getByRole("row") + .filter({ hasText: "pause-batch" }) + .filter({ hasText: "Transcribe all" }) + .first(); + await expect(row.getByText("running", { exact: true })).toBeVisible({ timeout: 10_000, }); @@ -187,12 +188,14 @@ test("Workers page and Active jobs cross-reference the running task", async ({ await expect(only).toContainText("slowopx1"); await expect(only).toContainText("xref-chan"); - // The Active Jobs page shows that task running ON the worker "Only". - await page.goto("/jobs/active"); - const section = page.locator( - "section[aria-label='Active jobs for xref-chan']", - ); - await expect(section).toContainText(/on\s+Only/, { timeout: 15_000 }); + // /jobs shows that task running ON the worker "Only". + await page.goto("/jobs"); + const row = page + .getByRole("row") + .filter({ hasText: "xref-chan" }) + .filter({ hasText: "Transcribe all" }) + .first(); + await expect(row).toContainText(/on\s+Only/, { timeout: 15_000 }); }); test("Set as default persists the enabled workers and re-applies them on relaunch", async ({ diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -41,8 +41,8 @@ const nextConfig: NextConfig = { // are deliberately no loading.tsx skeletons; they never showed on a client // navigation AND they broke 404 status codes. See e2e/navigation.spec.ts.) // - // DO NOT RAISE IT: /jobs, /jobs/active and /jobs/queue are live operational - // views, and a stale queue is worse than a slow one. + // DO NOT RAISE IT: /jobs is a live operational view — its head is polled, + // its tail rides the pulse — and a stale queue is worse than a slow one. staleTimes: { dynamic: 15, static: 180 }, }, // Every retired route redirects rather than 404s — the nav rule this repo @@ -58,6 +58,11 @@ const nextConfig: NextConfig = { // /api/scheduler/* is NOT redirected — the cron client and the console's own // poll never moved. // + // /jobs/active and /jobs/queue were the live third and the scheduler's third + // of one list; /jobs is that list now — the live head is polled, the history + // tail is paged. /api/jobs/active is NOT redirected: the dashboard, the widget + // and the page itself poll it. + // // TEMPORARY, not permanent: a 308 is cached by the browser forever, and this // is a self-hosted admin surface where a wrong permanent redirect is a // support call with no remedy but a profile wipe. `permanent: false` is a @@ -69,6 +74,8 @@ const nextConfig: NextConfig = { { source: "/auto-queue", destination: "/operations", permanent: false }, { source: "/actionable", destination: "/operations", permanent: false }, { source: "/scheduler", destination: "/operations/sync", permanent: false }, + { source: "/jobs/active", destination: "/jobs", permanent: false }, + { source: "/jobs/queue", destination: "/jobs", permanent: false }, ]; }, // Serve the built export artifacts (stats/summaries/transcripts) through a