|
|
import React, { useState, useRef, useEffect } from 'react'; |
|
|
import { useNavigate } from 'react-router-dom'; |
|
|
import { UploadIcon, StackIcon, DownloadIcon, ArrowLeftIcon, CheckCircleIcon, XCircleIcon } from './Icons'; |
|
|
import { BatchItem } from '../types'; |
|
|
import { uploadMultiple, classifyMultipleStream, clearUploads, getSamples, useSample } from '../services/apiService'; |
|
|
|
|
|
const BatchAnalysis: React.FC = () => { |
|
|
const navigate = useNavigate(); |
|
|
const [items, setItems] = useState<BatchItem[]>([]); |
|
|
const [processing, setProcessing] = useState(false); |
|
|
const [showSamples, setShowSamples] = useState(false); |
|
|
const [samples, setSamples] = useState<{ id: number, path: string, name: string }[]>([]); |
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
useEffect(() => { |
|
|
const fetchSamples = async () => { |
|
|
try { |
|
|
const data = await getSamples(); |
|
|
if (Array.isArray(data)) { |
|
|
setSamples(data); |
|
|
} |
|
|
} catch (err) { |
|
|
console.error("Failed to fetch samples", err); |
|
|
} |
|
|
}; |
|
|
fetchSamples(); |
|
|
}, []); |
|
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
if (e.target.files && e.target.files.length > 0) { |
|
|
const newFiles = Array.from(e.target.files) as File[]; |
|
|
|
|
|
|
|
|
const newItems: BatchItem[] = newFiles.map(file => ({ |
|
|
id: Math.random().toString(36).substr(2, 9), |
|
|
file: file, |
|
|
previewUrl: URL.createObjectURL(file), |
|
|
status: 'pending' |
|
|
})); |
|
|
|
|
|
setItems(prev => [...prev, ...newItems]); |
|
|
|
|
|
|
|
|
try { |
|
|
await uploadMultiple(newFiles); |
|
|
} catch (err) { |
|
|
console.error("Upload failed", err); |
|
|
|
|
|
setItems(prev => prev.map(item => |
|
|
newItems.find(ni => ni.id === item.id) ? { ...item, status: 'error' } : item |
|
|
)); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
const addSampleToQueue = async (filename: string, url: string) => { |
|
|
try { |
|
|
|
|
|
await useSample(filename, 'multiple'); |
|
|
|
|
|
|
|
|
|
|
|
const file = new File([""], filename, { type: "image/png" }); |
|
|
|
|
|
const newItem: BatchItem = { |
|
|
id: Math.random().toString(36).substr(2, 9), |
|
|
file, |
|
|
previewUrl: url, |
|
|
status: 'pending' |
|
|
}; |
|
|
|
|
|
setItems(prev => [...prev, newItem]); |
|
|
|
|
|
} catch (err) { |
|
|
console.error("Failed to load sample", err); |
|
|
} |
|
|
}; |
|
|
|
|
|
const normalizeFilename = (name: string) => { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let normalized = name.replace(/\s+/g, '_'); |
|
|
normalized = normalized.replace(/[^a-zA-Z0-9._-]/g, ''); |
|
|
return normalized; |
|
|
}; |
|
|
|
|
|
const runBatchProcessing = async () => { |
|
|
setProcessing(true); |
|
|
setItems(prev => prev.map(item => ({ ...item, status: 'processing', error: undefined }))); |
|
|
|
|
|
try { |
|
|
|
|
|
for await (const result of classifyMultipleStream()) { |
|
|
console.log("Received result:", result); |
|
|
|
|
|
if (result.error) { |
|
|
console.error("Error for file:", result.filename, result.error); |
|
|
setItems(prev => prev.map(item => { |
|
|
|
|
|
if (item.file.name === result.filename || normalizeFilename(item.file.name) === result.filename) { |
|
|
return { ...item, status: 'error', error: result.error }; |
|
|
} |
|
|
return item; |
|
|
})); |
|
|
continue; |
|
|
} |
|
|
|
|
|
setItems(prev => prev.map(item => { |
|
|
|
|
|
if (item.file.name === result.filename || normalizeFilename(item.file.name) === result.filename) { |
|
|
return { |
|
|
...item, |
|
|
status: 'completed', |
|
|
result: result.status === 'pass' ? 'pass' : 'fail', |
|
|
labels: result.labels |
|
|
}; |
|
|
} |
|
|
return item; |
|
|
})); |
|
|
} |
|
|
|
|
|
} catch (err) { |
|
|
console.error("Batch processing error:", err); |
|
|
setItems(prev => prev.map(item => |
|
|
item.status === 'processing' ? { ...item, status: 'error', error: 'Network or server error' } : item |
|
|
)); |
|
|
} finally { |
|
|
setProcessing(false); |
|
|
|
|
|
setItems(prev => prev.map(item => |
|
|
item.status === 'processing' ? { |
|
|
...item, |
|
|
status: 'error', |
|
|
error: 'No result from server (Filename mismatch or timeout)' |
|
|
} : item |
|
|
)); |
|
|
} |
|
|
}; |
|
|
|
|
|
const getProgress = () => { |
|
|
if (items.length === 0) return 0; |
|
|
const completed = items.filter(i => i.status === 'completed' || i.status === 'error').length; |
|
|
return (completed / items.length) * 100; |
|
|
}; |
|
|
|
|
|
const downloadReport = () => { |
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); |
|
|
const htmlContent = ` |
|
|
<!DOCTYPE html> |
|
|
<html> |
|
|
<head> |
|
|
<title>Prism Batch Report - ${timestamp}</title> |
|
|
<style> |
|
|
body { font-family: sans-serif; background: #f8fafc; padding: 40px; } |
|
|
h1 { color: #0f172a; } |
|
|
table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; } |
|
|
th { background: #1e293b; color: white; text-align: left; padding: 12px 20px; } |
|
|
td { border-bottom: 1px solid #e2e8f0; padding: 12px 20px; color: #334155; } |
|
|
.pass { color: #059669; font-weight: bold; } |
|
|
.fail { color: #e11d48; font-weight: bold; } |
|
|
.labels { font-family: monospace; background: #f1f5f9; padding: 2px 6px; rounded: 4px; color: #475569; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<h1>Batch Classification Report</h1> |
|
|
<p>Generated on: ${new Date().toLocaleString()}</p> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Filename</th> |
|
|
<th>Status</th> |
|
|
<th>Result</th> |
|
|
<th>Failure Reason</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
${items.map(item => ` |
|
|
<tr> |
|
|
<td>${item.file.name}</td> |
|
|
<td>${item.status}</td> |
|
|
<td class="${item.result}">${item.result ? item.result.toUpperCase() : '-'}</td> |
|
|
<td>${item.labels && item.labels.length > 0 ? `<span class="labels">${item.labels.join(', ')}</span>` : '-'}</td> |
|
|
</tr> |
|
|
`).join('')} |
|
|
</tbody> |
|
|
</table> |
|
|
</body> |
|
|
</html> |
|
|
`; |
|
|
|
|
|
const blob = new Blob([htmlContent], { type: 'text/html' }); |
|
|
const url = URL.createObjectURL(blob); |
|
|
const a = document.createElement('a'); |
|
|
a.href = url; |
|
|
a.download = `prism-batch-report-${timestamp}.html`; |
|
|
document.body.appendChild(a); |
|
|
a.click(); |
|
|
document.body.removeChild(a); |
|
|
URL.revokeObjectURL(url); |
|
|
}; |
|
|
|
|
|
const clearAll = async () => { |
|
|
setItems([]); |
|
|
await clearUploads(); |
|
|
}; |
|
|
|
|
|
const isComplete = items.length > 0 && items.every(i => i.status === 'completed' || i.status === 'error'); |
|
|
|
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col p-4 md:p-8 max-w-7xl mx-auto"> |
|
|
<header className="flex items-center justify-between mb-8"> |
|
|
<h2 className="text-2xl font-light tracking-wide">Batch Image <span className="font-bold text-cyan-400">Analysis</span></h2> |
|
|
</header> |
|
|
|
|
|
{/* Controls */} |
|
|
<div className="glass-panel rounded-2xl p-6 mb-8"> |
|
|
<div className="flex flex-col md:flex-row items-center justify-between gap-6"> |
|
|
<div className="flex items-center gap-4 w-full md:w-auto"> |
|
|
<button |
|
|
onClick={() => fileInputRef.current?.click()} |
|
|
className="flex items-center gap-2 bg-cyan-500 hover:bg-cyan-400 text-black font-bold py-3 px-6 rounded-lg transition-all hover:shadow-[0_0_20px_rgba(34,211,238,0.4)]" |
|
|
> |
|
|
<UploadIcon /> Upload Files |
|
|
</button> |
|
|
<input |
|
|
type="file" |
|
|
ref={fileInputRef} |
|
|
className="hidden" |
|
|
multiple |
|
|
accept="image/*" |
|
|
onChange={handleFileChange} |
|
|
/> |
|
|
|
|
|
{items.length > 0 && ( |
|
|
<button |
|
|
onClick={clearAll} |
|
|
className="text-slate-400 hover:text-white transition-colors text-sm" |
|
|
> |
|
|
Clear Queue |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="flex items-center gap-4 w-full md:w-auto"> |
|
|
<div className="flex-1 md:w-64 h-2 bg-slate-700 rounded-full overflow-hidden"> |
|
|
<div |
|
|
className="h-full bg-cyan-400 transition-all duration-500 ease-out" |
|
|
style={{ width: `${getProgress()}%` }} |
|
|
/> |
|
|
</div> |
|
|
<span className="text-sm font-mono text-cyan-400 w-12">{Math.round(getProgress())}%</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Sample Gallery Toggle */} |
|
|
<button |
|
|
onClick={() => setShowSamples(!showSamples)} |
|
|
className="mt-6 w-full py-2 border-t border-white/5 text-slate-400 hover:text-cyan-400 text-sm uppercase tracking-widest font-medium transition-colors flex items-center justify-center gap-2" |
|
|
> |
|
|
<StackIcon /> |
|
|
{showSamples ? 'Close Test Deck' : 'Load Test Data'} |
|
|
</button> |
|
|
|
|
|
<div className={`w-full transition-all duration-500 ease-in-out overflow-hidden ${showSamples ? 'max-h-[400px] opacity-100' : 'max-h-0 opacity-0'}`}> |
|
|
<div className="p-6 bg-slate-800/30 rounded-b-2xl border-x border-b border-slate-700/50"> |
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 overflow-y-auto max-h-[350px] pr-2 custom-scrollbar"> |
|
|
{samples.map((sample) => { |
|
|
const isSelected = items.some(item => item.previewUrl === sample.url); |
|
|
return ( |
|
|
<div |
|
|
key={sample.id} |
|
|
className={`group relative aspect-square rounded-xl overflow-hidden cursor-pointer border-2 transition-all duration-300 ${isSelected ? 'border-cyan-400 ring-2 ring-cyan-400/50' : 'border-slate-700 hover:border-cyan-500' |
|
|
}`} |
|
|
onClick={() => addSampleToQueue(sample.filename, sample.url)} |
|
|
> |
|
|
<img |
|
|
src={sample.url} |
|
|
alt={`Sample ${sample.id}`} |
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" |
|
|
/> |
|
|
<div className={`absolute inset-0 transition-colors duration-300 ${isSelected ? 'bg-cyan-500/20' : 'bg-black/0 group-hover:bg-black/20' |
|
|
}`}> |
|
|
{isSelected && ( |
|
|
<div className="absolute top-2 right-2 bg-cyan-500 rounded-full p-1"> |
|
|
<CheckCircleIcon className="w-4 h-4 text-white" /> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Status Bar */} |
|
|
{items.length > 0 && ( |
|
|
<div className="flex items-center justify-between mb-6 animate-fade-in"> |
|
|
<div> |
|
|
<p className="text-white font-medium">{items.length} items in queue</p> |
|
|
{processing && ( |
|
|
<p className="text-[10px] text-center text-purple-300/80 animate-pulse"> |
|
|
Running on CPU: Classification takes time, please be patient 🐨✨ |
|
|
</p> |
|
|
)} |
|
|
</div> |
|
|
<div className="flex gap-4"> |
|
|
<button |
|
|
onClick={runBatchProcessing} |
|
|
disabled={processing || isComplete} |
|
|
className="bg-white text-black font-bold py-2 px-6 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-[0_0_20px_rgba(255,255,255,0.2)]" |
|
|
> |
|
|
{processing ? 'Processing...' : isComplete ? 'Analysis Complete' : 'Start Analysis'} |
|
|
</button> |
|
|
<button |
|
|
onClick={downloadReport} |
|
|
disabled={!isComplete} |
|
|
className="flex items-center gap-2 bg-slate-800 text-white py-2 px-6 rounded-lg border border-slate-700 hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" |
|
|
> |
|
|
<DownloadIcon /> Report |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Grid */} |
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 pb-20"> |
|
|
{items.map((item) => ( |
|
|
<div |
|
|
key={item.id} |
|
|
className={`relative aspect-[9/16] rounded-xl overflow-hidden group border animate-fade-in ${item.status === 'completed' |
|
|
? (item.result === 'pass' ? 'border-emerald-500/50' : 'border-rose-500/50') |
|
|
: 'border-white/5' |
|
|
}`} |
|
|
> |
|
|
<img src={item.previewUrl} className="w-full h-full object-cover" alt="Batch Item" /> |
|
|
|
|
|
{/* Overlay Status */} |
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 to-transparent opacity-80 flex flex-col justify-end p-3"> |
|
|
{item.status === 'processing' && ( |
|
|
<span className="text-cyan-400 text-xs font-bold animate-pulse">ANALYZING...</span> |
|
|
)} |
|
|
{item.status === 'pending' && ( |
|
|
<span className="text-slate-400 text-xs">PENDING</span> |
|
|
)} |
|
|
{item.status === 'error' && ( |
|
|
<div className="flex flex-col"> |
|
|
<span className="text-rose-400 text-xs font-bold">ERROR</span> |
|
|
{item.error && ( |
|
|
<span className="text-[10px] text-rose-200 leading-tight mt-1 break-words"> |
|
|
{item.error.length > 50 ? item.error.substring(0, 50) + '...' : item.error} |
|
|
</span> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
{item.status === 'completed' && ( |
|
|
<div className="flex flex-col gap-1"> |
|
|
<div className="flex items-center gap-1"> |
|
|
{item.result === 'pass' |
|
|
? <CheckCircleIcon className="text-emerald-400 w-5 h-5" /> |
|
|
: <XCircleIcon className="text-rose-400 w-5 h-5" /> |
|
|
} |
|
|
<span className={`text-sm font-bold uppercase ${item.result === 'pass' ? 'text-emerald-400' : 'text-rose-400'}`}> |
|
|
{item.result} |
|
|
</span> |
|
|
</div> |
|
|
{item.labels && item.labels.length > 0 && ( |
|
|
<div className="flex flex-wrap gap-1 mt-1"> |
|
|
{item.labels.map((label, idx) => ( |
|
|
<span key={idx} className="text-[10px] bg-rose-500/20 text-rose-200 px-1.5 py-0.5 rounded border border-rose-500/30"> |
|
|
{label} |
|
|
</span> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default BatchAnalysis; |