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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user