mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-08 05:51:10 -05:00
Replace the CROSS_PY_WORKERS env list with a DB-backed worker store so agents can be added/removed (and their online status verified) from the browser. - db.py: add workers table + list_workers/add_worker/remove_worker. - workers.py: configured_workers() reads the DB instead of settings. - app.py: POST /api/workers (add, probes reachability) and DELETE /api/workers/<port>/<host>; startup log uses DB worker count. - UI: Workers card with add form, per-worker live status + OS/CPU, remove. - settings.py: drop CROSS_PY_WORKERS/_get_json_list; DB is single source. - README/DESIGN updated to describe UI-managed workers.
252 lines
7.8 KiB
JavaScript
252 lines
7.8 KiB
JavaScript
"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.url}${w.online ? "" : " (down)"}`
|
|
);
|
|
$("#worker-summary").textContent =
|
|
`${online}/${workers.length} workers online` + (parts.length ? ` — ${parts.join(" · ")}` : "");
|
|
|
|
const box = $("#workers");
|
|
if (!workers.length) {
|
|
box.innerHTML = `<p class="muted">No workers configured. Add one below.</p>`;
|
|
return;
|
|
}
|
|
box.innerHTML = `<table>
|
|
<tr><th>Address</th><th>Status</th><th>OS / CPU</th><th></th></tr>` +
|
|
workers.map((w) => {
|
|
const addr = `${w.host || ""}:${w.port || ""}`;
|
|
const status = w.online
|
|
? `<span class="badge done">ready · ${esc(w.status || "ok")}</span>`
|
|
: `<span class="badge failed">down</span>`;
|
|
const osCpu = w.online ? `${esc(w.os || "?")} / ${esc(w.cpu || "?")}` : "—";
|
|
return `<tr>
|
|
<td><code>${esc(addr)}</code></td>
|
|
<td>${status}</td>
|
|
<td>${osCpu}</td>
|
|
<td><button class="remove-worker" data-url="${esc(w.url)}">Remove</button></td>
|
|
</tr>`;
|
|
}).join("") + `</table>`;
|
|
} catch (_) {
|
|
$("#worker-summary").textContent = "Worker summary unavailable";
|
|
}
|
|
}
|
|
|
|
$("#worker-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const input = $("#worker-spec");
|
|
const spec = input.value.trim();
|
|
if (!spec) return;
|
|
try {
|
|
const resp = await fetch("/api/workers", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ spec }),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error(data.error || resp.statusText);
|
|
input.value = "";
|
|
refreshWorkers();
|
|
refreshCapabilities();
|
|
} catch (err) {
|
|
$("#worker-spec").value = "";
|
|
$("#worker-spec").placeholder = `Error: ${err.message}`;
|
|
}
|
|
});
|
|
|
|
$("#workers").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("button.remove-worker");
|
|
if (!btn) return;
|
|
const url = btn.dataset.url;
|
|
const host = url.replace(/^https?:\/\//, "").split(":")[0];
|
|
const port = url.replace(/^https?:\/\//, "").split(":")[1];
|
|
try {
|
|
await fetch(`/api/workers/${encodeURIComponent(port)}/${encodeURIComponent(host)}`, { method: "DELETE" });
|
|
refreshWorkers();
|
|
refreshCapabilities();
|
|
} catch (_) {}
|
|
});
|
|
|
|
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 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);
|