Archilyzer · Source

archilyzer

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

commit fd8acd675d37c1ce02c847efeb8b13699389e3e9
parent 4057f76683f5508b6703374e6204973d28b176e2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 27 Apr 2026 00:30:57 -0400

feat(editor): cancel running jobs and fixed-height log boxes

Adds a Cancel button at every place a running job is visible and
converts log boxes from grow-to-max-height to fixed 24rem with smart
auto-follow.

UI:
- common/components/StreamActionLog: new optional cancelAction prop;
  when running with a known jobId the box renders a Cancel button next
  to the run button. Pre is now h-96 (fixed) and a useLayoutEffect
  scrolls to bottom only when the user is within ~24px of it, so manual
  scroll-up sticks until they scroll back down. Wired into all four
  consumer panels (build, pipeline ×3, whisper ×2).
- editor/app/jobs/[id]/JobLogTail: same fixed-height + smart-follow
  treatment. Inline Cancel button next to status when status === running;
  imports cancelJobAction directly.
- editor/app/jobs/page.tsx + new CancelJobButton: per-row Cancel button
  on running rows in the jobs table. Refreshes the route on success.

Cypress:
- editor/cypress/fixtures/bin/fake-ytdlp.mjs honors --cy-slow: a
  cancel-test hook that sleeps 30s on stderr before doing any work, so
  SIGTERM during the sleep is clean.
- New fixture slow-pipeline-channel with --cy-slow in ytdlpExtraArgs.
- editor/cypress/e2e/cancel.cy.ts (2 specs):
  - cancels a running pipeline job from the channel panel and asserts
    the run button re-enables and no data dir was written.
  - kicks off a job, then cancels it from the /jobs list and waits for
    the row to flip to "cancelled".

Verified: 30/30 cypress specs pass; pnpm --filter export run build still
produces the existing static output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/StreamActionLog.tsx | 53++++++++++++++++++++++++++++++++++++++++++++++++-----
Meditor/app/build/page.tsx | 3+++
Meditor/app/channels/[slug]/_components/PipelinePanel.tsx | 4++++
Meditor/app/channels/[slug]/_components/WhisperPanel.tsx | 3+++
Meditor/app/jobs/[id]/_components/JobLogTail.tsx | 61++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Aeditor/app/jobs/_components/CancelJobButton.tsx | 34++++++++++++++++++++++++++++++++++
Meditor/app/jobs/page.tsx | 10++++++++++
Aeditor/cypress/e2e/cancel.cy.ts | 42++++++++++++++++++++++++++++++++++++++++++
Meditor/cypress/fixtures/bin/fake-ytdlp.mjs | 12++++++++++++
Aeditor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json | 6++++++
10 files changed, 214 insertions(+), 14 deletions(-)

diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import type { StreamActionResult } from "../jobs/streamCommand"; type Props = { @@ -8,6 +8,7 @@ type Props = { buttonLabel: string; runningLabel?: string; testId?: string; + cancelAction?: (id: string) => Promise<{ ok: boolean }>; }; export function StreamActionLog({ @@ -15,21 +16,41 @@ export function StreamActionLog({ buttonLabel, runningLabel = "Running…", testId, + cancelAction, }: Props) { const [running, setRunning] = useState(false); const [log, setLog] = useState(""); const [error, setError] = useState<string | null>(null); const [jobId, setJobId] = useState<string | null>(null); + const [cancelling, setCancelling] = useState(false); const preRef = useRef<HTMLPreElement | null>(null); + const stickToBottomRef = useRef(true); - useEffect(() => { - if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; + // After every log update, only scroll to the bottom if the user hasn't + // scrolled away. useLayoutEffect avoids a visible flicker between paint + // and the scroll adjustment. + useLayoutEffect(() => { + const el = preRef.current; + if (!el || !stickToBottomRef.current) return; + el.scrollTop = el.scrollHeight; }, [log]); + const handleScroll = useCallback(() => { + const el = preRef.current; + if (!el) return; + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distFromBottom < 24; + }, []); + + useEffect(() => { + if (running) stickToBottomRef.current = true; + }, [running]); + async function handleClick() { setError(null); setLog(""); setJobId(null); + stickToBottomRef.current = true; setRunning(true); try { const result = await trigger(); @@ -51,9 +72,19 @@ export function StreamActionLog({ } } + async function handleCancel() { + if (!jobId || !cancelAction) return; + setCancelling(true); + try { + await cancelAction(jobId); + } finally { + setCancelling(false); + } + } + return ( <div className="flex flex-col gap-2" data-testid={testId}> - <div className="flex items-center gap-3"> + <div className="flex items-center gap-3 flex-wrap"> <button type="button" onClick={handleClick} @@ -62,6 +93,17 @@ export function StreamActionLog({ > {running ? runningLabel : buttonLabel} </button> + {running && jobId && cancelAction && ( + <button + type="button" + onClick={handleCancel} + disabled={cancelling} + data-testid={testId ? `${testId}-cancel` : undefined} + className="px-3 py-2 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" + > + {cancelling ? "Cancelling…" : "Cancel"} + </button> + )} {jobId && ( <span className="text-xs text-zinc-500 font-mono">job {jobId}</span> )} @@ -77,8 +119,9 @@ export function StreamActionLog({ {(log || running) && ( <pre ref={preRef} + onScroll={handleScroll} data-testid={testId ? `${testId}-log` : undefined} - className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 max-h-96 overflow-auto whitespace-pre-wrap" + className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap" > {log || "Waiting for output…"} </pre> diff --git a/editor/app/build/page.tsx b/editor/app/build/page.tsx @@ -1,4 +1,5 @@ import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { cancelJobAction } from "../jobs/actions"; import { buildExportAction, buildIndexAction } from "./buildAction"; export default function BuildPage() { @@ -17,6 +18,7 @@ export default function BuildPage() { </div> <StreamActionLog trigger={buildIndexAction} + cancelAction={cancelJobAction} buttonLabel="Build index" runningLabel="Building index…" testId="build-index" @@ -34,6 +36,7 @@ export default function BuildPage() { </div> <StreamActionLog trigger={buildExportAction} + cancelAction={cancelJobAction} buttonLabel="Build static export" runningLabel="Building static export…" testId="build-export" diff --git a/editor/app/channels/[slug]/_components/PipelinePanel.tsx b/editor/app/channels/[slug]/_components/PipelinePanel.tsx @@ -1,6 +1,7 @@ "use client"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { cancelJobAction } from "../../../jobs/actions"; import { downloadAction, storePlaylistAction, @@ -30,6 +31,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) { /> <StreamActionLog trigger={() => storePlaylistAction(slug)} + cancelAction={cancelJobAction} buttonLabel="Store playlist" runningLabel="Storing playlist…" testId="pipeline-store" @@ -42,6 +44,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) { /> <StreamActionLog trigger={() => downloadAction(slug)} + cancelAction={cancelJobAction} buttonLabel="Download from playlist" runningLabel="Downloading…" testId="pipeline-download" @@ -54,6 +57,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) { /> <StreamActionLog trigger={() => syncAction(slug)} + cancelAction={cancelJobAction} buttonLabel="Sync" runningLabel="Syncing…" testId="pipeline-sync" diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { cancelJobAction } from "../../../jobs/actions"; import { retryFailuresAction, transcribeMissingAction, @@ -23,6 +24,7 @@ export function WhisperPanel({ slug }: Props) { /> <StreamActionLog trigger={() => transcribeMissingAction(slug)} + cancelAction={cancelJobAction} buttonLabel="Transcribe missing" runningLabel="Transcribing…" testId="whisper-all" @@ -35,6 +37,7 @@ export function WhisperPanel({ slug }: Props) { /> <StreamActionLog trigger={() => retryFailuresAction(slug)} + cancelAction={cancelJobAction} buttonLabel="Retry failures" runningLabel="Retrying…" testId="whisper-retry" diff --git a/editor/app/jobs/[id]/_components/JobLogTail.tsx b/editor/app/jobs/[id]/_components/JobLogTail.tsx @@ -1,6 +1,13 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { cancelJobAction } from "../../actions"; type Props = { jobId: string; @@ -19,13 +26,24 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { const [status, setStatus] = useState<string>( initiallyRunning ? "running" : "loaded", ); + const [cancelling, setCancelling] = useState(false); const offsetRef = useRef(0); const preRef = useRef<HTMLPreElement | null>(null); + const stickToBottomRef = useRef(true); - useEffect(() => { - if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; + useLayoutEffect(() => { + const el = preRef.current; + if (!el || !stickToBottomRef.current) return; + el.scrollTop = el.scrollHeight; }, [log]); + const handleScroll = useCallback(() => { + const el = preRef.current; + if (!el) return; + const dist = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = dist < 24; + }, []); + useEffect(() => { let cancelled = false; let timer: ReturnType<typeof setTimeout> | null = null; @@ -56,18 +74,43 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) { }; }, [jobId]); + async function handleCancel() { + setCancelling(true); + try { + await cancelJobAction(jobId); + } finally { + setCancelling(false); + } + } + + const isRunning = status === "running"; + return ( <div className="flex flex-col gap-2" data-testid={testId}> - <div className="text-xs text-zinc-500"> - Status:{" "} - <span data-testid={testId ? `${testId}-status` : undefined}> - {status} - </span> + <div className="flex items-center gap-3 flex-wrap"> + <div className="text-xs text-zinc-500"> + Status:{" "} + <span data-testid={testId ? `${testId}-status` : undefined}> + {status} + </span> + </div> + {isRunning && ( + <button + type="button" + onClick={handleCancel} + disabled={cancelling} + data-testid={testId ? `${testId}-cancel` : undefined} + className="px-3 py-1.5 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" + > + {cancelling ? "Cancelling…" : "Cancel"} + </button> + )} </div> <pre ref={preRef} + onScroll={handleScroll} data-testid={testId ? `${testId}-pre` : undefined} - className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 max-h-[28rem] overflow-auto whitespace-pre-wrap" + className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap" > {log || "(empty log)"} </pre> diff --git a/editor/app/jobs/_components/CancelJobButton.tsx b/editor/app/jobs/_components/CancelJobButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { cancelJobAction } from "../actions"; + +type Props = { + jobId: string; + testId?: string; +}; + +export function CancelJobButton({ jobId, testId }: Props) { + const [busy, setBusy] = useState(false); + const router = useRouter(); + return ( + <button + type="button" + onClick={async () => { + setBusy(true); + try { + await cancelJobAction(jobId); + router.refresh(); + } finally { + setBusy(false); + } + }} + disabled={busy} + data-testid={testId} + className="px-2 py-1 rounded border border-red-300 dark:border-red-800 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" + > + {busy ? "Cancelling…" : "Cancel"} + </button> + ); +} diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { ClearArchivedButton } from "./_components/ClearArchivedButton"; +import { CancelJobButton } from "./_components/CancelJobButton"; export const dynamic = "force-dynamic"; @@ -56,6 +57,7 @@ export default async function JobsPage() { <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">Log size</th> + <th className="px-3 py-2"></th> </tr> </thead> <tbody> @@ -94,6 +96,14 @@ export default async function JobsPage() { <td className="px-3 py-2 text-xs text-zinc-500 text-right"> {j.logSize.toLocaleString()} B </td> + <td className="px-3 py-2 text-right"> + {j.status === "running" && ( + <CancelJobButton + jobId={j.id} + testId={`cancel-${j.id}`} + /> + )} + </td> </tr> ); })} diff --git a/editor/cypress/e2e/cancel.cy.ts b/editor/cypress/e2e/cancel.cy.ts @@ -0,0 +1,42 @@ +describe("Cancel job", () => { + it("cancels a running pipeline job from the channel page", () => { + cy.resetData("slow-pipeline-channel"); + cy.visit("/channels/slow-channel"); + cy.findByTestId("pipeline-sync").find("button").click(); + // Wait for the slow fake-ytdlp to start logging. + cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( + "contain.text", + "cypress-slow", + ); + cy.findByTestId("pipeline-sync-cancel").click(); + // After cancel, the run button should re-enable (Cancel button gone). + cy.findByTestId("pipeline-sync-cancel", { timeout: 15_000 }).should( + "not.exist", + ); + cy.findByTestId("pipeline-sync") + .find("button") + .should("not.be.disabled") + .and("contain.text", "Sync"); + // No data dir should have been written. + cy.task( + "pathExists", + "test-transcripts/channels/slow-channel/data", + ).then((exists) => expect(exists).to.equal(false)); + }); + + it("cancels a running job from the jobs list", () => { + cy.resetData("slow-pipeline-channel"); + cy.visit("/channels/slow-channel"); + cy.findByTestId("pipeline-sync").find("button").click(); + cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( + "contain.text", + "cypress-slow", + ); + + cy.visit("/jobs"); + cy.contains("running"); + cy.get('[data-testid^="cancel-"]').first().click(); + // Row's status flips to cancelled (refresh runs, page rerenders). + cy.contains("cancelled", { timeout: 15_000 }); + }); +}); diff --git a/editor/cypress/fixtures/bin/fake-ytdlp.mjs b/editor/cypress/fixtures/bin/fake-ytdlp.mjs @@ -120,7 +120,19 @@ async function modeSync(url) { await downloadOne("https://www.youtube.com/watch?v=fakeSync0001"); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function main() { + // Cypress hook for cancellation tests — sleep before doing any work so + // a Cancel click during the sleep can cleanly SIGTERM us. + if (has("--cy-slow")) { + // Stderr because store-playlist mode buffers stdout for the URL list. + process.stderr.write(`[fake-ytdlp] sleeping (cypress-slow)\n`); + await sleep(30_000); + } + if ( has("--flat-playlist") && has("--skip-download") && diff --git a/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json b/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json @@ -0,0 +1,6 @@ +{ + "handling": "youtube", + "name": "Slow Channel", + "url": "https://www.youtube.com/@slow/videos", + "ytdlpExtraArgs": ["--cy-slow"] +}