90fd87be61
Build and Push Docker Image / build (push) Successful in 1m49s
across review, processing, and done columns the movie/episode name is now a link to /review/audio/\$id — matches the usual web pattern and removes an extra click through the now-redundant Details button on pipeline cards. jellyfin's deep link moves to a small ↗ affordance next to the title so the 'open in jellyfin' path is still one click away without hijacking the primary click. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
import { Link } from "@tanstack/react-router";
|
||
import { useEffect, useState } from "react";
|
||
import { Badge } from "~/shared/components/ui/badge";
|
||
import { api } from "~/shared/lib/api";
|
||
import type { PipelineJobItem } from "~/shared/lib/types";
|
||
import { ColumnShell } from "./ColumnShell";
|
||
|
||
interface ProcessingColumnProps {
|
||
items: PipelineJobItem[];
|
||
progress?: { id: number; seconds: number; total: number } | null;
|
||
queueStatus?: { status: string; until?: string; seconds?: number } | null;
|
||
onMutate: () => void;
|
||
}
|
||
|
||
export function ProcessingColumn({ items, progress, queueStatus, onMutate }: ProcessingColumnProps) {
|
||
const job = items[0]; // at most one running job
|
||
|
||
// Wall-clock elapsed since the job started — re-renders every second.
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
if (!job) return;
|
||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(t);
|
||
}, [job]);
|
||
|
||
// Only trust progress if it belongs to the current job — stale events from
|
||
// a previous job would otherwise show wrong numbers until the new job emits.
|
||
const liveProgress = job && progress && progress.id === job.id ? progress : null;
|
||
const startedAt = job?.started_at ? new Date(`${job.started_at}Z`).getTime() : null;
|
||
const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
|
||
const percent = liveProgress && liveProgress.total > 0 ? (liveProgress.seconds / liveProgress.total) * 100 : null;
|
||
// ETA = elapsed × (1 - p) / p — only meaningful once we have a few percent
|
||
// of progress and the rate has stabilised.
|
||
const etaMs = percent != null && percent >= 1 && elapsedMs > 0 ? (elapsedMs * (100 - percent)) / percent : null;
|
||
|
||
const formatDuration = (ms: number) => {
|
||
const total = Math.floor(ms / 1000);
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor((total % 3600) / 60);
|
||
const s = total % 60;
|
||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||
return `${m}:${String(s).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 (
|
||
<ColumnShell
|
||
title="Processing"
|
||
count={job ? 1 : 0}
|
||
actions={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">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<Link
|
||
to="/review/audio/$id"
|
||
params={{ id: String(job.item_id) }}
|
||
className="text-sm font-medium truncate flex-1 hover:text-blue-600 hover:underline"
|
||
>
|
||
{job.name}
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={stop}
|
||
className="text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 shrink-0"
|
||
>
|
||
Stop
|
||
</button>
|
||
</div>
|
||
<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>
|
||
|
||
<div className="mt-3">
|
||
<div className="flex justify-between text-xs text-gray-500 mb-1 tabular-nums">
|
||
<span>elapsed {formatDuration(elapsedMs)}</span>
|
||
<span>{percent != null ? `${Math.round(percent)}%` : "starting…"}</span>
|
||
<span>{etaMs != null ? `~${formatDuration(etaMs)} left` : "—"}</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: `${percent != null ? Math.min(100, percent) : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
|
||
)}
|
||
</ColumnShell>
|
||
);
|
||
}
|