import { useEffect, useState, useRef, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { api, apiRaw } from '../api/client'; import type { Job, LogLine, SyncPair } from '../api/client'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Switch } from '@/components/ui/Switch'; import { Card } from '@/components/ui/Card'; import { Spinner } from '@/components/ui/Spinner'; import { CopyButton } from '@/components/ui/CopyButton'; import { Textarea } from '@/components/ui/Textarea'; import { Label } from '@/components/ui/Label'; import { Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, } from '@/components/ui/Modal'; import { ErrorDetailsModal } from '@/components/ErrorDetailsModal'; import { ArrowLeft, Download, XCircle, ScrollText, Terminal, AlertCircle, Ban, ChevronDown, Activity, } from 'lucide-react'; import { toast } from 'sonner'; import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status'; import { formatDuration } from '@/lib/utils'; import { cn } from '@/lib/utils'; interface SSEProgress { file_bytes: number; pct: number; speed_bps: number; eta_seconds: number; xfr_done: number; xfr_total: number; } interface SSEEvent { type: string; job_id: number; status?: string; line?: string; stream?: string; progress?: SSEProgress; totalBytes?: number; sentBytes?: number; } export default function JobDetail() { const { id } = useParams<{ id: string }>(); const [job, setJob] = useState(null); const [pair, setPair] = useState(null); const [logs, setLogs] = useState([]); const [liveLines, setLiveLines] = useState<{ stream: string; text: string }[]>([]); const [autoScroll, setAutoScroll] = useState(true); const logEndRef = useRef(null); const esRef = useRef(null); const jobId = Number(id); const [loading, setLoading] = useState(true); const [cancelModal, setCancelModal] = useState(false); const [cancelReason, setCancelReason] = useState(''); const [errorModal, setErrorModal] = useState(false); const [progress, setProgress] = useState(null); const [finalTotals, setFinalTotals] = useState<{ totalBytes: number; sentBytes: number } | null>(null); const [hasMoreLogs, setHasMoreLogs] = useState(false); const [loadingMore, setLoadingMore] = useState(false); useEffect(() => { loadJob(); if (jobId) { loadLogs(0); const es = new EventSource( `/api/jobs/${jobId}/log/stream?job_id=${jobId}` ); esRef.current = es; es.onmessage = (e) => { const evt: SSEEvent = JSON.parse(e.data); if (evt.type === 'log') { setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]); } if (evt.type === 'status') { setJob(prev => { if (!prev) return prev; const updated = { ...prev, status: evt.status! }; if (evt.line) updated.error_message = evt.line; return updated; }); } if (evt.type === 'progress' && evt.progress) { setProgress(evt.progress); } if (evt.type === 'progress_total' && evt.totalBytes !== undefined && evt.sentBytes !== undefined) { setFinalTotals({ totalBytes: evt.totalBytes, sentBytes: evt.sentBytes }); setProgress(null); } }; } return () => esRef.current?.close(); }, [id]); useEffect(() => { if (autoScroll && logEndRef.current) { logEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [liveLines, autoScroll]); async function loadJob() { try { const j = await api(`/api/jobs/${id}`); setJob(j); const pairs = (await api('/api/sync-pairs')) ?? []; const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id); setPair(p || null); } catch { } finally { setLoading(false); } } async function loadLogs(offset: number) { try { const ls = (await api( `/api/jobs/${id}/log?offset=${offset}&limit=1000` )) ?? []; if (offset === 0) { setLogs(ls); setHasMoreLogs(ls.length === 1000); } else { setLogs(prev => [...prev, ...ls]); setHasMoreLogs(ls.length === 1000); } } catch {} } async function cancel() { try { await api(`/api/jobs/${id}/cancel`, { method: 'POST', body: { reason: cancelReason.trim() || undefined }, }); toast.success('Job cancelled'); setCancelModal(false); setCancelReason(''); loadJob(); } catch (e: unknown) { toast.error((e as Error).message); } } async function downloadLog() { try { const resp = await apiRaw(`/api/jobs/${id}/log/download`); if (resp.ok && resp.body) { const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `job-${id}.log`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { fallbackDownload(); } } catch { fallbackDownload(); } } function fallbackDownload() { const allLines = [ ...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`), ...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`), ]; const blob = new Blob(allLines as string[], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `job-${id}.log`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } const copyLog = useCallback(() => { const text = [ ...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`), ...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`), ].join('\n'); navigator.clipboard.writeText(text); toast.success('Log copied to clipboard'); }, [logs, liveLines]); if (loading) { return (
); } if (!job) { return (

Job not found

); } const totalLines = logs.length + liveLines.length; const fullErrorLog = [ ...logs.filter(l => l.stream === 'stderr').map(l => l.content), ...liveLines.filter(l => l.stream === 'stderr').map(l => l.text), ].join('\n'); const errorInfo = getErrorCodeInfo(job.error_code ?? null); return (

#{job.id}

{[ { label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` }, { label: 'Trigger', value: job.trigger_type }, { label: 'Duration', value: job.started_at ? formatDuration( (job.finished_at ? new Date(job.finished_at).getTime() : Date.now()) - new Date(job.started_at).getTime() ) : '-', }, { label: 'Started', value: job.started_at ? new Date(job.started_at).toLocaleString() : '-', }, ].map(item => (
{item.label}
{item.value}
))}
{(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
{job.status === 'failed' ? : }
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')} {job.error_code && ( [{job.error_code}] )}
{errorInfo?.hint && (

{errorInfo.hint}

)}

{job.error_message}

)} {['queued', 'waking_up', 'running'].includes(job.status) && (
)} {(progress || finalTotals) && ( )}
Output {totalLines} line{totalLines !== 1 ? 's' : ''}
`[${l.timestamp}] [${l.stream}] ${l.content}`), ...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`), ].join('\n')} displayText="Copy log" />
{logs.length === 0 && liveLines.length === 0 ? (

No log output yet

) : ( <> {logs.map(l => ( ))} {liveLines.map((l, i) => (
LIVE {l.text}
))} )}
{hasMoreLogs && (
)}
Cancel Job Are you sure you want to cancel job #{job.id}? This action cannot be undone.
{ e.preventDefault(); cancel(); }}>