// 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);
}
});