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 ──────────────────────────────────────────────────────────────
|
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AudioDetailPage() {
|
export function AudioDetailPage() {
|
||||||
@@ -348,6 +456,9 @@ export function AudioDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Job */}
|
||||||
|
{data.job && <JobSection job={data.job} onMutate={load} />}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{plan?.status === "pending" && !plan.is_noop && (
|
{plan?.status === "pending" && !plan.is_noop && (
|
||||||
<div className="flex gap-2 mt-6">
|
<div className="flex gap-2 mt-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user