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 = '
';
return;
}
container.innerHTML = `
| Status | Bundle | Source | Started | Duration | |
${jobs.map(j => `
| ${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` : ''} |
`).join('')}
`;
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 ? `` : ''}
${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));
}