Manage workers from the web UI, persisted in SQLite

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.
This commit is contained in:
Brett Williams
2026-08-30 23:37:31 -05:00
parent 5967b606c7
commit 18d8d2e398
10 changed files with 187 additions and 41 deletions
+58 -2
View File
@@ -50,15 +50,71 @@ async function refreshWorkers() {
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)"}`
`${w.url}${w.online ? "" : " (down)"}`
);
$("#worker-summary").textContent =
`${online}/${workers.length} workers online — ${parts.join(" · ")}`;
`${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 {