import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, Link } from 'react-router'; import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { PhaseBar, type DownloadProgress } from './DownloadJobDetail'; type ScriptJob = { id: string; mode: string; taskDirName: string; taskName: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; exitCode: number | null; error: string | null; progress: DownloadProgress | null; createdAt: string; startedAt: string | null; completedAt: string | null; }; const LOG_POLL_MS = 1500; const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted'; // A script may publish counter-style progress via the `@@officer:progress@@` sentinel (e.g. the // download-media permission). When shaped like that, render the two phase bars above the log. const isDownloadProgress = (p: unknown): p is DownloadProgress => !!p && typeof p === 'object' && 'meta' in p && 'dl' in p; const statusBadge = (status: string, exitCode: number | null) => { switch (status) { case 'running': return { icon: , label: 'Running', cls: 'text-amber-600 dark:text-amber-500', }; case 'pending': return { icon: , label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' }; case 'completed': return { icon: , label: 'Completed', cls: 'text-duck-teal' }; case 'failed': return { icon: , label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`, cls: 'text-red-600 dark:text-red-400', }; case 'stopped': return { icon: , label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' }; default: return { icon: , label: 'Interrupted', cls: 'text-orange-600 dark:text-orange-400', }; } }; export const ScriptJobDetail = () => { const { id } = useParams<{ id: string }>(); const client = useClient(); const [job, setJob] = useState(null); const [output, setOutput] = useState(''); const [loading, setLoading] = useState(true); const offsetRef = useRef(0); const preRef = useRef(null); const autoFollow = useRef(true); // Append the next slice of the log from the tracked offset. const pullLog = useCallback(async () => { if (!id) return; try { const res = await client.get<{ text: string; offset: number; size: number }>( `/jobs/${id}/log?offset=${offsetRef.current}`, ); if (res.text) { offsetRef.current = res.offset; setOutput((prev) => prev + res.text); } } catch { // transient } }, [id]); // Initial load: job + full log so far. useEffect(() => { if (!id) return; let cancelled = false; (async () => { try { const j = await client.get(`/jobs/${id}`); if (cancelled) return; setJob(j); await pullLog(); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [id]); // While live, poll the log + status until the job reaches a terminal state. useEffect(() => { if (!id || !job || isTerminal(job.status)) return; const timer = setInterval(async () => { await pullLog(); try { const j = await client.get(`/jobs/${id}`); setJob(j); if (isTerminal(j.status)) { clearInterval(timer); await pullLog(); // final catch-up } } catch { // transient } }, LOG_POLL_MS); return () => clearInterval(timer); }, [id, job?.status]); // Auto-scroll to the bottom unless the user scrolled up. useEffect(() => { const el = preRef.current; if (el && autoFollow.current) el.scrollTop = el.scrollHeight; }, [output]); const onScroll = () => { const el = preRef.current; if (!el) return; autoFollow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }; const stop = async () => { if (!id) return; try { await client.post(`/jobs/${id}/stop`, {}); toast.success('Stop requested'); } catch { toast.error('Failed to stop'); } }; if (loading) { return ( ); } if (!job) { return ( Job not found. Back to jobs ); } const badge = statusBadge(job.status, job.exitCode); const running = !isTerminal(job.status); return ( {job.taskName} {job.taskDirName} {badge.icon} {badge.label} {running && ( Stop )} {isDownloadProgress(job.progress) && ( {running && job.progress.phase === 'download' && job.progress.current && ( {job.progress.current} )} )} {output || (running ? '…' : '(no output)')} {job.error && !running && ( {job.error} )} ); };
{output || (running ? '…' : '(no output)')}