/* Dashboard — overview statistics, pipeline flow, task progress */ var _dashTimer = null; var _dashVisible = true; var _dashController = null; var _dashLoadEpoch = 0; // ── Formatting ──────────────────────────────────────── function fmtNum(n) { if (n == null) return '0'; return n.toLocaleString('en-US'); } function fmtToday(n) { if (!n || n <= 0) return ''; return '+' + fmtNum(n) + ' today'; } function timeAgo(isoStr) { if (!isoStr) return ''; var diff = Date.now() - new Date(isoStr).getTime(); var sec = Math.floor(diff / 1000); if (sec < 60) return 'just now'; if (sec < 3600) return Math.floor(sec / 60) + 'm ago'; if (sec < 86400) return Math.floor(sec / 3600) + 'h ago'; return Math.floor(sec / 86400) + 'd ago'; } function statusBadge(status) { var map = { 'completed': { cls: 'bg-yellow-900/50 text-yellow-400', label: t('stat_reviewing') }, 'to_review': { cls: 'bg-yellow-900/50 text-yellow-400', label: t('stat_reviewing') }, 'reviewed': { cls: 'bg-green-900/50 text-green-400', label: t('stat_approved') }, 'approved': { cls: 'bg-green-900/50 text-green-400', label: t('stat_approved') }, 'processing': { cls: 'bg-blue-900/50 text-blue-400', label: t('stat_processing') }, 'received': { cls: 'bg-indigo-900/50 text-indigo-400', label: t('stat_received') }, 'failed': { cls: 'bg-red-900/50 text-red-400', label: t('stat_failed') }, }; var m = map[status] || { cls: 'bg-gray-800 text-gray-400', label: status || 'unknown' }; return '' + m.label + ''; } // ── Load all data ───────────────────────────────────── async function refreshDashboard() { if (_dashController) _dashController.abort(); var controller = new AbortController(); _dashController = controller; var loadEpoch = ++_dashLoadEpoch; var btn = document.getElementById('btn-refresh'); if (btn) { btn.disabled = true; var icon = btn.querySelector('iconify-icon'); if (icon) icon.setAttribute('icon', 'ant-design:loading-outlined'); } // These four panels are independent. Request them together so a slow // remote-storage read cannot delay all of the other dashboard sections. const results = await Promise.allSettled([ fetch('/api/v1/dashboard/overview', { signal: controller.signal }), fetch('/api/v1/dashboard/recent-episodes?limit=6', { signal: controller.signal }), fetch('/api/v1/projects/summary?limit=5', { signal: controller.signal }), fetch('/api/v1/dashboard/trend?days=30', { signal: controller.signal }), ]); if (loadEpoch !== _dashLoadEpoch) return; const [overview, recent, projectSummary, trend] = results; try { if (overview.status !== 'fulfilled' || !overview.value.ok) { throw new Error('Overview request failed'); } renderStats(await overview.value.json()); hideError(); } catch (e) { showError(); } try { if (recent.status === 'fulfilled' && recent.value.ok) { renderRecent((await recent.value.json()).episodes || []); } } catch (e) { /* silent */ } try { if (projectSummary.status === 'fulfilled' && projectSummary.value.ok) { renderProjectSummary((await projectSummary.value.json()).tasks || []); } } catch (e) { /* silent */ } try { if (trend.status === 'fulfilled' && trend.value.ok) { const trendData = await trend.value.json(); renderTrend(trendData.labels || [], trendData.counts || []); } } catch (e) { /* silent */ } if (btn && _dashController === controller) { btn.disabled = false; var icon = btn.querySelector('iconify-icon'); if (icon) icon.setAttribute('icon', 'ant-design:reload-outlined'); } if (_dashController === controller) _dashController = null; } // ── 4 Stat cards ────────────────────────────────────── function setCardValue(id, value) { var el = document.getElementById(id); if (el) el.textContent = fmtNum(value); } function setCardToday(id, today) { var el = document.getElementById(id); if (!el) return; var text = fmtToday(today); if (text) { el.textContent = text; el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } function renderStats(data) { document.querySelectorAll('.stat-card-skeleton').forEach(function(el) { el.remove(); }); var real = document.getElementById('stat-cards-real'); if (real) real.classList.remove('hidden'); setCardValue('stat-total-total', data.total ? data.total.total : 0); setCardToday('stat-total-today', data.total ? data.total.today : 0); var reviewing = data.reviewing ? data.reviewing.total : 0; var approved = data.approved ? data.approved.total : 0; var failed = data.failed ? data.failed.total : 0; setCardValue('stat-reviewing-total', reviewing); setCardToday('stat-reviewing-today', data.reviewing ? data.reviewing.today : 0); setCardValue('stat-approved-total', approved); setCardToday('stat-approved-today', data.approved ? data.approved.today : 0); setCardValue('stat-failed-total', failed); // Donut chart renderDonut(reviewing, approved, failed); } // ── Projects (原 Task progress,任务概念已移除) ─────── function renderProjectSummary(projects) { var el = document.getElementById('task-progress-list'); if (!el) return; if (!projects || projects.length === 0) { el.innerHTML = `
${t('no_projects')}
`; return; } el.innerHTML = projects.map(function(p) { return '' + '
' + '' + '' + escHtml(p.task_name || '') + '' + '' + fmtNum(p.total_episodes) + ' episodes' + '
' + '
'; }).join(''); } // ── Recent episodes ────────────────────────────────── function renderRecent(episodes) { var el = document.getElementById('recent-list'); if (!el) return; if (!episodes || episodes.length === 0) { el.innerHTML = '
No episodes yet
'; return; } el.innerHTML = episodes.map(function(ep) { var cleanIcon = ''; if (ep.cleaning_passed === false) { cleanIcon = ' '; } else if (ep.cleaning_passed === true) { cleanIcon = ' '; } return '' + '' + (ep.task_name || '').slice(0, 18) + cleanIcon + '' + '' + (ep.frame_count || 0) + 'f' + '' + statusBadge(ep.status) + '' + '' + timeAgo(ep.received_at) + '' + ''; }).join(''); } // ── Donut chart (SVG) ──────────────────────────────── function renderDonut(reviewing, approved, failed) { var el = document.getElementById('donut-chart'); if (!el) return; var total = reviewing + approved + failed || 1; var segments = [ { value: reviewing, color: '#eab308', label: t('stat_to_review') }, // yellow { value: approved, color: '#22c55e', label: t('stat_approved') }, // green { value: failed, color: '#ef4444', label: t('stat_failed') }, // red ]; var radius = 60; var stroke = 16; var circumference = 2 * Math.PI * radius; var cx = 100, cy = 100, size = 200; // Build SVG var html = ''; // Background ring html += ''; // Segments var offset = -Math.PI / 2; // start from top segments.forEach(function(seg) { if (seg.value <= 0) return; var dashLen = (seg.value / total) * circumference; var dashGap = circumference - dashLen; var rotation = (offset / (2 * Math.PI)) * 360; html += ''; offset += (seg.value / total) * 2 * Math.PI; }); // Center text html += '' + fmtNum(total) + ''; html += '' + t('donut_total') + ''; html += ''; // Legend html += '
'; segments.forEach(function(seg) { html += '
' + '' + '' + seg.label + '' + '' + fmtNum(seg.value) + '' + '
'; }); html += '
'; el.innerHTML = html; } // ── Trend bar chart (div) ──────────────────────────── function renderTrend(labels, counts) { var el = document.getElementById('trend-chart'); if (!el || !labels.length) return; var max = 0; for (var i = 0; i < counts.length; i++) { if (counts[i] > max) max = counts[i]; } max = max || 1; var html = '
'; for (var i = 0; i < labels.length; i++) { var h = Math.max(4, Math.round((counts[i] / max) * 100)); var isMax = counts[i] === max && max > 0; var barCls = isMax ? 'bg-blue-500 hover:bg-blue-400' : 'bg-gray-700 hover:bg-gray-600'; html += '
' + '' + (counts[i] || '') + '' + '
' + '
'; } html += '
'; // X-axis labels (show ~7 evenly spaced) var step = Math.max(1, Math.floor(labels.length / 7)); html += '
'; for (var i = 0; i < labels.length; i++) { html += '
'; if (i % step === 0) { html += '' + labels[i] + ''; } html += '
'; } html += '
'; el.innerHTML = html; } // ── Error handling ─────────────────────────────────── function showError() { document.querySelectorAll('.stat-card-skeleton').forEach(function(el) { el.remove(); }); var real = document.getElementById('stat-cards-real'); if (real) real.classList.remove('hidden'); var err = document.getElementById('stat-error'); if (err) err.classList.remove('hidden'); } function hideError() { var err = document.getElementById('stat-error'); if (err) err.classList.add('hidden'); } function escHtml(s) { if (!s) return ''; return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── Auto-refresh (30s) ─────────────────────────────── function startAutoRefresh() { stopAutoRefresh(); _dashTimer = setInterval(function() { if (_dashVisible) refreshDashboard(); }, 30000); } function stopAutoRefresh() { if (_dashTimer) { clearInterval(_dashTimer); _dashTimer = null; } } document.addEventListener('visibilitychange', function() { _dashVisible = !document.hidden; if (_dashVisible) refreshDashboard(); }); // ── Init ───────────────────────────────────────────── document.addEventListener('DOMContentLoaded', function() { refreshDashboard(); startAutoRefresh(); });