File size: 15,668 Bytes
d790e98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
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;