Spaces:
Sleeping
Sleeping
Indrajit Ari
fix: resolve 'Failed to fetch' by hardening API URL detection and using same-origin relative paths
34c09b7 | 'use client' | |
| import { useEffect, useState, useRef, Suspense } from 'react' | |
| import { useRouter, useSearchParams } from 'next/navigation' | |
| // Determine API_BASE: if the baked-in env var is defined, use it. | |
| const getApiBase = () => { | |
| if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; | |
| if (typeof window !== 'undefined') return ''; // Production same-origin | |
| return 'http://localhost:8000'; // SSR fallback | |
| }; | |
| const API_BASE = getApiBase(); | |
| const VOC_COLORS: Record<string, string> = { | |
| aeroplane:'#87CEEB', bicycle:'#FFA500', bird:'#FFD700', boat:'#00BFFF', | |
| bottle:'#9400D3', bus:'#FF1493', car:'#DC143C', cat:'#FF8C00', | |
| chair:'#8B4513', cow:'#D4A017', diningtable:'#D2691E', dog:'#BA55D3', | |
| horse:'#FF69B4', motorbike:'#22c55e', person:'#FF4500', | |
| 'potted plant':'#228B22', sheep:'#B8A40A', sofa:'#00CED1', | |
| train:'#3b82f6', 'tv/monitor':'#0D9488', | |
| } | |
| const STEPS = ['Queued', 'Inferring Frames', 'Encoding H.264', 'Complete'] | |
| function ProcessingContent() { | |
| const router = useRouter() | |
| const searchParams = useSearchParams() | |
| const jobId = searchParams?.get('id') ?? '' | |
| const cardRef = useRef<HTMLDivElement>(null) | |
| const [pct, setPct] = useState(0) | |
| const [status, setStatus] = useState('queued') | |
| const [detected, setDetected] = useState<string[]>([]) | |
| const [error, setError] = useState<string | null>(null) | |
| const [elapsed, setElapsed] = useState(0) | |
| const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) | |
| useEffect(() => { | |
| setTimeout(() => cardRef.current?.classList.add('scroll-visible'), 50) | |
| }, []) | |
| useEffect(() => { | |
| if (!jobId) return | |
| const start = Date.now() | |
| timerRef.current = setInterval(() => setElapsed(Math.floor((Date.now()-start)/1000)), 1000) | |
| const apiOrigin = API_BASE || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : ''); | |
| const wsUrl = jobId ? `${apiOrigin.replace('http','ws')}/ws/${jobId}` : ''; | |
| const ws = wsUrl ? new WebSocket(wsUrl) : null; | |
| if (!ws) return; | |
| ws.onmessage = (evt) => { | |
| const data = JSON.parse(evt.data) | |
| setStatus(data.status) | |
| if (data.pct !== undefined) setPct(data.pct) | |
| if (data.detected) setDetected(data.detected) | |
| if (data.status === 'done') { | |
| setPct(100); clearInterval(timerRef.current!) | |
| setTimeout(() => router.push(`/result?id=${jobId}`), 1200) | |
| } | |
| if (data.status === 'error') { setError(data.error ?? 'Failed'); clearInterval(timerRef.current!) } | |
| } | |
| ws.onerror = () => pollFallback() | |
| return () => { ws.close(); clearInterval(timerRef.current!) } | |
| }, [jobId, router]) | |
| const pollFallback = () => { | |
| const iv = setInterval(async () => { | |
| try { | |
| const endpoint = API_BASE ? `${API_BASE}/api/status/${jobId}` : `api/status/${jobId}` | |
| const d = await fetch(endpoint).then(r=>r.json()) | |
| setStatus(d.status) | |
| if (d.pct !== undefined) setPct(d.pct) | |
| if (d.detected) setDetected(d.detected) | |
| if (d.status === 'done') { | |
| clearInterval(iv); clearInterval(timerRef.current!) | |
| setTimeout(() => router.push(`/result?id=${jobId}`), 1200) | |
| } | |
| if (d.status === 'error') { setError(d.error); clearInterval(iv) } | |
| } catch {} | |
| }, 1200) | |
| } | |
| const fmtTime = (s: number) => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}` | |
| const currentStep = status==='queued' ? 0 : status==='processing' ? 1 : status==='done' ? 3 : 2 | |
| return ( | |
| <div className="max-w-xl mx-auto px-5 py-20"> | |
| <div | |
| ref={cardRef} | |
| className="scroll-hidden card p-8 border border-slate-200 shadow-sm" | |
| style={{ borderRadius: '20px' }} | |
| > | |
| <div className="text-center mb-8"> | |
| <div className={`w-20 h-20 rounded-2xl mx-auto mb-5 flex items-center justify-center | |
| ${status==='done' ? 'bg-green-50 border border-green-200' | |
| : status==='error' ? 'bg-red-50 border border-red-200' | |
| : 'bg-orange-50 border border-orange-200'}`}> | |
| {status==='done' ? ( | |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg> | |
| ) : status==='error' ? ( | |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2"> | |
| <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/> | |
| </svg> | |
| ) : ( | |
| <svg className="animate-spin" width="30" height="30" viewBox="0 0 24 24" fill="none" strokeWidth="2"> | |
| <defs> | |
| <linearGradient id="spin-g" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| <stop offset="0%" stopColor="#f97316"/> | |
| <stop offset="100%" stopColor="#fbbf24"/> | |
| </linearGradient> | |
| </defs> | |
| <path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#spin-g)"/> | |
| </svg> | |
| )} | |
| </div> | |
| <h1 className="text-xl font-bold text-slate-900 mb-1"> | |
| {status==='queued' ? 'In Queue' | |
| : status==='processing' ? 'Segmenting…' | |
| : status==='done' ? 'Complete!' | |
| : status==='error' ? 'Failed' : status} | |
| </h1> | |
| <p className="text-sm text-slate-400"> | |
| Job <code className="text-orange-500 font-mono text-xs">{jobId?.slice(0,8)}…</code> | |
| {status==='processing' && <span className="ml-2 text-slate-400">· {fmtTime(elapsed)}</span>} | |
| </p> | |
| </div> | |
| {status !== 'error' && ( | |
| <div className="mb-7"> | |
| <div className="flex justify-between text-xs font-medium text-slate-500 mb-2"> | |
| <span>Progress</span> | |
| <span className={pct>=100 ? 'text-green-600' : 'text-orange-500'}>{pct.toFixed(1)}%</span> | |
| </div> | |
| <div className="progress-track h-2"> | |
| <div className="progress-fill h-full" style={{ width:`${pct}%` }} /> | |
| </div> | |
| </div> | |
| )} | |
| {status !== 'error' && ( | |
| <div className="mb-7"> | |
| <div className="flex items-center gap-0"> | |
| {STEPS.map((s, i) => ( | |
| <div key={i} className="flex items-center flex-1"> | |
| <div className="flex flex-col items-center gap-1"> | |
| <div className={`step-dot ${i < currentStep ? 'done' : i === currentStep ? 'active' : 'pending'}`}> | |
| {i < currentStep | |
| ? <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#15803d" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg> | |
| : i+1} | |
| </div> | |
| <p className={`text-[9px] font-semibold uppercase tracking-wider whitespace-nowrap | |
| ${i===currentStep ? 'text-orange-500' : i<currentStep ? 'text-green-600' : 'text-slate-300'}`}> | |
| {s} | |
| </p> | |
| </div> | |
| {i < STEPS.length-1 && ( | |
| <div className={`h-px flex-1 mx-1 mb-4 ${i < currentStep ? 'bg-green-300' : 'bg-slate-200'}`} /> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm mb-6"> | |
| <strong>Error:</strong> {error} | |
| </div> | |
| )} | |
| {status === 'processing' && ( | |
| <div className="grid grid-cols-3 gap-3 mb-7"> | |
| {[ | |
| { label:'Progress', val:`${pct.toFixed(0)}%`, color:'text-orange-500' }, | |
| { label:'Objects', val:`${detected.length}`, color:'text-slate-800' }, | |
| { label:'Elapsed', val:fmtTime(elapsed), color:'text-slate-800' }, | |
| ].map(s => ( | |
| <div key={s.label} className="text-center p-4 rounded-xl bg-slate-50 border border-slate-100"> | |
| <p className="text-[10px] text-slate-400 uppercase tracking-widest mb-1">{s.label}</p> | |
| <p className={`text-lg font-bold ${s.color}`} style={{fontVariantNumeric:'tabular-nums'}}>{s.val}</p> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {status === 'queued' && ( | |
| <div className="flex items-center gap-3 p-4 rounded-xl bg-orange-50 border border-orange-100 mb-6"> | |
| <div className="flex gap-1.5"> | |
| <span className="bounce-dot bg-orange-400" /> | |
| <span className="bounce-dot bg-amber-400" /> | |
| <span className="bounce-dot bg-yellow-400" /> | |
| </div> | |
| <p className="text-sm text-orange-700">Waiting for a worker to pick up this job…</p> | |
| </div> | |
| )} | |
| {detected.length > 0 && ( | |
| <div className="pt-4 border-t border-slate-100"> | |
| <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3"> | |
| Detected Objects · {detected.length} | |
| </p> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {detected.map(cls => ( | |
| <span key={cls} className="class-pill"> | |
| <span className="w-2 h-2 rounded-full" style={{ backgroundColor: VOC_COLORS[cls]??'#888' }} /> | |
| {cls} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <a href="/" className="mt-8 flex items-center justify-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg> | |
| Back to upload | |
| </a> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default function ProcessingPage() { | |
| return ( | |
| <Suspense fallback={<div className="p-20 text-center text-slate-400">Loading process…</div>}> | |
| <ProcessingContent /> | |
| </Suspense> | |
| ) | |
| } | |