commit 6f16923845c0950724e2e027d3c1510ffe807001
parent 35f1971861862aefd3ef9137659d7e473a10b2a9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 19 Jun 2026 15:49:14 -0400
Add "Drain all" spin-down and queued-job Cancel to /jobs/active
The active jobs page header gains a confirm-gated "Drain all" button that
drains every running job and cancels every queued one in a single action,
reusing the per-job requestDrain path (drains running, cancels queued) — for
easy graceful spin-down before a server restart.
Queued job rows now show a Cancel button inline, so a not-yet-started job can
be dropped without expanding its log. Drain stays running-only.
Covered by two new e2e tests in jobs-batch-tasks-drain.spec.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 148 insertions(+), 2 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **One-click "Drain all" to spin work down before a server restart.** The `/jobs/active` header gained a **Drain all** button that, in a single confirmed action, **drains every running job** (lets in-flight sub-operations finish, starts no new ones) and **cancels every queued job** — so you can wind the queue down gracefully before restarting the server instead of draining/cancelling each job row by hand. It reuses the existing per-job drain path (`requestDrain` drains running jobs and cancels queued ones), so semantics match the per-row **Drain**/**Cancel** buttons exactly; non-drainable running kinds are marked draining and finish on their own. A confirm prompt guards the bulk action. See `editor/app/jobs/components/DrainAllButton.tsx` and `drainAllAction` in `editor/app/jobs/actions.ts`.
+- **Queued jobs now have a Cancel button right on the row.** On `/jobs/active`, a queued (not-yet-started) job previously offered no inline way to cancel it — you had to expand its log to find a control. Each active row now shows a **Cancel** button directly (queued *and* running), so you can drop a queued job without opening anything. The running-only **Drain** button is unchanged. See `editor/app/jobs/components/RunningJobsList.tsx`.
- **The Bookmarks panel is now a compact one-click run menu, with management moved to its own page.** The bookmarks block atop `/jobs` and `/jobs/active` used to render a heavy multi-row card per bookmark — inline rename, tags, created/last-run metadata, a confirm-gated delete, and a full streaming **Run again** button that popped a tall inline log box on every run — which buried the live job list below it. It's now a tight **wrap of quick-access buttons**, one per bookmark, labeled with the bookmark's name (the full kind/bucket/channel shows on hover). Clicking a button **launches the job fire-and-forget** — no inline shell, no expanding log — and the relaunched job simply appears in the live list below (it runs to completion server-side whether or not the page watches its stream); the button briefly reads *Launching… → Launched ✓*, an empty re-derived bucket still reads as a neutral *"Nothing to retry right now"* line, and a bookmark whose channel was deleted is disabled with a *channel missing* hint. The heavier controls — rename, delete, created/last-run metadata, and the full streaming **Run again** with its inline log — move to a new **`/jobs/bookmarks`** management page, reachable via the **Manage** link in the menu header. See `editor/app/jobs/components/BookmarksMenu.tsx`, `editor/app/jobs/components/BookmarkRunButton.tsx`, and `editor/app/jobs/bookmarks/page.tsx`.
- **ETAs are smarter in two edge cases, and audio-integrity probes now show their own progress bar.** Batch ETAs (downloads/transcripts on `/jobs/active` and the widget) now round the remaining work up to whole **parallel waves** instead of dividing straight through, so the tail no longer reads too low — e.g. *2 videos left across 4 workers* now estimates ~one full task, not "half a task." For **audio-checked downloads**, the per-task ETA used to come straight from yt-dlp, which only counts active download time and is blind to the periodic ffmpeg integrity **probe** pauses (each one transcodes the whole, growing `.part`, so later probes cost more). The download parser now measures each probe and **projects the remaining probe overhead** — extrapolating the per-probe duration as an arithmetic progression — onto yt-dlp's ETA, so a long audio-checked download no longer counts down faster than wall-clock. And while a probe runs (yt-dlp is paused, so the download bar would otherwise just **freeze**), the task now shows a distinct violet **"probing audio"** bar that fills against the estimated probe duration. See `editor/app/jobs/active/buildActiveJobs.ts`, `common/jobs/progressParsers.ts`, and `common/ytdlp/audioCheckedDownload.ts`.
- **Bookmark a job to re-run it with one click.** Any bookmarkable job now shows a **Bookmark** button — on the `/jobs` table rows, the job detail page, and the `/jobs/active` rows — and saved jobs appear in a **Bookmarks** panel atop both `/jobs` and `/jobs/active`, each with a **Run again** button (which streams the relaunched job's log) and **Delete**. So bookmarking a *whisper-all on HasanAbiVODs3* or a *retry partial downloads on cornbreadman* gives you a button that re-launches the same job on the same channel later. Re-running **re-derives the work from the channel's current state** rather than replaying a frozen list: bucket jobs (retry partial downloads, transcribe downloaded-no-transcript, missing-transcript-and-no-download) store only the snapshot bucket category, so "Run again" always acts on whatever's in that bucket *now* (and reports "Nothing to retry right now" when it's empty) — matching how *Transcribe missing* already re-scans. Every channel-scoped pipeline, whisper, transcode, and cleanup job kind is bookmarkable; ad-hoc checkbox selections and one-off URL imports are not (there's no stable set to re-derive). A bookmark captures the job's kind, channel, and its flags (queue, audio format, abort-on-error, etc.) as a small replay descriptor persisted on the job (and its `<id>.meta.json` sidecar, so even an archived job can be bookmarked) and saved to `transcripts/.bookmarks/bookmarks.json`. Each bookmark can be **renamed** (auto-named `kind · channel` by default — handy when two bookmarks differ only in their options), shows its **created / last-run** times, and **delete is confirm-gated**. An empty re-derived bucket reads as a **neutral notice** ("Nothing to retry right now") rather than a red error, and a bookmark whose channel was since deleted is **flagged and its Run-again disabled**. See `common/jobs/jobSpec.ts`, `common/jobs/bookmarks.ts`, and `editor/app/jobs/runJobSpec.ts`.
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -19,6 +19,23 @@ export async function drainJobAction(id: string): Promise<{ ok: boolean }> {
return { ok };
}
+// Spin-down: drain every running job and cancel every queued one in a single
+// pass. requestDrain() drains running jobs and, for queued jobs, falls through
+// to cancel() (see registry.ts), so one loop covers both. Returns how many jobs
+// were acted on.
+export async function drainAllAction(): Promise<{ count: number }> {
+ const registry = getRegistry();
+ const targets = registry
+ .list()
+ .filter((j) => j.status === "running" || j.status === "queued");
+ let count = 0;
+ for (const j of targets) {
+ if (registry.requestDrain(j.id)) count++;
+ }
+ revalidatePath("/jobs");
+ return { count };
+}
+
export async function clearArchivedAction(): Promise<{ deleted: number }> {
const deleted = await clearArchivedLogs(getPaths());
revalidatePath("/jobs");
diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { buildActiveJobsPayload } from "./buildActiveJobs";
import { ActiveJobsLive } from "../components/ActiveJobsLive";
+import { DrainAllButton } from "../components/DrainAllButton";
import { BookmarksMenu } from "../components/BookmarksMenu";
import { loadBookmarksView } from "../loadBookmarks";
@@ -17,6 +18,7 @@ export default async function ActiveJobsPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Active jobs</h1>
+ <DrainAllButton />
</div>
<BookmarksMenu bookmarks={bookmarks} missingSlugs={missingSlugs} />
<ActiveJobsLive initial={initial} />
diff --git a/editor/app/jobs/components/DrainAllButton.tsx b/editor/app/jobs/components/DrainAllButton.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { drainAllAction } from "../actions";
+
+export function DrainAllButton() {
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ return (
+ <button
+ type="button"
+ onClick={async () => {
+ if (
+ !window.confirm(
+ "Drain all running jobs and cancel all queued jobs? In-flight work finishes; no new work starts.",
+ )
+ )
+ return;
+ setBusy(true);
+ try {
+ await drainAllAction();
+ router.refresh();
+ } finally {
+ setBusy(false);
+ }
+ }}
+ disabled={busy}
+ className="px-3 py-2 rounded-md border border-amber-300 dark:border-amber-800 text-sm font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950 disabled:opacity-50"
+ >
+ {busy ? "Draining…" : "Drain all"}
+ </button>
+ );
+}
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -133,10 +133,10 @@ function JobRow({
)}
<div className="ml-auto flex items-center gap-2">
{job.bookmarkable && <BookmarkJobButton jobId={job.id} />}
- {(job.drainable || job.draining) && (
+ {job.status === "running" && (job.drainable || job.draining) && (
<DrainJobButton jobId={job.id} draining={job.draining} />
)}
- {job.status === "running" && <CancelJobButton jobId={job.id} />}
+ <CancelJobButton jobId={job.id} />
<button
type="button"
onClick={() => setShowLog((s) => !s)}
diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts
@@ -167,6 +167,97 @@ test("draining a batch finishes in-flight work, skips the rest, and releases the
await expect(aRow).toContainText("done", { timeout: 15_000 });
});
+test("'Drain all' drains the running batch and cancels the queued one", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await resetData(null);
+ // A runs (6 videos > concurrency 4, so some stay in-flight when we drain);
+ // B waits queued behind A on the shared transcription queue.
+ const aIds = ["slowopd1", "slowopd2", "slowopd3", "slowopd4", "slowopd5", "slowopd6"];
+ const bIds = ["slowopd7"];
+ await makeTranscribeChannel("drainall-a", "DrainAll A", aIds);
+ await makeTranscribeChannel("drainall-b", "DrainAll B", bIds);
+ await invalidateCache();
+
+ await page.goto("/channels/drainall-a");
+ await page.getByRole("button", { name: "Transcribe missing" }).click();
+ await page.goto("/channels/drainall-b");
+ 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({
+ timeout: 15_000,
+ });
+
+ // Accept the confirm, then spin everything down in one click.
+ page.on("dialog", (d) => d.accept());
+ await page.getByRole("button", { name: "Drain all" }).click();
+
+ // B (queued) ends cancelled and never transcribes — proof Drain all cancels
+ // the queue rather than letting it run after A finishes (the per-job Drain
+ // test above shows the opposite). A drains to "done" with a partial subset.
+ await page.goto("/jobs");
+ const bRow = page
+ .getByRole("row")
+ .filter({ hasText: "drainall-b" })
+ .filter({ hasText: "whisper-all" })
+ .first();
+ await expect(bRow).toContainText("cancelled", { timeout: 20_000 });
+ expect(await transcriptCount("drainall-b", bIds)).toBe(0);
+
+ const aRow = page
+ .getByRole("row")
+ .filter({ hasText: "drainall-a" })
+ .filter({ hasText: "whisper-all" })
+ .first();
+ await expect(aRow).toContainText("done", { timeout: 20_000 });
+ const aDone = await transcriptCount("drainall-a", aIds);
+ expect(aDone).toBeGreaterThanOrEqual(1);
+ expect(aDone).toBeLessThan(aIds.length);
+});
+
+test("a queued job can be cancelled directly from its row without opening the log", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await resetData(null);
+ // A runs; B waits queued behind A on the shared transcription queue.
+ const aIds = ["slowopq1", "slowopq2", "slowopq3", "slowopq4", "slowopq5", "slowopq6"];
+ const bIds = ["slowopq7"];
+ await makeTranscribeChannel("qcancel-a", "QCancel A", aIds);
+ await makeTranscribeChannel("qcancel-b", "QCancel B", bIds);
+ await invalidateCache();
+
+ await page.goto("/channels/qcancel-a");
+ await page.getByRole("button", { name: "Transcribe missing" }).click();
+ await page.goto("/channels/qcancel-b");
+ 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']",
+ );
+ // B's row shows as queued, with a Cancel button right there (no "Show log").
+ await expect(sectionB.getByText("queued", { exact: true })).toBeVisible({
+ timeout: 15_000,
+ });
+ await sectionB.getByRole("button", { name: /^Cancel$/ }).click();
+
+ // B ends cancelled and never transcribes; A keeps running and finishes.
+ await page.goto("/jobs");
+ const bRow = page
+ .getByRole("row")
+ .filter({ hasText: "qcancel-b" })
+ .filter({ hasText: "whisper-all" })
+ .first();
+ await expect(bRow).toContainText("cancelled", { timeout: 20_000 });
+ expect(await transcriptCount("qcancel-b", bIds)).toBe(0);
+});
+
test("hard Cancel during a drain ends the job cancelled without stream errors", async ({
page,
}) => {