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:
Brett Williams
2026-08-30 21:55:46 -05:00
parent 5f0d2db1a9
commit 0e89919342
14 changed files with 1237 additions and 342 deletions
View File
+158
View File
@@ -0,0 +1,158 @@
import datetime
import json
import os
import queue
from flask import Flask, jsonify, request, send_from_directory, Response
import ctrl.db as db
import ctrl.settings as settings
import ctrl.workers as wrk
from ctrl.scheduler import make_scheduler
app = Flask(__name__, static_folder="static", static_url_path="")
scheduler = make_scheduler()
VIEW_STATIC = os.path.join(os.path.dirname(__file__), "static")
_initialized = False
def _ensure_ready():
global _initialized
if _initialized:
return
settings.ensure_dirs()
db.init_db()
_initialized = True
@app.before_request
def _before_request():
_ensure_ready()
@app.route("/")
def index():
return send_from_directory(VIEW_STATIC, "index.html")
@app.get("/api/workers")
def api_workers():
return jsonify(wrk.probe_all())
@app.get("/api/capabilities")
def api_capabilities():
combos = {
(w.get("os"), w.get("cpu"))
for w in wrk.probe_all()
if w.get("os") and w.get("cpu")
}
return jsonify(sorted({"os": o, "cpu": c} for o, c in combos if o and c))
@app.post("/api/jobs")
def api_create_job():
os_req = (request.form.get("os") or "").strip() or None
cpu_req = (request.form.get("cpu") or "").strip() or None
if request.files and "file" in request.files:
upload = request.files["file"]
if not upload.filename:
return jsonify({"error": "No file selected"}), 400
source_dir = os.path.join(settings.DATA_DIR, "sources")
os.makedirs(source_dir, exist_ok=True)
fname = os.path.basename(upload.filename)
dest = os.path.join(source_dir, fname)
upload.save(dest)
job_id = db.create_job("upload", fname, os_req, cpu_req)
else:
data = request.get_json(silent=True) or {}
repo_url = (data.get("repo_url") or "").strip()
if not repo_url:
return jsonify({"error": "Provide a file upload or a repo_url"}), 400
job_id = db.create_job("git", repo_url, os_req, cpu_req)
scheduler.start()
return jsonify({"id": job_id}), 201
@app.get("/api/jobs")
def api_list_jobs():
return jsonify(db.list_jobs())
@app.get("/api/jobs/<job_id>")
def api_get_job(job_id):
job = db.get_job(job_id)
if not job:
return jsonify({"error": "not found"}), 404
return jsonify(job)
@app.get("/api/jobs/<job_id>/artifacts/<name>")
def api_download(job_id, name):
job = db.get_job(job_id)
if not job:
return jsonify({"error": "not found"}), 404
safe = os.path.basename(name)
out_dir = os.path.join(settings.BUILDS_DIR, job_id)
return send_from_directory(out_dir, safe, as_attachment=True)
@app.post("/api/jobs/<job_id>/cancel")
def api_cancel(job_id):
job = db.get_job(job_id)
if not job:
return jsonify({"error": "not found"}), 404
if job["status"] in ("queued",):
db.update_job(job_id, status="cancelled", finished_at=datetime.datetime.now().isoformat())
return jsonify({"id": job_id, "status": "cancelled"})
return jsonify({"error": f"Cannot cancel job in state {job['status']}"}), 400
@app.get("/api/jobs/<job_id>/stream")
def api_stream(job_id):
job = db.get_job(job_id)
if not job:
return jsonify({"error": "not found"}), 404
q = scheduler.subscribe(job_id)
def gen():
# initial snapshot
snap = db.get_job(job_id)
if snap:
yield f"event: job\ndata: {json.dumps({'status': snap['status']})}\n\n"
for line in (snap.get("log") or "").splitlines():
yield f"event: log\ndata: {json.dumps(line)}\n\n"
try:
while True:
try:
frame = q.get(timeout=15)
yield frame
except queue.Empty:
# heartbeat to keep connection alive
yield ": keepalive\n\n"
job_now = db.get_job(job_id)
if job_now and job_now["status"] in ("done", "failed", "cancelled"):
break
finally:
scheduler.unsubscribe(job_id, q)
return Response(gen(), mimetype="text/event-stream")
def main():
_ensure_ready()
scheduler.start()
if settings.DEBUG:
app.run(host="0.0.0.0", port=settings.PORT, threaded=True)
return
from waitress import serve
serve(app, host="0.0.0.0", port=settings.PORT, threads=8)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
import json
import sqlite3
import threading
import ctrl.settings as settings
_local = threading.local()
def _conn():
conn = getattr(_local, "conn", None)
if conn is None:
conn = sqlite3.connect(settings.DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
_local.conn = conn
return conn
def init_db():
settings.ensure_dirs()
conn = _conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
source_type TEXT NOT NULL,
source TEXT,
os_req TEXT,
cpu_req TEXT,
worker_host TEXT,
worker_job_id TEXT,
worker_url TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
log TEXT DEFAULT '',
error TEXT,
artifacts TEXT DEFAULT '[]'
)
"""
)
conn.commit()
def create_job(source_type, source, os_req, cpu_req):
import uuid
import datetime
job_id = "JOB-" + uuid.uuid4().hex[:8]
conn = _conn()
conn.execute(
"""
INSERT INTO jobs (id, status, source_type, source, os_req, cpu_req, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(job_id, "queued", source_type, source, os_req, cpu_req,
datetime.datetime.now().isoformat()),
)
conn.commit()
return job_id
def _row_to_dict(row):
d = dict(row)
try:
d["artifacts"] = json.loads(d.get("artifacts") or "[]")
except (TypeError, ValueError):
d["artifacts"] = []
return d
def get_job(job_id):
row = _conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return _row_to_dict(row) if row else None
def list_jobs(limit=100):
rows = _conn().execute(
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [_row_to_dict(r) for r in rows]
def update_job(job_id, **fields):
if not fields:
return
cols = ", ".join(f"{k} = ?" for k in fields)
values = []
for v in fields.values():
if isinstance(v, (list, dict)):
v = json.dumps(v)
values.append(v)
conn = _conn()
conn.execute(f"UPDATE jobs SET {cols} WHERE id = ?", (*values, job_id))
conn.commit()
def append_log(job_id, text):
conn = _conn()
conn.execute("UPDATE jobs SET log = log || ? WHERE id = ?", (text, job_id))
conn.commit()
def set_queued_jobs_for_worker(worker_url):
"""Reschedule any jobs currently stuck on a worker back to queued."""
conn = _conn()
conn.execute(
"UPDATE jobs SET status = 'queued', worker_url = NULL WHERE worker_url = ? AND status IN ('dispatching', 'building')",
(worker_url,),
)
conn.commit()
+191
View File
@@ -0,0 +1,191 @@
import datetime
import os
import threading
import requests
import ctrl.db as db
import ctrl.settings as settings
import ctrl.workers as wrk
class Scheduler:
def __init__(self):
self._stop = threading.Event()
self._thread = None
self._active = 0
self._active_lock = threading.Lock()
self._log_lock = threading.Lock()
self._stream_subscribers = {} # job_id -> set of queue.Queue
# ---- SSE helpers -----------------------------------------------------
def subscribe(self, job_id):
import queue
q = queue.Queue()
self._stream_subscribers.setdefault(job_id, set()).add(q)
return q
def unsubscribe(self, job_id, q):
subs = self._stream_subscribers.get(job_id)
if subs:
subs.discard(q)
if not subs:
self._stream_subscribers.pop(job_id, None)
def _publish(self, job_id, event, data):
subs = self._stream_subscribers.get(job_id)
if subs:
frame = f"event: {event}\ndata: {data}\n\n"
for q in list(subs):
q.put_nowait(frame)
# ---- lifecycle -------------------------------------------------------
def start(self):
if self._thread and self._thread.is_alive():
return
settings.ensure_dirs()
db.init_db()
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="scheduler", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
# ---- dispatch loop ---------------------------------------------------
def _loop(self):
while not self._stop.is_set():
try:
self._tick()
except Exception as e:
print(f"[scheduler] tick error: {e}")
self._stop.wait(settings.SCHEDULER_INTERVAL)
def _tick(self):
with self._active_lock:
if self._active >= settings.MAX_CONCURRENT:
return
free = settings.MAX_CONCURRENT - self._active
jobs = [j for j in db.list_jobs(limit=200) if j["status"] == "queued"]
for job in jobs[:free]:
self._dispatch(job)
# ---- job dispatch ----------------------------------------------------
def _dispatch(self, job):
job_id = job["id"]
candidates = [w for w in wrk.probe_all() if wrk.matches(job, w)]
if not candidates:
self._append(job_id, f"[scheduler] No worker matched requirements (os='{job['os_req']}', cpu='{job['cpu_req']}').\n")
return
worker = candidates[0]
with self._active_lock:
self._active += 1
try:
self._run_on_worker(job, worker)
finally:
with self._active_lock:
self._active -= 1
def _run_on_worker(self, job, worker):
job_id = job["id"]
db.update_job(job_id, status="dispatching", worker_host=worker["url"],
started_at=datetime.datetime.now().isoformat())
self._append(job_id, f"[scheduler] Dispatching to {worker['url']} ({worker.get('os')} {worker.get('cpu')})\n")
try:
if job["source_type"] == "git":
worker_job = self._request_git(job, worker)
else:
worker_job = self._request_upload(job, worker)
except Exception as e:
db.update_job(job_id, status="failed", finished_at=datetime.datetime.now().isoformat(),
error=str(e))
self._append(job_id, f"[scheduler] FAILED: {e}\n")
return
worker_job_id = worker_job.get("id")
worker_url = worker["url"]
db.update_job(job_id, status="building", worker_job_id=worker_job_id, worker_url=worker_url)
# download artifacts
try:
self._fetch_artifacts(job, worker, worker_job_id)
db.update_job(job_id, status="done", finished_at=datetime.datetime.now().isoformat())
self._append(job_id, "[scheduler] Build complete, artifacts saved.\n")
except Exception as e:
db.update_job(job_id, status="failed", finished_at=datetime.datetime.now().isoformat(),
error=str(e))
self._append(job_id, f"[scheduler] FAILED: {e}\n")
finally:
self._publish(job["id"], "job", '"done"')
def _request_git(self, job, worker):
self._append(job["id"], f"[scheduler] Cloning {job['source']} on {worker['url']}\n")
resp = requests.post(f"{worker['url']}/checkout_git",
json={"repo_url": job["source"]},
timeout=settings.WORKER_BUILD_TIMEOUT,
stream=False)
return self._worker_response(resp, "checkout")
def _request_upload(self, job, worker):
path = self._source_path(job)
if not path or not os.path.exists(path):
raise FileNotFoundError(f"Source file not found: {path}")
self._append(job["id"], f"[scheduler] Uploading {os.path.basename(path)} to {worker['url']}\n")
with open(path, "rb") as f:
resp = requests.post(f"{worker['url']}/upload",
files={"file": (os.path.basename(path), f, "application/zip")},
timeout=settings.WORKER_BUILD_TIMEOUT,
stream=False)
worker_job = self._worker_response(resp, "upload")
if not worker_job.get("id"):
# Agent may have started building before returning; try progress fallback.
pass
return worker_job
@staticmethod
def _worker_response(resp, action):
try:
data = resp.json()
except ValueError:
raise RuntimeError(f"{action} returned non-JSON ({resp.status_code})")
if resp.status_code >= 400:
raise RuntimeError(f"{action} failed ({resp.status_code}): {data.get('error', data)}")
return data
def _source_path(self, job):
jobs_dir = os.path.join(settings.DATA_DIR, "sources")
name = job.get("source")
if not name:
return None
return os.path.join(jobs_dir, name)
def _fetch_artifacts(self, job, worker, worker_job_id):
job_id = job["id"]
self._append(job_id, f"[scheduler] Fetching artifacts from {worker['url']}/download/{worker_job_id}\n")
resp = requests.get(f"{worker['url']}/download/{worker_job_id}", timeout=settings.WORKER_BUILD_TIMEOUT)
resp.raise_for_status()
out_dir = os.path.join(settings.BUILDS_DIR, job_id)
os.makedirs(out_dir, exist_ok=True)
base = os.path.splitext(os.path.basename(job.get("source") or job_id))[0]
fname = f"{base}-{str(worker.get('os')).lower()}-{str(worker.get('cpu')).lower()}.zip"
dest = os.path.join(out_dir, fname)
with open(dest, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
db.update_job(job_id, artifacts=[fname])
# ---- logging / status -------------------------------------------------
def _append(self, job_id, text):
with self._log_lock:
db.append_log(job_id, text)
self._publish(job_id, "log", '"%s"' % text.rstrip().replace('"', '\\"'))
def make_scheduler():
return Scheduler()
+56
View File
@@ -0,0 +1,56 @@
import json
import os
def _get_bool(name, default=False):
val = os.environ.get(name)
if val is None:
return default
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:
return default
return int(raw)
DATA_DIR = os.environ.get("CROSS_PY_DATA", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data"))
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)
# Per-request timeouts (seconds) for talking to workers.
WORKER_STATUS_TIMEOUT = _get_int("CROSS_PY_WORKER_STATUS_TIMEOUT", 5)
WORKER_BUILD_TIMEOUT = _get_int("CROSS_PY_WORKER_BUILD_TIMEOUT", 3600)
# Interval (seconds) for the scheduler loop.
SCHEDULER_INTERVAL = 1.0
# Debug mode uses Flask's dev server; otherwise serve through waitress.
DEBUG = _get_bool("CROSS_PY_DEBUG", False)
def ensure_dirs():
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(BUILDS_DIR, exist_ok=True)
+197
View File
@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
+68
View File
@@ -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">&times;</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>
+143
View File
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
import ctrl.settings as settings
import requests
def parse_worker(spec):
"""'host:port' -> {'url': 'http://host:port', 'host': host, 'port': port}."""
host, _, port = spec.strip().rpartition(":")
if not host or not port.isdigit():
raise ValueError(f"Invalid worker spec: {spec!r} (expected host:port)")
return {"url": f"http://{host}:{port}", "host": host, "port": int(port)}
def configured_workers():
return [parse_worker(s) for s in settings.WORKERS]
def probe_worker(url, timeout=None):
"""Return a normalized worker status dict, or None if unreachable."""
timeout = timeout or settings.WORKER_STATUS_TIMEOUT
try:
resp = requests.get(f"{url}/status", timeout=timeout)
resp.raise_for_status()
info = resp.json()
except (requests.RequestException, ValueError):
return None
info["url"] = url
info["online"] = True
return info
def probe_all():
workers = []
for w in configured_workers():
status = probe_worker(w["url"])
if status:
workers.append(status)
else:
workers.append({"url": w["url"], "host": w["host"], "port": w["port"], "online": False})
return workers
def matches(job, worker):
if worker.get("status") != "ready":
return False
if job.get("os_req") and job["os_req"].lower() not in str(worker.get("os", "")).lower():
return False
if job.get("cpu_req") and job["cpu_req"].lower() not in str(worker.get("cpu", "")).lower():
return False
return True