Archilyzer · Source

archilyzer

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

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:
Meditor/CHANGELOG.md | 2++
Meditor/app/jobs/actions.ts | 17+++++++++++++++++
Meditor/app/jobs/active/page.tsx | 2++
Aeditor/app/jobs/components/DrainAllButton.tsx | 34++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/RunningJobsList.tsx | 4++--
Meditor/e2e/jobs-batch-tasks-drain.spec.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, }) => {