mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-07 21:41:09 -05:00
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:
+158
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
@@ -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">×</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user