PRISM2.0 / frontend /components /BatchAnalysis.tsx
devranx's picture
Initial deploy with LFS images and audio
d790e98
raw
history blame
15.7 kB
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[];
// Create preview items
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]);
// Upload files immediately
try {
await uploadMultiple(newFiles);
} catch (err) {
console.error("Upload failed", err);
// Mark these items as error
setItems(prev => prev.map(item =>
newItems.find(ni => ni.id === item.id) ? { ...item, status: 'error' } : item
));
}
}
};
const addSampleToQueue = async (filename: string, url: string) => {
try {
// Call backend to copy sample
await useSample(filename, 'multiple');
// Create a dummy file object for UI state consistency
// The backend already has the file, so we don't need actual content here
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) => {
// Basic emulation of werkzeug.secure_filename behavior
// 1. ASCII only (remove non-ascii) - simplified here to just keep standard chars
// 2. Replace whitespace with underscore
// 3. Remove invalid chars
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 {
// Use the generator helper which handles buffering and parsing correctly
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 => {
// Check exact match or normalized match
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 => {
// Check exact match or normalized match
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);
// Safety check: Mark any remaining processing items as error
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;