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