mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-07 21:41:09 -05:00
Add controller with web UI and retire CLI
- Add ctrl/ package: Flask app (waitress), SQLite job store, background scheduler that probes configured workers, dispatches via /upload and /checkout_git, fetches artifacts, and serves a single-page UI - Supporting API: /api/jobs (create/list/detail/cancel), /api/workers, /api/capabilities, artifact download, and SSE log stream - Workers keep the existing HTTP API; add /progress/<job_id> endpoint and per-step build tracking for live status - Retire agent_manager.py and the cross-py-builder CLI; controller UI is the primary frontend - Record design in DESIGN.md; add waitress dependency Note: /progress is exposed on the worker but the synchronous /upload and /checkout endpoints block until a build completes, so per-step worker progress is not yet relayed live in the UI. Live streaming needs an async-start worker endpoint as a follow-up.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"use strict";
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const BADGES = { done: "done", failed: "failed", cancelled: "cancelled", queued: "queued", dispatching: "dispatching", building: "building" };
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
||||
);
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { msg = (await resp.json()).error || msg; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.status === 204 ? null : resp.json();
|
||||
}
|
||||
|
||||
async function refreshCapabilities() {
|
||||
try {
|
||||
const caps = await api("/api/capabilities");
|
||||
const os = new Set(caps.map((c) => c.os).filter(Boolean));
|
||||
const cpu = new Set(caps.map((c) => c.cpu).filter(Boolean));
|
||||
fillSelect($("#os"), os);
|
||||
fillSelect($("#cpu"), cpu);
|
||||
} catch (e) {
|
||||
$("#submit-msg").textContent = `Could not reach controller API: ${e.message}`;
|
||||
$("#submit-msg").className = "msg error";
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(sel, values) {
|
||||
const current = sel.value;
|
||||
sel.innerHTML = `<option value="">Any</option>`;
|
||||
[...values].sort().forEach((v) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v;
|
||||
opt.textContent = v;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (current) sel.value = current;
|
||||
}
|
||||
|
||||
async function refreshWorkers() {
|
||||
try {
|
||||
const workers = await api("/api/workers");
|
||||
const online = workers.filter((w) => w.online).length;
|
||||
const parts = workers.map((w) =>
|
||||
`${w.host || w.url}${w.online ? "" : " (down)"}`
|
||||
);
|
||||
$("#worker-summary").textContent =
|
||||
`${online}/${workers.length} workers online — ${parts.join(" · ")}`;
|
||||
} catch (_) {
|
||||
$("#worker-summary").textContent = "Worker summary unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshJobs() {
|
||||
let jobs;
|
||||
try {
|
||||
jobs = await api("/api/jobs");
|
||||
} catch (e) {
|
||||
$("#jobs").innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
const box = $("#jobs");
|
||||
if (!jobs.length) {
|
||||
box.innerHTML = `<p class="muted">No jobs yet.</p>`;
|
||||
return;
|
||||
}
|
||||
let html = `<table>
|
||||
<tr><th>ID</th><th>Status</th><th>Source</th><th>Target</th><th>Created</th></tr>`;
|
||||
for (const j of jobs) {
|
||||
html += `<tr class="clickable" data-id="${esc(j.id)}">
|
||||
<td><code>${esc(j.id)}</code></td>
|
||||
<td><span class="badge ${BADGES[j.status] || ""}">${esc(j.status)}</span></td>
|
||||
<td>${esc(j.source)}</td>
|
||||
<td>${esc(j.os_req || "")} ${esc(j.cpu_req || "")}</td>
|
||||
<td>${esc(shortTime(j.created_at))}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</table>`;
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
function shortTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
function badgeFor(status) {
|
||||
return `<span class="badge ${BADGES[status] || ""}">${esc(status)}</span>`;
|
||||
}
|
||||
|
||||
function openJob(id) {
|
||||
const modal = $("#modal");
|
||||
modal.hidden = false;
|
||||
$("#modal-title").textContent = `Job ${id}`;
|
||||
$("#log").textContent = "";
|
||||
$("#artifacts").innerHTML = "";
|
||||
let src = new EventSource(`/api/jobs/${encodeURIComponent(id)}/stream`);
|
||||
src.addEventListener("job", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.status) $("#status-badge").innerHTML = badgeFor(data.status);
|
||||
} catch (_) {}
|
||||
});
|
||||
src.addEventListener("log", (e) => {
|
||||
let line;
|
||||
try { line = JSON.parse(e.data); } catch (_) { line = e.data; }
|
||||
$("#log").textContent += line + String.fromCharCode(10);
|
||||
$("#log").scrollTop = $("#log").scrollHeight;
|
||||
});
|
||||
src.onerror = () => src.close();
|
||||
src.onopen = () => {
|
||||
// seed meta + artifacts from REST
|
||||
api(`/api/jobs/${encodeURIComponent(id)}`).then((j) => {
|
||||
$("#modal-meta").textContent =
|
||||
`${j.source} — ${j.os_req || "any"} / ${j.cpu_req || "any"} → ${j.worker_host || "unassigned"}`;
|
||||
$("#status-badge").innerHTML = badgeFor(j.status);
|
||||
const arts = j.artifacts || [];
|
||||
if (arts.length) {
|
||||
$("#artifacts").innerHTML = arts.map((a) =>
|
||||
`<a href="/api/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(a)}">⬇ ${esc(a)}</a>`
|
||||
).join("");
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$("#modal").hidden = true;
|
||||
}
|
||||
|
||||
$("#jobs").addEventListener("click", (e) => {
|
||||
const tr = e.target.closest("tr[data-id]");
|
||||
if (tr) openJob(tr.dataset.id);
|
||||
});
|
||||
$("#modal-close").addEventListener("click", closeModal);
|
||||
$("#modal").addEventListener("click", (e) => {
|
||||
if (e.target === $("#modal")) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
|
||||
$("#mode").addEventListener("change", () => {
|
||||
const git = $("#mode").value === "git";
|
||||
$("#url-field").hidden = !git;
|
||||
$("#upload-field").hidden = git;
|
||||
});
|
||||
|
||||
$("#job-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = $("#submit-msg");
|
||||
msg.className = "msg";
|
||||
msg.textContent = "Submitting…";
|
||||
const git = $("#mode").value === "git";
|
||||
const body = new FormData();
|
||||
body.append("os", $("#os").value);
|
||||
body.append("cpu", $("#cpu").value);
|
||||
if (git) {
|
||||
body.append("repo_url", $("#url").value.trim());
|
||||
} else {
|
||||
const file = $("#file").files[0];
|
||||
if (!file) {
|
||||
msg.textContent = "Choose a zip file to upload.";
|
||||
msg.className = "msg error";
|
||||
return;
|
||||
}
|
||||
body.append("file", file);
|
||||
}
|
||||
try {
|
||||
const opts = { method: "POST", body };
|
||||
if (!git) opts.headers = { "Content-Type": "multipart/form-data" }; // let browser set boundary
|
||||
const res = await fetch("/api/jobs", { method: "POST", body });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
msg.textContent = `Submitted job ${data.id}`;
|
||||
$("#job-form").reset();
|
||||
refreshJobs();
|
||||
} catch (err) {
|
||||
msg.textContent = `Error: ${err.message}`;
|
||||
msg.className = "msg error";
|
||||
}
|
||||
});
|
||||
|
||||
refreshCapabilities();
|
||||
refreshWorkers();
|
||||
refreshJobs();
|
||||
setInterval(refreshWorkers, 15000);
|
||||
setInterval(refreshJobs, 5000);
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cross-Py-Builder</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Cross-Py-Builder</h1>
|
||||
<p id="worker-summary"></p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card" id="submit-card">
|
||||
<h2>New build</h2>
|
||||
<form id="job-form">
|
||||
<div class="field">
|
||||
<label for="mode">Source</label>
|
||||
<select id="mode">
|
||||
<option value="upload">Upload zip</option>
|
||||
<option value="git">Git repo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="upload-field">
|
||||
<label for="file">Project zip</label>
|
||||
<input type="file" id="file" accept=".zip">
|
||||
</div>
|
||||
<div class="field" id="url-field" hidden>
|
||||
<label for="url">Git URL</label>
|
||||
<input type="text" id="url" placeholder="https://…">
|
||||
</div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label for="os">OS</label>
|
||||
<select id="os"><option value="">Any</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="cpu">CPU</label>
|
||||
<select id="cpu"><option value="">Any</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Submit build</button>
|
||||
<p id="submit-msg" class="msg"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Jobs</h2>
|
||||
<div id="jobs"><p class="muted">Loading…</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modal" class="modal" hidden>
|
||||
<div class="modal-body">
|
||||
<button id="modal-close" class="close" aria-label="Close">×</button>
|
||||
<h3 id="modal-title"></h3>
|
||||
<div class="job-meta"><code id="modal-meta"></code></div>
|
||||
<div id="status-badge" class="badge"></div>
|
||||
<div id="artifacts"></div>
|
||||
<pre id="log"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Cross-Py-Builder styles */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #10151c;
|
||||
--panel: #1a222c;
|
||||
--panel-2: #202a36;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #4c8bf5;
|
||||
--border: #2d3a49;
|
||||
--ok: #3fb950;
|
||||
--fail: #f85149;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
header h1 { margin: 0 0 4px; font-size: 22px; }
|
||||
header p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.card h2 { margin: 0 0 14px; font-size: 16px; }
|
||||
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.fields-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
|
||||
.msg { color: var(--accent); font-size: 13px; min-height: 1em; }
|
||||
.msg.error { color: var(--fail); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
#jobs table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
#jobs th, #jobs td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
|
||||
#jobs th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
|
||||
#jobs tr.clickable { cursor: pointer; }
|
||||
#jobs tr.clickable:hover { background: var(--panel-2); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.badge.done { background: #12331a; color: var(--ok); }
|
||||
.badge.failed { background: #331517; color: var(--fail); }
|
||||
.badge.queued, .badge.dispatching, .badge.building { background: #1c2a40; color: var(--accent); }
|
||||
.badge.cancelled { background: var(--panel-2); color: var(--muted); }
|
||||
|
||||
.modal {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-body {
|
||||
position: relative;
|
||||
width: 100%; max-width: 760px; max-height: 90vh;
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 22px 26px;
|
||||
}
|
||||
.modal-body h3 { margin: 0 0 6px; }
|
||||
.modal-body .close {
|
||||
position: absolute; top: 12px; right: 14px;
|
||||
background: none; border: none; color: var(--muted); font-size: 22px; line-height: 1;
|
||||
}
|
||||
.job-meta code { color: var(--muted); font-size: 13px; word-break: break-all; }
|
||||
|
||||
#log {
|
||||
background: #0b0e13;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
color: #c9d4df;
|
||||
}
|
||||
|
||||
#artifacts { margin-top: 12px; }
|
||||
#artifacts a {
|
||||
display: inline-block;
|
||||
margin: 4px 8px 0 0;
|
||||
padding: 6px 12px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
Reference in New Issue
Block a user