Rewrites the builder console to browse local Xcode projects instead of accepting source uploads or git URLs. Replaces the devices page with a profiles page that manages ad-hoc provisioning profiles and lists registered bundle IDs per team. Adds multi-account support: ASC API keys are now stored in an asc_keys table keyed by team_id (team_name, key_id, issuer_id, p8_filename). At build time, the worker reads DEVELOPMENT_TEAM from the Xcode project and auto-picks the matching key for fastlane sigh + JWT signing. Legacy single-key settings auto-migrate on first boot. Fixes storefront IPA parser to handle binary plists produced by Xcode. Drops the enrollment bridge, device management routes, and direct ASC API client -- fastlane sigh handles profile lifecycle now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
3.9 KiB
JavaScript
108 lines
3.9 KiB
JavaScript
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 `<span class="badge ${cls}">${status}</span>`;
|
|
}
|
|
|
|
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 = '<div class="card"><p style="color:var(--text-muted)">No builds yet. Start one from <a href="/build" style="color:var(--accent)">New Build</a>.</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<table class="table">
|
|
<thead>
|
|
<tr><th>Status</th><th>Bundle</th><th>Project</th><th>Started</th><th>Duration</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${jobs.map(j => `
|
|
<tr data-id="${j.id}" style="cursor:pointer">
|
|
<td>${statusBadge(j.status)}</td>
|
|
<td class="mono">${esc(j.bundle_id) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
|
<td class="mono">${esc((j.source_ref || '').replace(/\.(xcodeproj|xcworkspace)$/, ''))}</td>
|
|
<td class="mono">${esc(formatDate(j.started_at))}</td>
|
|
<td class="mono">${esc(duration(j.started_at, j.finished_at))}</td>
|
|
<td>${j.install_url ? `<a href="${esc(j.install_url)}" class="btn-sm" style="background:var(--accent);color:white;padding:5px 12px;border-radius:14px;text-decoration:none;font-size:12px">Install</a>` : ''}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
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 = `
|
|
<div>bundle: ${esc(job.bundle_id || '—')}</div>
|
|
<div>scheme: ${esc(job.scheme || '—')}</div>
|
|
<div>project: ${esc(job.source_ref || job.project_path || '--')}</div>
|
|
<div>started: ${esc(formatDate(job.started_at))} · finished: ${esc(formatDate(job.finished_at))}</div>
|
|
${job.install_url ? `<div>install: <a href="${esc(job.install_url)}" style="color:var(--accent)">${esc(job.install_url.slice(0, 80))}…</a></div>` : ''}
|
|
${job.error ? `<div style="color:var(--danger)">error: ${esc(job.error)}</div>` : ''}
|
|
`;
|
|
|
|
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));
|
|
}
|