Spaces:
Sleeping
Sleeping
| {% extends "base.html" %} | |
| {% block title %}Batch Processing — Aseesa Ingestion Agent{% endblock %} | |
| {% block content %} | |
| <div class="max-w-4xl mx-auto"> | |
| <div class="text-center mb-8"> | |
| <h1 class="text-2xl font-bold text-gray-900 mb-2">Batch Processing</h1> | |
| <p class="text-gray-500">Upload multiple datasets and process them all at once using default classifications</p> | |
| </div> | |
| <!-- Product selector --> | |
| <div class="mb-6"> | |
| <div class="flex justify-center gap-3" id="batch-product-selector"> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="batch_product" value="reshape" class="peer hidden" checked> | |
| <div class="peer-checked:border-brand-500 peer-checked:bg-brand-50 peer-checked:ring-2 peer-checked:ring-brand-200 | |
| border-2 border-gray-200 rounded-lg px-6 py-2.5 text-center transition-all hover:border-gray-300"> | |
| <span class="font-semibold text-gray-900 text-sm">Stars</span> | |
| </div> | |
| </label> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="batch_product" value="qvey" class="peer hidden"> | |
| <div class="peer-checked:border-brand-500 peer-checked:bg-brand-50 peer-checked:ring-2 peer-checked:ring-brand-200 | |
| border-2 border-gray-200 rounded-lg px-6 py-2.5 text-center transition-all hover:border-gray-300"> | |
| <span class="font-semibold text-gray-900 text-sm">Qvey</span> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Batch upload area --> | |
| <div class="bg-white rounded-2xl border border-gray-200 p-6 mb-6"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h2 class="text-sm font-semibold text-gray-900">Batch Groups</h2> | |
| <button type="button" onclick="addGroup()" | |
| class="px-3 py-1.5 bg-brand-600 text-white text-xs font-medium rounded-lg hover:bg-brand-700 transition-colors flex items-center gap-1.5"> | |
| <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> | |
| </svg> | |
| Add Group | |
| </button> | |
| </div> | |
| <p class="text-xs text-gray-400 mb-4">Each group is processed as a separate session. Files within a group are joined together.</p> | |
| <div id="batch-groups" class="space-y-3"> | |
| <!-- Group 1 (default) --> | |
| <div class="batch-group border border-gray-200 rounded-xl p-4" data-group-idx="0"> | |
| <div class="flex items-center justify-between mb-3"> | |
| <div class="flex items-center gap-2"> | |
| <span class="w-6 h-6 rounded-full bg-brand-100 text-brand-600 flex items-center justify-center text-xs font-bold group-number">1</span> | |
| <input type="text" class="group-name text-sm font-medium text-gray-900 border-0 border-b border-transparent focus:border-brand-400 focus:ring-0 px-0 py-0 w-48" | |
| value="Group 1" placeholder="Group name"> | |
| </div> | |
| <button type="button" onclick="removeGroup(this)" class="text-gray-400 hover:text-red-500 transition-colors" title="Remove group"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="border-2 border-dashed border-gray-200 rounded-lg p-4 text-center cursor-pointer hover:border-brand-400 transition-colors group-drop-zone" | |
| onclick="this.querySelector('input[type=file]').click()"> | |
| <p class="text-xs text-gray-400">Drop files or click to add — .sas7bdat, .csv, .xpt, .xlsx, .xls</p> | |
| <input type="file" accept=".sas7bdat,.csv,.xpt,.xlsx,.xls" multiple class="hidden group-file-input" | |
| onchange="updateGroupFiles(this)"> | |
| </div> | |
| <div class="group-file-list mt-2 space-y-1"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Output format --> | |
| <div class="bg-white rounded-2xl border border-gray-200 p-6 mb-6"> | |
| <div class="flex items-center gap-4"> | |
| <span class="text-sm font-medium text-gray-700">Output format:</span> | |
| <div class="flex items-center gap-1.5"> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="batch_format" value="csv" checked class="peer hidden"> | |
| <span class="peer-checked:bg-brand-600 peer-checked:text-white px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 transition-colors">CSV</span> | |
| </label> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="batch_format" value="xlsx" class="peer hidden"> | |
| <span class="peer-checked:bg-brand-600 peer-checked:text-white px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 transition-colors">Excel</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Run batch button --> | |
| <button type="button" id="run-batch-btn" onclick="runBatch()" disabled | |
| class="w-full py-3 bg-brand-600 text-white font-medium rounded-xl hover:bg-brand-700 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/> | |
| </svg> | |
| <span>Process All Groups</span> | |
| </button> | |
| <!-- Progress area --> | |
| <div id="batch-progress" class="hidden mt-6 space-y-4"> | |
| <div class="flex items-center justify-between text-sm"> | |
| <span id="batch-progress-label" class="font-medium text-brand-600">Processing...</span> | |
| <span id="batch-progress-count" class="text-gray-500 font-mono text-xs">0 / 0</span> | |
| </div> | |
| <div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden"> | |
| <div id="batch-progress-bar" class="bg-brand-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| <div id="batch-results" class="space-y-2"></div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block scripts %} | |
| <script> | |
| let groupCounter = 1; | |
| function getSelectedProduct() { | |
| const checked = document.querySelector('input[name="batch_product"]:checked'); | |
| return checked ? checked.value : 'reshape'; | |
| } | |
| function getSelectedFormat() { | |
| const checked = document.querySelector('input[name="batch_format"]:checked'); | |
| return checked ? checked.value : 'csv'; | |
| } | |
| function addGroup() { | |
| groupCounter++; | |
| const container = document.getElementById('batch-groups'); | |
| const div = document.createElement('div'); | |
| div.className = 'batch-group border border-gray-200 rounded-xl p-4 fade-in'; | |
| div.dataset.groupIdx = groupCounter - 1; | |
| div.innerHTML = ` | |
| <div class="flex items-center justify-between mb-3"> | |
| <div class="flex items-center gap-2"> | |
| <span class="w-6 h-6 rounded-full bg-brand-100 text-brand-600 flex items-center justify-center text-xs font-bold group-number">${groupCounter}</span> | |
| <input type="text" class="group-name text-sm font-medium text-gray-900 border-0 border-b border-transparent focus:border-brand-400 focus:ring-0 px-0 py-0 w-48" | |
| value="Group ${groupCounter}" placeholder="Group name"> | |
| </div> | |
| <button type="button" onclick="removeGroup(this)" class="text-gray-400 hover:text-red-500 transition-colors" title="Remove group"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="border-2 border-dashed border-gray-200 rounded-lg p-4 text-center cursor-pointer hover:border-brand-400 transition-colors group-drop-zone" | |
| onclick="this.querySelector('input[type=file]').click()"> | |
| <p class="text-xs text-gray-400">Drop files or click to add — .sas7bdat, .csv, .xpt, .xlsx, .xls</p> | |
| <input type="file" accept=".sas7bdat,.csv,.xpt,.xlsx,.xls" multiple class="hidden group-file-input" | |
| onchange="updateGroupFiles(this)"> | |
| </div> | |
| <div class="group-file-list mt-2 space-y-1"></div> | |
| `; | |
| container.appendChild(div); | |
| renumberGroups(); | |
| } | |
| function removeGroup(btn) { | |
| const groups = document.querySelectorAll('.batch-group'); | |
| if (groups.length <= 1) return; | |
| btn.closest('.batch-group').remove(); | |
| renumberGroups(); | |
| updateRunButton(); | |
| } | |
| function renumberGroups() { | |
| document.querySelectorAll('.batch-group').forEach((g, i) => { | |
| g.querySelector('.group-number').textContent = i + 1; | |
| }); | |
| } | |
| function updateGroupFiles(input) { | |
| const group = input.closest('.batch-group'); | |
| const listDiv = group.querySelector('.group-file-list'); | |
| listDiv.innerHTML = ''; | |
| for (const file of input.files) { | |
| const div = document.createElement('div'); | |
| div.className = 'flex items-center justify-between px-3 py-1.5 bg-gray-50 rounded-lg text-xs'; | |
| div.innerHTML = ` | |
| <span class="font-mono text-gray-700">${file.name}</span> | |
| <span class="text-gray-400">${formatSize(file.size)}</span> | |
| `; | |
| listDiv.appendChild(div); | |
| } | |
| updateRunButton(); | |
| } | |
| function updateRunButton() { | |
| const groups = document.querySelectorAll('.batch-group'); | |
| let hasFiles = false; | |
| groups.forEach(g => { | |
| const input = g.querySelector('.group-file-input'); | |
| if (input && input.files && input.files.length > 0) hasFiles = true; | |
| }); | |
| document.getElementById('run-batch-btn').disabled = !hasFiles; | |
| } | |
| function formatSize(bytes) { | |
| if (bytes < 1024) return bytes + ' B'; | |
| if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; | |
| return (bytes / 1048576).toFixed(1) + ' MB'; | |
| } | |
| async function runBatch() { | |
| const groups = document.querySelectorAll('.batch-group'); | |
| const product = getSelectedProduct(); | |
| const format = getSelectedFormat(); | |
| const btn = document.getElementById('run-batch-btn'); | |
| const progressDiv = document.getElementById('batch-progress'); | |
| const progressBar = document.getElementById('batch-progress-bar'); | |
| const progressLabel = document.getElementById('batch-progress-label'); | |
| const progressCount = document.getElementById('batch-progress-count'); | |
| const resultsDiv = document.getElementById('batch-results'); | |
| // Collect groups that have files | |
| const validGroups = []; | |
| groups.forEach(g => { | |
| const input = g.querySelector('.group-file-input'); | |
| const name = g.querySelector('.group-name').value || 'Unnamed'; | |
| if (input && input.files && input.files.length > 0) { | |
| validGroups.push({ name, files: input.files }); | |
| } | |
| }); | |
| if (validGroups.length === 0) return; | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50'); | |
| progressDiv.classList.remove('hidden'); | |
| progressDiv.classList.add('fade-in'); | |
| resultsDiv.innerHTML = ''; | |
| let completed = 0; | |
| const total = validGroups.length; | |
| progressCount.textContent = `0 / ${total}`; | |
| for (const group of validGroups) { | |
| progressLabel.textContent = `Processing "${group.name}"...`; | |
| // Step 1: Upload | |
| const uploadData = new FormData(); | |
| uploadData.append('product', product); | |
| for (const file of group.files) { | |
| uploadData.append('files', file); | |
| } | |
| let sessionId = null; | |
| try { | |
| const uploadResp = await fetch('/ui/upload', { method: 'POST', body: uploadData }); | |
| const uploadHtml = await uploadResp.text(); | |
| // Extract session_id from the response HTML | |
| const match = uploadHtml.match(/classify\/([a-f0-9-]+)/); | |
| if (!match) throw new Error('Upload failed — no session ID returned'); | |
| sessionId = match[1]; | |
| // Step 2: Auto-generate with default classifications | |
| const genData = new FormData(); | |
| genData.append('session_id', sessionId); | |
| genData.append('product', product); | |
| genData.append('output_format', format); | |
| genData.append('exclude_training', '1'); | |
| genData.append('batch_defaults', '1'); | |
| const genResp = await fetch('/ui/generate', { method: 'POST', body: genData }); | |
| const genHtml = await genResp.text(); | |
| const isSuccess = genHtml.includes('generate_success') || genHtml.includes('Files Ready') || genHtml.includes('download'); | |
| completed++; | |
| resultsDiv.innerHTML += ` | |
| <div class="flex items-center gap-3 px-4 py-2.5 rounded-lg ${isSuccess ? 'bg-emerald-50 border border-emerald-200' : 'bg-red-50 border border-red-200'} fade-in"> | |
| <svg class="w-4 h-4 ${isSuccess ? 'text-emerald-500' : 'text-red-500'} flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| ${isSuccess | |
| ? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>' | |
| : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>'} | |
| </svg> | |
| <span class="text-sm font-medium ${isSuccess ? 'text-emerald-700' : 'text-red-700'}">${group.name}</span> | |
| ${isSuccess && sessionId ? `<a href="/ui/download/${sessionId}" class="ml-auto text-xs font-medium text-brand-600 hover:text-brand-700">Download</a>` : ''} | |
| </div> | |
| `; | |
| } catch (err) { | |
| completed++; | |
| resultsDiv.innerHTML += ` | |
| <div class="flex items-center gap-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 fade-in"> | |
| <svg class="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| <span class="text-sm font-medium text-red-700">${group.name}: ${err.message}</span> | |
| </div> | |
| `; | |
| } | |
| progressCount.textContent = `${completed} / ${total}`; | |
| progressBar.style.width = (completed / total * 100) + '%'; | |
| } | |
| progressLabel.textContent = `Batch complete — ${completed} / ${total} processed`; | |
| btn.disabled = false; | |
| btn.classList.remove('opacity-50'); | |
| } | |
| // Enable drag-and-drop on group zones | |
| document.addEventListener('dragover', (e) => { | |
| const zone = e.target.closest('.group-drop-zone'); | |
| if (zone) { | |
| e.preventDefault(); | |
| zone.classList.add('border-brand-400', 'bg-brand-50'); | |
| } | |
| }); | |
| document.addEventListener('dragleave', (e) => { | |
| const zone = e.target.closest('.group-drop-zone'); | |
| if (zone) { | |
| zone.classList.remove('border-brand-400', 'bg-brand-50'); | |
| } | |
| }); | |
| document.addEventListener('drop', (e) => { | |
| const zone = e.target.closest('.group-drop-zone'); | |
| if (zone) { | |
| e.preventDefault(); | |
| zone.classList.remove('border-brand-400', 'bg-brand-50'); | |
| const input = zone.querySelector('.group-file-input'); | |
| if (input && e.dataTransfer.files.length) { | |
| input.files = e.dataTransfer.files; | |
| updateGroupFiles(input); | |
| } | |
| } | |
| }); | |
| </script> | |
| {% endblock %} | |