pipeline: equal-width columns + per-column clear/stop button
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
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.
This commit is contained in:
@@ -20,6 +20,8 @@ const app = new Hono();
|
||||
// ─── Sequential local queue ──────────────────────────────────────────────────
|
||||
|
||||
let queueRunning = false;
|
||||
let runningProc: ReturnType<typeof Bun.spawn> | 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<void> {
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" });
|
||||
runningProc = proc;
|
||||
runningJobId = job.id;
|
||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = "") => {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -422,6 +443,9 @@ async function runJob(job: Job): Promise<void> {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
40
src/features/pipeline/ColumnShell.tsx
Normal file
40
src/features/pipeline/ColumnShell.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col flex-1 basis-0 min-w-64 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{title} <span className="text-gray-400 font-normal">({count})</span>
|
||||
</span>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={
|
||||
action.danger
|
||||
? "text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
: "text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col w-64 min-w-64 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Done <span className="text-gray-400">({items.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ColumnShell
|
||||
title="Done"
|
||||
count={items.length}
|
||||
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
|
||||
>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,9 +95,9 @@ export function PipelinePage() {
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||
<QueueColumn items={data.queued} />
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} />
|
||||
<DoneColumn items={data.done} />
|
||||
<QueueColumn items={data.queued} onMutate={load} />
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
|
||||
<DoneColumn items={data.done} onMutate={load} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col w-72 min-w-72 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">Processing</div>
|
||||
<div className="flex-1 p-3">
|
||||
{queueStatus && queueStatus.status !== "running" && (
|
||||
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
|
||||
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === "idle" && <>Idle</>}
|
||||
<ColumnShell
|
||||
title="Processing"
|
||||
count={job ? 1 : 0}
|
||||
action={job ? { label: "Stop", onClick: stop, danger: true } : undefined}
|
||||
>
|
||||
{queueStatus && queueStatus.status !== "running" && (
|
||||
<div className="mb-2 text-xs text-gray-500 bg-white rounded border p-2">
|
||||
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === "idle" && <>Idle</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job ? (
|
||||
<div className="rounded border bg-white p-3">
|
||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="running">running</Badge>
|
||||
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job ? (
|
||||
<div className="rounded border bg-white p-3">
|
||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="running">running</Badge>
|
||||
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
|
||||
</div>
|
||||
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{formatTime(progress.seconds)}</span>
|
||||
<span>{Math.round((progress.seconds / progress.total) * 100)}%</span>
|
||||
<span>{formatTime(progress.total)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{formatTime(progress.seconds)}</span>
|
||||
<span>{Math.round((progress.seconds / progress.total) * 100)}%</span>
|
||||
<span>{formatTime(progress.total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
|
||||
)}
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col w-64 min-w-64 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Queued <span className="text-gray-400">({items.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ColumnShell
|
||||
title="Queued"
|
||||
count={items.length}
|
||||
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
|
||||
>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
|
||||
@@ -45,11 +52,12 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-96 min-w-96 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Review <span className="text-gray-400">({truncated ? `${items.length} of ${total}` : total})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<ColumnShell
|
||||
title="Review"
|
||||
count={truncated ? `${items.length} of ${total}` : total}
|
||||
action={total > 0 ? { label: "Skip all", onClick: skipAll } : undefined}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{allItems.map((entry) => {
|
||||
if (entry.type === "movie") {
|
||||
return (
|
||||
@@ -86,6 +94,6 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user