diff --git a/services/web-ui/public/jobs.html b/services/web-ui/public/jobs.html index 6f142fd..4ee2352 100644 --- a/services/web-ui/public/jobs.html +++ b/services/web-ui/public/jobs.html @@ -479,7 +479,7 @@
- @@ -490,7 +490,7 @@
- Live + Connecting…
@@ -525,9 +525,9 @@ ──────────────────────────────────────────────────────── */ const API = '/api/v1'; -let allJobs = []; +let allJobs = []; let currentFilter = 'all'; -let refreshTimer = null; +let sseSource = null; let activeCount = 0; /* ──────────────────────────────────────────────────────── @@ -547,33 +547,46 @@ async function api(path, opts = {}) { } /* ──────────────────────────────────────────────────────── - Load jobs + SSE live feed ──────────────────────────────────────────────────────── */ -async function loadJobs() { - const type = document.getElementById('type-filter').value; - try { - let url = '/jobs'; - const params = []; - if (type) params.push(`type=${encodeURIComponent(type)}`); - if (params.length) url += '?' + params.join('&'); +function startSSE() { + const dot = document.getElementById('refresh-dot'); + const label = document.getElementById('refresh-label'); - const data = await api(url); - allJobs = Array.isArray(data) ? data : (data.jobs || []); + if (sseSource) { sseSource.close(); sseSource = null; } - updateStats(); - updateCounts(); - renderJobs(); - scheduleRefresh(); - } catch (e) { - showError('Failed to load jobs: ' + e.message); - } + sseSource = new EventSource('/api/v1/jobs/events'); + + sseSource.addEventListener('open', () => { + dot.classList.add('live'); + label.textContent = 'Live'; + }); + + sseSource.addEventListener('message', (ev) => { + try { + const payload = JSON.parse(ev.data); + if (payload.type !== 'jobs') return; + allJobs = payload.jobs; + updateStats(); + updateCounts(); + renderJobs(); + } catch (_) {} + }); + + sseSource.addEventListener('error', () => { + dot.classList.remove('live'); + label.textContent = 'Reconnecting…'; + }); } +/* ──────────────────────────────────────────────────────── + Stats + counts +──────────────────────────────────────────────────────── */ function updateStats() { - const total = allJobs.length; - const active = allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length; - const done = allJobs.filter(j => j.status === 'completed').length; - const failed = allJobs.filter(j => j.status === 'failed').length; + const total = allJobs.length; + const active = allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length; + const done = allJobs.filter(j => j.status === 'completed').length; + const failed = allJobs.filter(j => j.status === 'failed').length; activeCount = active; @@ -584,11 +597,13 @@ function updateStats() { } function updateCounts() { + const typeFilter = document.getElementById('type-filter').value; + const base = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs; const counts = { - all: allJobs.length, - active: allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length, - completed: allJobs.filter(j => j.status === 'completed').length, - failed: allJobs.filter(j => j.status === 'failed').length + all: base.length, + active: base.filter(j => j.status === 'active' || j.status === 'waiting').length, + completed: base.filter(j => j.status === 'completed').length, + failed: base.filter(j => j.status === 'failed').length }; for (const [k, v] of Object.entries(counts)) { const el = document.getElementById('cnt-' + k); @@ -600,11 +615,12 @@ function updateCounts() { Render ──────────────────────────────────────────────────────── */ function getFilteredJobs() { - if (currentFilter === 'all') return allJobs; - if (currentFilter === 'active') return allJobs.filter(j => j.status === 'active' || j.status === 'waiting'); - if (currentFilter === 'completed') return allJobs.filter(j => j.status === 'completed'); - if (currentFilter === 'failed') return allJobs.filter(j => j.status === 'failed'); - return allJobs; + const typeFilter = document.getElementById('type-filter').value; + let jobs = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs; + if (currentFilter === 'active') return jobs.filter(j => j.status === 'active' || j.status === 'waiting'); + if (currentFilter === 'completed') return jobs.filter(j => j.status === 'completed'); + if (currentFilter === 'failed') return jobs.filter(j => j.status === 'failed'); + return jobs; } function renderJobs() { @@ -706,7 +722,7 @@ async function killJob(jobId, ev) { if (!confirm('Remove this job from the queue? If a worker is still processing it, the run is abandoned.')) return; try { const r = await fetch('/api/v1/jobs/' + encodeURIComponent(jobId), { method: 'DELETE', credentials: 'include' }); - if (r.ok) { toast('Job removed', 'success'); loadJobs(); } + if (r.ok) { toast('Job removed', 'success'); } else { const d = await r.json().catch(()=>({})); toast('Remove failed: ' + (d.error || r.statusText), 'error'); } } catch (err) { toast('Remove failed: ' + err.message, 'error'); @@ -718,7 +734,6 @@ async function retryJob(assetId, ev) { try { await api('/assets/' + encodeURIComponent(assetId) + '/retry', { method: 'POST' }); toast('Job re-queued — processing will restart shortly.'); - loadJobs(); } catch (e) { showError('Retry failed: ' + e.message); } @@ -745,24 +760,6 @@ function setFilter(filter, btn) { renderJobs(); } -/* ──────────────────────────────────────────────────────── - Auto-refresh -──────────────────────────────────────────────────────── */ -function scheduleRefresh() { - clearTimeout(refreshTimer); - const dot = document.getElementById('refresh-dot'); - const label = document.getElementById('refresh-label'); - - if (activeCount > 0) { - dot.classList.add('live'); - label.textContent = 'Live'; - refreshTimer = setTimeout(loadJobs, 5000); - } else { - dot.classList.remove('live'); - label.textContent = 'Idle'; - } -} - /* ──────────────────────────────────────────────────────── Detail panel ──────────────────────────────────────────────────────── */ @@ -841,8 +838,6 @@ function closeDetail() { document.getElementById('detail-overlay').classList.remove('open'); } -/* ── formatFileSize + formatDuration from common utils ── */ - /* ──────────────────────────────────────────────────────── Clear completed ──────────────────────────────────────────────────────── */ @@ -853,7 +848,6 @@ async function clearCompleted() { try { await Promise.all(completed.map(j => api(`/jobs/${j.id}`, { method: 'DELETE' }).catch(() => {}))); toast(`Cleared ${completed.length} completed job${completed.length === 1 ? '' : 's'}.`); - loadJobs(); } catch (e) { showError('Failed to clear jobs: ' + e.message); } @@ -900,7 +894,7 @@ function showError(msg) { toast(msg, 'error'); } /* ──────────────────────────────────────────────────────── Init ──────────────────────────────────────────────────────── */ -loadJobs(); +startSSE();