// logs.js - Unified Logs page with SSE live feed let currentRunId = null; let eventSource = null; let runsPage = 1; let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 }; function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') .replace(/'/g, '''); } function fmtTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch (e) { return ''; } } function fmtDatetime(iso) { if (!iso) return '-'; try { return new Date(iso).toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch (e) { return iso; } } function fmtDuration(startedAt, finishedAt) { if (!startedAt || !finishedAt) return '-'; const diffMs = new Date(finishedAt) - new Date(startedAt); if (isNaN(diffMs) || diffMs < 0) return '-'; const secs = Math.round(diffMs / 1000); if (secs < 60) return secs + 's'; return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; } function statusBadge(status) { switch ((status || '').toUpperCase()) { case 'IMPORTED': return 'IMPORTED'; case 'SKIPPED': return 'SKIPPED'; case 'ERROR': return 'ERROR'; default: return `${esc(status || '-')}`; } } function runStatusBadge(status) { switch ((status || '').toLowerCase()) { case 'completed': return 'completed'; case 'running': return 'running'; case 'failed': return 'failed'; default: return `${esc(status)}`; } } // ── Runs Table ────────────────────────────────── async function loadRuns(page) { if (page != null) runsPage = page; const perPage = 20; try { const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const runs = data.runs || []; const total = data.total || runs.length; // Populate dropdown const sel = document.getElementById('runSelector'); sel.innerHTML = '' + runs.map(r => { const date = fmtDatetime(r.started_at); const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`; return ``; }).join(''); // Populate table const tbody = document.getElementById('runsTableBody'); if (runs.length === 0) { tbody.innerHTML = 'Niciun sync run'; } else { tbody.innerHTML = runs.map(r => { const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-'; const duration = fmtDuration(r.started_at, r.finished_at); const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; const activeClass = r.run_id === currentRunId ? 'table-active' : ''; return ` ${started} ${esc(r.status)} ${r.total_orders || 0} ${r.imported || 0} ${r.skipped || 0} ${r.errors || 0} ${duration} `; }).join(''); } // Pagination const pagDiv = document.getElementById('runsTablePagination'); const totalPages = Math.ceil(total / perPage); if (totalPages > 1) { pagDiv.innerHTML = ` ${runsPage} / ${totalPages} `; } else { pagDiv.innerHTML = ''; } } catch (err) { document.getElementById('runsTableBody').innerHTML = `${esc(err.message)}`; } } // ── Run Selection ─────────────────────────────── async function selectRun(runId) { if (eventSource) { eventSource.close(); eventSource = null; } currentRunId = runId; // Update URL without reload const url = new URL(window.location); if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); } history.replaceState(null, '', url); // Highlight active row in table document.querySelectorAll('#runsTableBody tr').forEach(tr => { tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`); }); // Update dropdown document.getElementById('runSelector').value = runId || ''; if (!runId) { document.getElementById('runDetailSection').style.display = 'none'; return; } document.getElementById('runDetailSection').style.display = ''; // Check if this run is currently active try { const statusRes = await fetch('/api/sync/status'); const statusData = await statusRes.json(); if (statusData.status === 'running' && statusData.run_id === runId) { startLiveFeed(runId); return; } } catch (e) { /* fall through to historical load */ } // Load historical data document.getElementById('liveFeedCard').style.display = 'none'; await loadRunLog(runId); } // ── Live SSE Feed ─────────────────────────────── function startLiveFeed(runId) { liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 }; // Show live feed card, clear it const feedCard = document.getElementById('liveFeedCard'); feedCard.style.display = ''; document.getElementById('liveFeed').innerHTML = ''; document.getElementById('logsBody').innerHTML = ''; // Reset summary document.getElementById('sum-total').textContent = '-'; document.getElementById('sum-imported').textContent = '0'; document.getElementById('sum-skipped').textContent = '0'; document.getElementById('sum-errors').textContent = '0'; document.getElementById('sum-duration').textContent = 'live...'; connectSSE(); } function connectSSE() { if (eventSource) eventSource.close(); eventSource = new EventSource('/api/sync/stream'); eventSource.onmessage = function(e) { let event; try { event = JSON.parse(e.data); } catch (err) { return; } if (event.type === 'keepalive') return; if (event.type === 'phase') { appendFeedEntry('phase', event.message); } else if (event.type === 'order_result') { const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌'; const progressText = event.progress ? `[${event.progress}]` : ''; appendFeedEntry( event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '', `${progressText} #${event.order_number} ${event.customer_name || ''} → ${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}` ); addOrderRow(event); updateLiveSummary(event); } else if (event.type === 'completed') { appendFeedEntry('phase', '🏁 Sync completed'); eventSource.close(); eventSource = null; document.querySelector('.live-pulse')?.remove(); // Reload full data from REST after short delay setTimeout(() => { loadRunLog(currentRunId); loadRuns(); }, 500); } else if (event.type === 'failed') { appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error')); eventSource.close(); eventSource = null; document.querySelector('.live-pulse')?.remove(); setTimeout(() => { loadRunLog(currentRunId); loadRuns(); }, 500); } }; eventSource.onerror = function() { // SSE disconnected — try to load historical data eventSource.close(); eventSource = null; setTimeout(() => loadRunLog(currentRunId), 1000); }; } function appendFeedEntry(type, message) { const feed = document.getElementById('liveFeed'); const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const typeClass = type ? ` ${type}` : ''; const iconMap = { phase: 'ℹ️', error: '❌', success: '✅' }; const icon = iconMap[type] || '▶'; const entry = document.createElement('div'); entry.className = `feed-entry${typeClass}`; entry.innerHTML = `${now}${icon}${esc(message)}`; feed.appendChild(entry); // Auto-scroll to bottom feed.scrollTop = feed.scrollHeight; } function addOrderRow(event) { const tbody = document.getElementById('logsBody'); const status = (event.status || '').toUpperCase(); let details = ''; if (event.error_message) { details = `${esc(event.error_message)}`; } if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) { details += `
${event.missing_skus.map(s => `${esc(s)}`).join('')}
`; } if (event.id_comanda) { details += `ID: ${event.id_comanda}`; } if (!details) details = '-'; const tr = document.createElement('tr'); tr.setAttribute('data-status', status); tr.innerHTML = ` ${esc(event.order_number || '-')} ${esc(event.customer_name || '-')} ${event.items_count ?? '-'} ${statusBadge(status)} ${details} `; tbody.appendChild(tr); } function updateLiveSummary(event) { liveCounts.total++; if (event.status === 'IMPORTED') liveCounts.imported++; else if (event.status === 'SKIPPED') liveCounts.skipped++; else if (event.status === 'ERROR') liveCounts.errors++; document.getElementById('sum-total').textContent = liveCounts.total; document.getElementById('sum-imported').textContent = liveCounts.imported; document.getElementById('sum-skipped').textContent = liveCounts.skipped; document.getElementById('sum-errors').textContent = liveCounts.errors; } // ── Historical Run Log ────────────────────────── async function loadRunLog(runId) { const tbody = document.getElementById('logsBody'); tbody.innerHTML = '
Se incarca...'; try { const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const run = data.run || {}; const orders = data.orders || []; // Populate summary bar document.getElementById('sum-total').textContent = run.total_orders ?? '-'; document.getElementById('sum-imported').textContent = run.imported ?? '-'; document.getElementById('sum-skipped').textContent = run.skipped ?? '-'; document.getElementById('sum-errors').textContent = run.errors ?? '-'; document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at); if (orders.length === 0) { const runError = run.error_message ? `${esc(run.error_message)}` : 'Nicio comanda in acest sync run'; tbody.innerHTML = runError; updateFilterCount(); return; } tbody.innerHTML = orders.map(order => { const status = (order.status || '').toUpperCase(); let missingSkuTags = ''; if (order.missing_skus) { try { const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus; if (Array.isArray(skus) && skus.length > 0) { missingSkuTags = '
' + skus.map(s => `${esc(s)}`).join('') + '
'; } } catch (e) { /* skip */ } } const details = order.error_message ? `${esc(order.error_message)}${missingSkuTags}` : missingSkuTags || '-'; return ` ${esc(order.order_number || '-')} ${esc(order.customer_name || '-')} ${order.items_count ?? '-'} ${statusBadge(status)} ${details} `; }).join(''); // Reset filter document.querySelectorAll('[data-filter]').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === 'all'); }); applyFilter('all'); } catch (err) { tbody.innerHTML = `${esc(err.message)}`; } } // ── Filters ───────────────────────────────────── function applyFilter(filter) { const rows = document.querySelectorAll('#logsBody tr[data-status]'); let visible = 0; rows.forEach(row => { const show = filter === 'all' || row.dataset.status === filter; row.style.display = show ? '' : 'none'; if (show) visible++; }); updateFilterCount(visible, rows.length, filter); } function updateFilterCount(visible, total, filter) { const el = document.getElementById('filterCount'); if (!el) return; if (visible == null) { el.textContent = ''; return; } el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`; } // ── Init ──────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { loadRuns(); // Dropdown change document.getElementById('runSelector').addEventListener('change', function() { selectRun(this.value); }); // Filter buttons document.querySelectorAll('[data-filter]').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active')); this.classList.add('active'); applyFilter(this.dataset.filter); }); }); // Auto-select run from URL or server const preselected = document.getElementById('preselectedRun'); const urlParams = new URLSearchParams(window.location.search); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); if (runFromUrl) { selectRun(runFromUrl); } });