const $ = (s) => document.querySelector(s); const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }; let currentEventSource = null; function formatDate(s) { if (!s) return '—'; return new Date(s + 'Z').toLocaleString(); } function duration(start, end) { if (!start) return '—'; const s = new Date(start + 'Z').getTime(); const e = end ? new Date(end + 'Z').getTime() : Date.now(); const secs = Math.max(0, Math.round((e - s) / 1000)); if (secs < 60) return `${secs}s`; return `${Math.floor(secs / 60)}m ${secs % 60}s`; } function statusBadge(status) { const running = ['preparing', 'signing', 'archiving', 'exporting', 'uploading'].includes(status); const cls = status === 'succeeded' ? 'succeeded' : status === 'failed' ? 'failed' : running ? 'running' : 'pending'; return `${status}`; } async function loadJobs() { const r = await fetch('/api/builds'); if (r.status === 401) { location.href = '/login'; return; } const jobs = await r.json(); const container = $('#jobs-container'); if (!jobs.length) { container.innerHTML = '

No builds yet. Start one from New Build.

'; return; } container.innerHTML = ` ${jobs.map(j => ` `).join('')}
StatusBundleSourceStartedDuration
${statusBadge(j.status)} ${esc(j.bundle_id) || ''} ${esc(j.source_kind)}: ${esc((j.source_ref || '').slice(0, 40))} ${esc(formatDate(j.started_at))} ${esc(duration(j.started_at, j.finished_at))} ${j.install_url ? `Install` : ''}
`; container.querySelectorAll('tbody tr').forEach((tr) => { tr.addEventListener('click', () => openJob(tr.getAttribute('data-id'))); }); } async function openJob(id) { location.hash = id; const r = await fetch(`/api/builds/${id}`); if (!r.ok) return; const job = await r.json(); $('#detail').style.display = 'block'; $('#detail-title').textContent = `Job ${id.slice(0, 8)} · ${job.status}`; $('#detail-meta').innerHTML = `
bundle: ${esc(job.bundle_id || '—')}
scheme: ${esc(job.scheme || '—')}
source: ${esc(job.source_kind)} ${esc(job.source_ref || '')}
started: ${esc(formatDate(job.started_at))} · finished: ${esc(formatDate(job.finished_at))}
${job.install_url ? `
install: ${esc(job.install_url.slice(0, 80))}…
` : ''} ${job.error ? `
error: ${esc(job.error)}
` : ''} `; const logEl = $('#log-viewer'); logEl.textContent = ''; if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } const es = new EventSource(`/api/builds/${id}/logs`); currentEventSource = es; es.onmessage = (ev) => { logEl.textContent += ev.data + '\n'; logEl.scrollTop = logEl.scrollHeight; }; es.addEventListener('done', () => { es.close(); currentEventSource = null; // Refresh the job list so status pill updates. loadJobs(); }); es.onerror = () => { es.close(); }; } loadJobs(); setInterval(loadJobs, 5000); // If arriving with a hash, open that job. if (location.hash.length > 1) { openJob(location.hash.slice(1)); }