(function () { 'use strict'; // ── Styles ────────────────────────────────────────────────────────────── const css = ` #pipeline-toasts { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column-reverse; gap: .5rem; max-width: 22rem; pointer-events: none; } .pipeline-toast { display: flex; align-items: flex-start; gap: .6rem; padding: .65rem .9rem; border-radius: .45rem; background: #1e293b; color: #f1f5f9; font-size: .82rem; line-height: 1.35; box-shadow: 0 4px 16px rgba(0,0,0,.35); pointer-events: all; opacity: 0; transform: translateX(110%); transition: opacity .25s ease, transform .25s ease; } .pipeline-toast.visible { opacity: 1; transform: translateX(0); } .pipeline-toast.hiding { opacity: 0; transform: translateX(110%); } .pipeline-toast .pt-icon { font-size: 1rem; flex-shrink: 0; margin-top: .05rem; } .pipeline-toast.status-queued { border-left: 3px solid #60a5fa; } .pipeline-toast.status-processing { border-left: 3px solid #f59e0b; } .pipeline-toast.status-completed { border-left: 3px solid #22c55e; } .pipeline-toast.status-failed { border-left: 3px solid #ef4444; background: #2d1a1a; } .pipeline-toast.status-needs_review { border-left: 3px solid #f97316; } `; const ICONS = { queued: '🕐', processing: '⚙️', completed: '✅', failed: '❌', needs_review: '⚠️', }; const DISPLAY_SECONDS = { queued: 5, processing: 6, completed: 8, failed: 12, needs_review: 12, }; // ── DOM setup ──────────────────────────────────────────────────────────── function inject() { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); const container = document.createElement('div'); container.id = 'pipeline-toasts'; document.body.appendChild(container); return container; } function showToast(container, event) { const status = event.status ?? 'queued'; const icon = ICONS[status] ?? '🔔'; const ttl = (DISPLAY_SECONDS[status] ?? 6) * 1000; const toast = document.createElement('div'); toast.className = `pipeline-toast status-${status}`; toast.innerHTML = `${icon}` + `${escapeHtml(event.message)}`; container.appendChild(toast); // Animate in requestAnimationFrame(() => { requestAnimationFrame(() => toast.classList.add('visible')); }); // Animate out and remove const hide = () => { toast.classList.add('hiding'); toast.addEventListener('transitionend', () => toast.remove(), { once: true }); }; const timer = setTimeout(hide, ttl); toast.addEventListener('click', () => { clearTimeout(timer); hide(); }); } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── SSE connection ─────────────────────────────────────────────────────── function connect(container, lastEventId) { const url = lastEventId ? `/admin/pipeline/events?lastEventId=${encodeURIComponent(lastEventId)}` : '/admin/pipeline/events'; const es = new EventSource(url); let storedLastId = lastEventId ?? null; es.onmessage = (e) => { if (e.lastEventId) storedLastId = e.lastEventId; try { const event = JSON.parse(e.data); if (event && event.message) showToast(container, event); } catch (_) { /* ignore malformed frames */ } }; es.addEventListener('reconnect', () => { es.close(); // Small delay so the server-side loop has fully exited setTimeout(() => connect(container, storedLastId), 500); }); es.onerror = () => { es.close(); // Browser will retry via EventSource built-in retry, but we also // reconnect manually to pass the last event ID properly. setTimeout(() => connect(container, storedLastId), 4000); }; } // ── Boot ───────────────────────────────────────────────────────────────── if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } function boot() { const container = inject(); connect(container, null); } }());