From 4a378eb8331ca75ef0d214bdd58079d5144d50ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 13 Apr 2026 10:08:42 +0200 Subject: [PATCH] pipeline: equal-width columns + per-column clear/stop button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a ColumnShell component so all four columns share the same flex-1 basis-0 width (no more 24/16/18/16 rem mix) and the same header layout (title + count + optional action button on the right). Per-column actions: - Review: 'Skip all' → POST /api/review/skip-all (new endpoint, sets all pending non-noop plans to skipped in one update) - Queued: 'Clear' → POST /api/execute/clear (existing; cancels pending jobs) - Processing: 'Stop' → POST /api/execute/stop (new; SIGTERMs the running ffmpeg via a tracked Bun.spawn handle, runJob's catch path marks the job error and cleans up) - Done: 'Clear' → POST /api/execute/clear-completed (existing) All destructive actions confirm before firing. --- server/api/execute.ts | 24 ++++++ server/api/review.ts | 10 +++ src/features/pipeline/ColumnShell.tsx | 40 ++++++++++ src/features/pipeline/DoneColumn.tsx | 37 ++++++---- src/features/pipeline/PipelinePage.tsx | 6 +- src/features/pipeline/ProcessingColumn.tsx | 86 ++++++++++++---------- src/features/pipeline/QueueColumn.tsx | 38 ++++++---- src/features/pipeline/ReviewColumn.tsx | 20 +++-- 8 files changed, 184 insertions(+), 77 deletions(-) create mode 100644 src/features/pipeline/ColumnShell.tsx diff --git a/server/api/execute.ts b/server/api/execute.ts index 6a8c869..56760bc 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -20,6 +20,8 @@ const app = new Hono(); // ─── Sequential local queue ────────────────────────────────────────────────── let queueRunning = false; +let runningProc: ReturnType | null = null; +let runningJobId: number | null = null; function emitQueueStatus( status: "running" | "paused" | "sleeping" | "idle", @@ -255,6 +257,23 @@ app.post("/clear-completed", (c) => { return c.json({ ok: true, cleared: result.changes }); }); +// ─── Stop running job ───────────────────────────────────────────────────────── + +app.post("/stop", (c) => { + if (!runningProc || runningJobId == null) { + return c.json({ ok: false, error: "No job is currently running" }, 409); + } + const stoppedId = runningJobId; + try { + runningProc.kill("SIGTERM"); + } catch (err) { + logError(`Failed to kill job ${stoppedId}:`, err); + return c.json({ ok: false, error: String(err) }, 500); + } + // runJob's catch path will mark the job error and clean up runningProc. + return c.json({ ok: true, stopped: stoppedId }); +}); + // ─── SSE ────────────────────────────────────────────────────────────────────── app.get("/events", (c) => { @@ -356,6 +375,8 @@ async function runJob(job: Job): Promise { try { const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" }); + runningProc = proc; + runningJobId = job.id; const readStream = async (readable: ReadableStream, prefix = "") => { const reader = readable.getReader(); const decoder = new TextDecoder(); @@ -422,6 +443,9 @@ async function runJob(job: Job): Promise { .run(fullOutput, job.id); emitJobUpdate(job.id, "error", fullOutput); db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id); + } finally { + runningProc = null; + runningJobId = null; } } diff --git a/server/api/review.ts b/server/api/review.ts index 2f66f57..72b38e4 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -632,6 +632,16 @@ app.post("/:id/unapprove", (c) => { // ─── Skip / Unskip ─────────────────────────────────────────────────────────── +app.post("/skip-all", (c) => { + const db = getDb(); + const result = db + .prepare( + "UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE status = 'pending' AND is_noop = 0", + ) + .run(); + return c.json({ ok: true, skipped: result.changes }); +}); + app.post("/:id/skip", (c) => { const db = getDb(); const id = parseId(c.req.param("id")); diff --git a/src/features/pipeline/ColumnShell.tsx b/src/features/pipeline/ColumnShell.tsx new file mode 100644 index 0000000..5037fa8 --- /dev/null +++ b/src/features/pipeline/ColumnShell.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; + +interface ColumnShellProps { + title: string; + count: ReactNode; + action?: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean }; + children: ReactNode; +} + +/** + * Equal-width pipeline column with a header (title + count + optional action button) + * and a scrolling body. All four pipeline columns share this shell so widths and + * header layout stay consistent. + */ +export function ColumnShell({ title, count, action, children }: ColumnShellProps) { + return ( +
+
+ + {title} ({count}) + + {action && ( + + )} +
+
{children}
+
+ ); +} diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index 076dcd5..e0854c1 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -1,24 +1,31 @@ import { Badge } from "~/shared/components/ui/badge"; +import { api } from "~/shared/lib/api"; +import { ColumnShell } from "./ColumnShell"; interface DoneColumnProps { items: any[]; + onMutate: () => void; } -export function DoneColumn({ items }: DoneColumnProps) { +export function DoneColumn({ items, onMutate }: DoneColumnProps) { + const clear = async () => { + await api.post("/api/execute/clear-completed"); + onMutate(); + }; + return ( -
-
- Done ({items.length}) -
-
- {items.map((item: any) => ( -
-

{item.name}

- {item.status} -
- ))} - {items.length === 0 &&

No completed items

} -
-
+ 0 ? { label: "Clear", onClick: clear } : undefined} + > + {items.map((item: any) => ( +
+

{item.name}

+ {item.status} +
+ ))} + {items.length === 0 &&

No completed items

} +
); } diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 490f90d..e6f1790 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -95,9 +95,9 @@ export function PipelinePage() {
- - - + + +
); diff --git a/src/features/pipeline/ProcessingColumn.tsx b/src/features/pipeline/ProcessingColumn.tsx index b5aeefb..b34d68a 100644 --- a/src/features/pipeline/ProcessingColumn.tsx +++ b/src/features/pipeline/ProcessingColumn.tsx @@ -1,12 +1,15 @@ import { Badge } from "~/shared/components/ui/badge"; +import { api } from "~/shared/lib/api"; +import { ColumnShell } from "./ColumnShell"; interface ProcessingColumnProps { items: any[]; progress?: { id: number; seconds: number; total: number } | null; queueStatus?: { status: string; until?: string; seconds?: number } | null; + onMutate: () => void; } -export function ProcessingColumn({ items, progress, queueStatus }: ProcessingColumnProps) { +export function ProcessingColumn({ items, progress, queueStatus, onMutate }: ProcessingColumnProps) { const job = items[0]; // at most one running job const formatTime = (s: number) => { @@ -15,46 +18,53 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol return `${m}:${String(sec).padStart(2, "0")}`; }; + const stop = async () => { + if (!confirm(`Stop the running job "${job?.name}"? It will be marked as error.`)) return; + await api.post("/api/execute/stop"); + onMutate(); + }; + return ( -
-
Processing
-
- {queueStatus && queueStatus.status !== "running" && ( -
- {queueStatus.status === "paused" && <>Paused until {queueStatus.until}} - {queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs} - {queueStatus.status === "idle" && <>Idle} + + {queueStatus && queueStatus.status !== "running" && ( +
+ {queueStatus.status === "paused" && <>Paused until {queueStatus.until}} + {queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs} + {queueStatus.status === "idle" && <>Idle} +
+ )} + + {job ? ( +
+

{job.name}

+
+ running + {job.job_type}
- )} - {job ? ( -
-

{job.name}

-
- running - {job.job_type} -
- - {progress && progress.total > 0 && ( -
-
- {formatTime(progress.seconds)} - {Math.round((progress.seconds / progress.total) * 100)}% - {formatTime(progress.total)} -
-
-
-
+ {progress && progress.total > 0 && ( +
+
+ {formatTime(progress.seconds)} + {Math.round((progress.seconds / progress.total) * 100)}% + {formatTime(progress.total)}
- )} -
- ) : ( -

No active job

- )} -
-
+
+
+
+
+ )} +
+ ) : ( +

No active job

+ )} + ); } diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index 429f2ff..d9a89fb 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -1,24 +1,32 @@ import { Badge } from "~/shared/components/ui/badge"; +import { api } from "~/shared/lib/api"; +import { ColumnShell } from "./ColumnShell"; interface QueueColumnProps { items: any[]; + onMutate: () => void; } -export function QueueColumn({ items }: QueueColumnProps) { +export function QueueColumn({ items, onMutate }: QueueColumnProps) { + const clear = async () => { + if (!confirm(`Cancel all ${items.length} pending jobs?`)) return; + await api.post("/api/execute/clear"); + onMutate(); + }; + return ( -
-
- Queued ({items.length}) -
-
- {items.map((item: any) => ( -
-

{item.name}

- {item.job_type} -
- ))} - {items.length === 0 &&

Queue empty

} -
-
+ 0 ? { label: "Clear", onClick: clear } : undefined} + > + {items.map((item: any) => ( +
+

{item.name}

+ {item.job_type} +
+ ))} + {items.length === 0 &&

Queue empty

} +
); } diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index e4170ac..b951e87 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -1,4 +1,5 @@ import { api } from "~/shared/lib/api"; +import { ColumnShell } from "./ColumnShell"; import { PipelineCard } from "./PipelineCard"; import { SeriesCard } from "./SeriesCard"; @@ -11,6 +12,12 @@ interface ReviewColumnProps { export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) { const truncated = total > items.length; + + const skipAll = async () => { + if (!confirm(`Skip all ${total} pending items? They won't be processed unless you unskip them.`)) return; + await api.post("/api/review/skip-all"); + onMutate(); + }; // Group by series (movies are standalone) const movies = items.filter((i: any) => i.type === "Movie"); const seriesMap = new Map(); @@ -45,11 +52,12 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu }; return ( -
-
- Review ({truncated ? `${items.length} of ${total}` : total}) -
-
+ 0 ? { label: "Skip all", onClick: skipAll } : undefined} + > +
{allItems.map((entry) => { if (entry.type === "movie") { return ( @@ -86,6 +94,6 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu

)}
-
+ ); }