159 lines
5.2 KiB
JavaScript
159 lines
5.2 KiB
JavaScript
|
|
(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 =
|
||
|
|
`<span class="pt-icon">${icon}</span>` +
|
||
|
|
`<span>${escapeHtml(event.message)}</span>`;
|
||
|
|
|
||
|
|
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, '>')
|
||
|
|
.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);
|
||
|
|
}
|
||
|
|
}());
|