Phase 4-5: build pipeline + device enrollment
Builder service (Mac mini): - Build worker: xcodebuild archive + export + fastlane signing + upload to unraid - /api/build/upload (source archive) and /api/build/git (clone) ingest paths - SSE-streamed build logs, builds list UI, live status updates - /api/devices/from-enrollment bridge endpoint (shared-secret auth) Storefront (unraid): - /enroll/ public flow: landing page, mobileconfig generator, callback parser - Forwards extracted UDIDs to the Mac mini builder for ASC registration - docker-compose.yml now passes BUILDER_URL and BUILDER_SHARED_SECRET Updated CLAUDE.md with full architecture, deploy flow, and gotchas.
This commit is contained in:
51
builder/public/js/build.js
Normal file
51
builder/public/js/build.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
function toast(msg, kind = '') {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show ' + kind;
|
||||
setTimeout(() => t.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
$('#upload-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Uploading…';
|
||||
try {
|
||||
const r = await fetch('/api/build/upload', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Upload failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
}
|
||||
});
|
||||
|
||||
$('#git-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
url: e.target.url.value.trim(),
|
||||
branch: e.target.branch.value.trim() || null,
|
||||
scheme: e.target.scheme.value.trim() || null,
|
||||
};
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Cloning…';
|
||||
try {
|
||||
const r = await fetch('/api/build/git', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Clone failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
}
|
||||
});
|
||||
107
builder/public/js/builds.js
Normal file
107
builder/public/js/builds.js
Normal file
@@ -0,0 +1,107 @@
|
||||
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>Source</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_kind)}: ${esc((j.source_ref || '').slice(0, 40))}</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>source: ${esc(job.source_kind)} ${esc(job.source_ref || '')}</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));
|
||||
}
|
||||
Reference in New Issue
Block a user