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
+31 -1
View File
@@ -46,6 +46,36 @@ def api_workers():
return jsonify(wrk.probe_all())
@app.post("/api/workers")
def api_add_worker():
data = request.get_json(silent=True) or {}
spec = str(data.get("spec") or "").strip()
host = str(data.get("host") or "").strip()
port = data.get("port")
try:
if spec:
w = wrk.parse_worker(spec)
elif host and port:
w = wrk.parse_worker(f"{host}:{int(port)}")
else:
return jsonify({"error": "Provide spec ('host:port') or host+port"}), 400
except (ValueError, TypeError) as e:
return jsonify({"error": str(e)}), 400
db.add_worker(w["host"], w["port"])
db.set_queued_jobs_for_worker(w["url"])
log.info("Added worker %s:%s", w["host"], w["port"])
status = wrk.probe_worker(w["url"])
return jsonify({"host": w["host"], "port": w["port"], "online": status is not None, "status": status}), 201
@app.delete("/api/workers/<int:port>/<path:host>")
def api_remove_worker(host, port):
db.remove_worker(host, port)
log.info("Removed worker %s:%s", host, port)
return jsonify({"removed": True, "host": host, "port": port}), 200
@app.get("/api/capabilities")
def api_capabilities():
combos = {
@@ -161,7 +191,7 @@ def main():
_ensure_ready()
scheduler.start()
_configure_logging()
log.info("Controller listening on 0.0.0.0:%s (%d workers configured)", settings.PORT, len(settings.WORKERS))
log.info("Controller listening on 0.0.0.0:%s (%d workers configured)", settings.PORT, len(db.list_workers()))
if settings.DEBUG:
app.run(host="0.0.0.0", port=settings.PORT, threaded=True)
return
+33
View File
@@ -41,6 +41,39 @@ def init_db():
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS workers (
host TEXT NOT NULL,
port INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (host, port)
)
"""
)
conn.commit()
# ---- workers -------------------------------------------------------------
def list_workers():
rows = _conn().execute("SELECT host, port FROM workers ORDER BY host, port").fetchall()
return [{"host": r["host"], "port": r["port"]} for r in rows]
def add_worker(host, port):
import datetime
conn = _conn()
conn.execute(
"INSERT OR IGNORE INTO workers (host, port, created_at) VALUES (?, ?, ?)",
(host, int(port), datetime.datetime.now().isoformat()),
)
conn.commit()
def remove_worker(host, port):
conn = _conn()
conn.execute("DELETE FROM workers WHERE host = ? AND port = ?", (host, int(port)))
conn.commit()
-17
View File
@@ -1,4 +1,3 @@
import json
import os
@@ -9,19 +8,6 @@ def _get_bool(name, default=False):
return val.strip().lower() in ("1", "true", "yes", "on")
def _get_json_list(name, default=None):
raw = os.environ.get(name)
if not raw:
return list(default or [])
try:
value = json.loads(raw)
except json.JSONDecodeError:
raise ValueError(f"{name} must be a JSON array string")
if not isinstance(value, list):
raise ValueError(f"{name} must be a JSON array")
return value
def _get_int(name, default):
raw = os.environ.get(name)
if raw is None:
@@ -34,9 +20,6 @@ DB_PATH = os.environ.get("CROSS_PY_DB", os.path.join(DATA_DIR, "jobs.db"))
BUILDS_DIR = os.environ.get("CROSS_PY_BUILDS", os.path.join(DATA_DIR, "builds"))
PORT = _get_int("CROSS_PY_PORT", 8080)
# Static worker list: JSON array of "host:port" strings.
WORKERS = _get_json_list("CROSS_PY_WORKERS")
# One job at a time by default; raise to allow parallel dispatches to distinct workers.
MAX_CONCURRENT = _get_int("CROSS_PY_MAX_CONCURRENT", 1)
+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 {
+9
View File
@@ -13,6 +13,15 @@
</header>
<main>
<section class="card">
<h2>Workers</h2>
<form id="worker-form" class="inline-form">
<input type="text" id="worker-spec" placeholder="host:port (e.g. 192.168.1.40:9001)">
<button type="submit">Add worker</button>
</form>
<div id="workers"></div>
</section>
<section class="card" id="submit-card">
<h2>New build</h2>
<form id="job-form">
+8
View File
@@ -59,6 +59,14 @@ main {
}
.fields-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.inline-form { display: flex; gap: 10px; margin-bottom: 12px; }
.inline-form input { flex: 1; }
button.remove-worker {
padding: 5px 10px;
background: var(--fail);
font-size: 12px;
}
button {
padding: 8px 14px;
background: var(--accent);
+9 -1
View File
@@ -1,3 +1,4 @@
import ctrl.db as db
import ctrl.settings as settings
import requests
@@ -11,7 +12,14 @@ def parse_worker(spec):
def configured_workers():
return [parse_worker(s) for s in settings.WORKERS]
out = []
for w in db.list_workers():
out.append({
"url": f"http://{w['host']}:{w['port']}",
"host": w["host"],
"port": w["port"],
})
return out
def probe_worker(url, timeout=None):