details: surface job status, command, log, and run/cancel inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 07:06:20 +02:00
parent 17b1d5974a
commit 346cd681f9

View File

@@ -206,6 +206,114 @@ function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string)
);
}
// ─── Job section ─────────────────────────────────────────────────────────────
interface JobSectionProps {
job: Job;
onMutate: () => void;
}
function JobSection({ job, onMutate }: JobSectionProps) {
const [showCmd, setShowCmd] = useState(false);
const [showLog, setShowLog] = useState(job.status === "error");
const [liveStatus, setLiveStatus] = useState(job.status);
const [liveOutput, setLiveOutput] = useState(job.output ?? "");
const [progress, setProgress] = useState<{ seconds: number; total: number } | null>(null);
useEffect(() => {
setLiveStatus(job.status);
setLiveOutput(job.output ?? "");
}, [job.status, job.output, job.id]);
useEffect(() => {
const es = new EventSource("/api/execute/events");
es.addEventListener("job_update", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; status: string; output?: string };
if (d.id !== job.id) return;
setLiveStatus(d.status as Job["status"]);
if (d.output !== undefined) setLiveOutput(d.output);
if (d.status === "done" || d.status === "error") onMutate();
});
es.addEventListener("job_progress", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; seconds: number; total: number };
if (d.id !== job.id) return;
setProgress({ seconds: d.seconds, total: d.total });
});
return () => es.close();
}, [job.id, onMutate]);
const runJob = async () => {
await api.post(`/api/execute/job/${job.id}/run`);
onMutate();
};
const cancelJob = async () => {
await api.post(`/api/execute/job/${job.id}/cancel`);
onMutate();
};
const stopJob = async () => {
await api.post("/api/execute/stop");
onMutate();
};
const typeLabel = job.job_type === "transcode" ? "Audio Transcode" : "Audio Remux";
const exitBadge = job.exit_code != null && job.exit_code !== 0 ? job.exit_code : null;
return (
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-2">Job</div>
<div className="flex items-center gap-2 flex-wrap mb-3">
<Badge variant={liveStatus}>{liveStatus}</Badge>
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{typeLabel}</Badge>
{exitBadge != null && <Badge variant="error">exit {exitBadge}</Badge>}
{job.started_at && <span className="text-gray-500 text-[0.72rem]">started {job.started_at}</span>}
{job.completed_at && <span className="text-gray-500 text-[0.72rem]">completed {job.completed_at}</span>}
<div className="flex-1" />
<Button size="sm" variant="secondary" onClick={() => setShowCmd((v) => !v)}>
Cmd
</Button>
{liveOutput && (
<Button size="sm" variant="secondary" onClick={() => setShowLog((v) => !v)}>
Log
</Button>
)}
{liveStatus === "pending" && (
<>
<Button size="sm" onClick={runJob}>
Run
</Button>
<Button size="sm" variant="secondary" onClick={cancelJob}>
Cancel
</Button>
</>
)}
{liveStatus === "running" && (
<Button size="sm" variant="secondary" onClick={stopJob}>
Stop
</Button>
)}
</div>
{liveStatus === "running" && progress && progress.total > 0 && (
<div className="h-1.5 bg-gray-200 rounded mb-3 overflow-hidden">
<div
className="h-full bg-blue-500 transition-[width] duration-500"
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100).toFixed(1)}%` }}
/>
</div>
)}
{showCmd && (
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3 py-2 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all mb-2">
{job.command}
</div>
)}
{showLog && liveOutput && (
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3 py-2 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{liveOutput}
</div>
)}
</div>
);
}
// ─── Detail page ──────────────────────────────────────────────────────────────
export function AudioDetailPage() {
@@ -348,6 +456,9 @@ export function AudioDetailPage() {
</div>
)}
{/* Job */}
{data.job && <JobSection job={data.job} onMutate={load} />}
{/* Actions */}
{plan?.status === "pending" && !plan.is_noop && (
<div className="flex gap-2 mt-6">