"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 = ``;
[...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 = `
No workers configured. Add one below.
`;
return;
}
box.innerHTML = `
| Address | Status | OS / CPU | |
` +
workers.map((w) => {
const displayHost = w.online ? (w.hostname || w.ip || w.host) : w.host;
const addr = `${esc(displayHost)}:${esc(w.port || "")}`;
const status = w.online
? `ready · ${esc(w.status || "ok")}`
: `down`;
const osCpu = w.online ? `${esc(w.os || "?")} / ${esc(w.cpu || "?")}` : "—";
return `
${esc(addr)} |
${status} |
${osCpu} |
|
`;
}).join("") + `
`;
} 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;
const msg = $("#worker-msg");
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 = "";
msg.textContent = `Added worker ${spec}`;
msg.className = "msg";
refreshWorkers();
refreshCapabilities();
} catch (err) {
input.value = "";
msg.textContent = `Error: ${err.message}`;
msg.className = "msg error";
}
});
$("#workers").addEventListener("click", async (e) => {
const btn = e.target.closest("button.remove-worker");
if (!btn) return;
const msg = $("#worker-msg");
const host = btn.dataset.host;
const port = btn.dataset.port;
try {
const resp = await fetch(`/api/workers/${encodeURIComponent(port)}/${encodeURIComponent(host)}`, { method: "DELETE" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText);
msg.textContent = `Removed ${host}:${port}`;
msg.className = "msg";
refreshWorkers();
refreshCapabilities();
} catch (err) {
msg.textContent = `Remove failed: ${err.message}`;
msg.className = "msg error";
}
});
async function refreshJobs() {
let jobs;
try {
jobs = await api("/api/jobs");
} catch (e) {
$("#jobs").innerHTML = `Error: ${esc(e.message)}
`;
return;
}
const box = $("#jobs");
if (!jobs.length) {
box.innerHTML = `No jobs yet.
`;
return;
}
let html = `
| ID | Status | Source | Target | Created |
`;
for (const j of jobs) {
html += `
${esc(j.id)} |
${esc(j.status)} |
${esc(j.source)} |
${esc(j.os_req || "")} ${esc(j.cpu_req || "")} |
${esc(shortTime(j.created_at))} |
`;
}
html += `
`;
box.innerHTML = html;
}
function shortTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
function badgeFor(status) {
return `${esc(status)}`;
}
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) =>
`⬇ ${esc(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);