Auth + dashboard (HANDOFF2 phases 1-2): guests local-only, system snapshot

Phase 1 — multi-user auth:
- server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256
  bearer tokens, FastAPI current_user/require_owner deps, login rate limit
- users + api_tokens tables + jobs/assets.user_id (additive migrations)
- HARD RULE enforced server-side: guests are local-only — /api/operators filters
  out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven
  via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret
  hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/
  own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once.
- mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management
- Frontend: Login gate, header user chip + logout, guest note, username on jobs,
  Users panel in Settings (owner)

Phase 2 — dashboard:
- server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no
  sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs;
  all cached (5s stats, 5min du). /api/system + /api/jobs/recent.
- Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip,
  lane strip, running/queued, recent grid.

tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security
rules. Browser-verified owner + guest + dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MODELBEAST 2026-07-13 12:06:52 +10:00
parent 4e66cc632e
commit 810400fb60
17 changed files with 1178 additions and 126 deletions

View File

@ -4,8 +4,11 @@ version = "0.1.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"bcrypt>=5.0.0",
"fal-client>=1.0.0", "fal-client>=1.0.0",
"fastapi>=0.139.0", "fastapi>=0.139.0",
"itsdangerous>=2.2.0",
"psutil>=7.2.2",
"python-multipart>=0.0.32", "python-multipart>=0.0.32",
"uvicorn[standard]>=0.51.0", "uvicorn[standard]>=0.51.0",
] ]

53
scripts/users.py Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env python
"""Manage MODELBEAST users directly against the DB — for bootstrapping or when
you're locked out of the UI. Run from the repo root: uv run python scripts/users.py ...
"""
import argparse
import getpass
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from server import auth, db # noqa: E402
def main():
con = db.connect()
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("add")
a.add_argument("username")
a.add_argument("--owner", action="store_true")
a.add_argument("--max-jobs", type=int, default=4)
sub.add_parser("list")
p = sub.add_parser("passwd")
p.add_argument("username")
d = sub.add_parser("delete")
d.add_argument("username")
args = ap.parse_args()
if args.cmd == "add":
pw = getpass.getpass("password: ")
u = auth.create_user(con, args.username, pw,
role="owner" if args.owner else "guest",
max_active_jobs=args.max_jobs)
print(f"created {u['username']} ({u['role']})")
elif args.cmd == "list":
for u in auth.list_users(con):
print(f"{u['username']:<20} {u['role']:<7} max_jobs={u['max_active_jobs']}")
elif args.cmd == "passwd":
u = auth.get_user_by_name(con, args.username)
if not u:
sys.exit("no such user")
auth.set_password(con, u["id"], getpass.getpass("new password: "))
print("updated")
elif args.cmd == "delete":
u = auth.get_user_by_name(con, args.username)
if not u:
sys.exit("no such user")
auth.delete_user(con, u["id"])
print("deleted")
if __name__ == "__main__":
main()

188
server/auth.py Normal file
View File

@ -0,0 +1,188 @@
"""Authentication: bcrypt passwords, signed-cookie sessions, bearer API tokens,
and the FastAPI dependency that resolves the current user.
Design premise (owner's hard rule): guests are LOCAL-ONLY. Any operator with a
non-empty requires_env is owner-only and enforced server-side (see main.py) this
module just answers "who is this request" and "is the password right".
"""
import hashlib
import re
import secrets
import time
import bcrypt
from fastapi import HTTPException, Request
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from . import db
SESSION_COOKIE = "mb_session"
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
USERNAME_RE = re.compile(r"^[a-z0-9_-]{2,24}$")
MIN_PW_LEN = 6
# in-memory login rate limit: ip -> [failure epoch, ...]
_login_fails: dict[str, list[float]] = {}
_RATE_WINDOW = 60.0
_RATE_MAX = 5
# -- passwords ----------------------------------------------------------------
def hash_pw(pw: str) -> str:
# bcrypt caps at 72 bytes; truncate deterministically (standard practice)
return bcrypt.hashpw(pw.encode()[:72], bcrypt.gensalt()).decode()
def verify_pw(pw: str, pw_hash: str) -> bool:
try:
return bcrypt.checkpw(pw.encode()[:72], pw_hash.encode())
except (ValueError, TypeError):
return False
# -- auth secret (stored hidden in settings; never exposed via the API) -------
def ensure_auth_secret(con) -> str:
row = con.execute("SELECT value FROM settings WHERE key='auth_secret'").fetchone()
if row and row["value"]:
return row["value"]
secret = secrets.token_hex(32)
con.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('auth_secret', ?)", (secret,))
con.commit()
return secret
def _serializer(con) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(ensure_auth_secret(con), salt="mb-session")
def make_session(con, user_id: str) -> str:
return _serializer(con).dumps({"uid": user_id})
def read_session(con, token: str) -> str | None:
try:
return _serializer(con).loads(token, max_age=SESSION_MAX_AGE).get("uid")
except (BadSignature, SignatureExpired, Exception):
return None
# -- API tokens ----------------------------------------------------------------
def _token_hash(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def create_token(con, user_id: str, name: str = "") -> str:
raw = "mbt_" + secrets.token_hex(20)
con.execute(
"INSERT INTO api_tokens (token_hash, user_id, name, created_at) VALUES (?, ?, ?, ?)",
(_token_hash(raw), user_id, name, db.now()))
con.commit()
return raw
def user_by_token(con, raw: str) -> dict | None:
row = con.execute(
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?",
(_token_hash(raw),)).fetchone()
return dict(row) if row else None
# -- users ---------------------------------------------------------------------
def get_user(con, user_id: str) -> dict | None:
row = con.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def get_user_by_name(con, username: str) -> dict | None:
row = con.execute("SELECT * FROM users WHERE username = ?", (username.lower(),)).fetchone()
return dict(row) if row else None
def create_user(con, username: str, password: str, role: str = "guest",
max_active_jobs: int = 4) -> dict:
username = username.lower()
if not USERNAME_RE.match(username):
raise ValueError("username must be 2-24 chars of a-z 0-9 _ -")
if len(password) < MIN_PW_LEN:
raise ValueError(f"password must be >= {MIN_PW_LEN} characters")
if get_user_by_name(con, username):
raise ValueError("username already exists")
if role not in ("owner", "guest"):
raise ValueError("role must be owner or guest")
uid = db.new_id()
con.execute(
"INSERT INTO users (id, username, pw_hash, role, max_active_jobs, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(uid, username, hash_pw(password), role, max_active_jobs, db.now()))
con.commit()
return get_user(con, uid)
def set_password(con, user_id: str, password: str) -> None:
if len(password) < MIN_PW_LEN:
raise ValueError(f"password must be >= {MIN_PW_LEN} characters")
con.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (hash_pw(password), user_id))
con.commit()
def list_users(con) -> list[dict]:
rows = con.execute(
"SELECT id, username, role, max_active_jobs, created_at FROM users ORDER BY created_at"
).fetchall()
return [dict(r) for r in rows]
def delete_user(con, user_id: str) -> None:
con.execute("DELETE FROM api_tokens WHERE user_id = ?", (user_id,))
con.execute("DELETE FROM users WHERE id = ?", (user_id,))
con.commit()
def public_user(u: dict) -> dict:
return {"username": u["username"], "role": u["role"],
"is_owner": u["role"] == "owner", "max_active_jobs": u["max_active_jobs"]}
# -- rate limit ----------------------------------------------------------------
def rate_ok(ip: str) -> bool:
now = time.time()
fails = [t for t in _login_fails.get(ip, []) if now - t < _RATE_WINDOW]
_login_fails[ip] = fails
return len(fails) < _RATE_MAX
def note_fail(ip: str) -> None:
_login_fails.setdefault(ip, []).append(time.time())
# -- FastAPI dependency --------------------------------------------------------
def resolve_user(request: Request) -> dict | None:
"""Cookie session first, then bearer token. Returns user dict or None."""
con = request.app.state.con
tok = request.cookies.get(SESSION_COOKIE)
if tok:
uid = read_session(con, tok)
if uid:
u = get_user(con, uid)
if u:
return u
authz = request.headers.get("authorization", "")
if authz.startswith("Bearer "):
u = user_by_token(con, authz[7:].strip())
if u:
return u
return None
def current_user(request: Request) -> dict:
u = resolve_user(request)
if not u:
raise HTTPException(401, "authentication required")
return u
def require_owner(request: Request) -> dict:
u = current_user(request)
if u["role"] != "owner":
raise HTTPException(403, "owner only")
return u

View File

@ -39,12 +39,28 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '' value TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
pw_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest', -- 'owner' | 'guest'; guests are LOCAL-ONLY
max_active_jobs INTEGER NOT NULL DEFAULT 4,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY, -- sha256 hex of the raw token
user_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL
);
""" """
# Columns added after the original Phase 0 schema; applied idempotently so an # Columns added after the original Phase 0 schema; applied idempotently so an
# existing data/modelbeast.db upgrades in place. # existing data/modelbeast.db upgrades in place.
MIGRATIONS = [ MIGRATIONS = [
("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"), ("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"),
("jobs", "user_id", "TEXT"), # nullable; legacy rows = owner-era
("assets", "user_id", "TEXT"),
] ]

View File

@ -1,17 +1,20 @@
import asyncio import asyncio
import json import json
import secrets
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from fastapi import (Depends, FastAPI, File, HTTPException, Request, Response,
UploadFile, WebSocket, WebSocketDisconnect)
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db, registry, settings as settings_mod, store from . import auth, db, registry, settings as settings_mod, store, sysinfo
from .runner import runner from .runner import runner
app = FastAPI(title="MODELBEAST") app = FastAPI(title="MODELBEAST")
WEB_DIST = db.ROOT / "web" / "dist" WEB_DIST = db.ROOT / "web" / "dist"
INBOX = db.DATA / "inbox" INBOX = db.DATA / "inbox"
MAX_UPLOAD = 1024 * 1024 * 1024 # 1 GB
@app.on_event("startup") @app.on_event("startup")
@ -20,62 +23,196 @@ async def startup():
con = db.connect() con = db.connect()
app.state.con = con app.state.con = con
runner.get_settings = lambda: settings_mod.get_all(con) runner.get_settings = lambda: settings_mod.get_all(con)
auth.ensure_auth_secret(con)
_bootstrap_owner(con)
asyncio.create_task(runner.worker()) asyncio.create_task(runner.worker())
asyncio.create_task(watch_inbox()) asyncio.create_task(watch_inbox())
# -- operators ---------------------------------------------------------------- def _bootstrap_owner(con):
if con.execute("SELECT 1 FROM users LIMIT 1").fetchone():
return
pw = secrets.token_urlsafe(12)
auth.create_user(con, "monster", pw, role="owner", max_active_jobs=999)
banner = "=" * 62
print(f"\n{banner}\n MODELBEAST owner account created (first run):\n"
f" username: monster\n password: {pw}\n"
f" change it: MB_TOKEN=... mb user passwd monster (or the UI)\n{banner}\n",
flush=True)
def _con(request: Request):
return request.app.state.con
def _with_usernames(con, rows: list[dict]) -> list[dict]:
"""Attach a 'username' field to job/asset dicts for display."""
umap = {u["id"]: u["username"] for u in auth.list_users(con)}
for r in rows:
r["username"] = umap.get(r.get("user_id")) or ("" if r.get("user_id") else "owner")
return rows
# -- auth / session ------------------------------------------------------------
@app.post("/api/login")
async def login(payload: dict, request: Request, response: Response):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() \
or (request.client.host if request.client else "?")
if not auth.rate_ok(ip):
raise HTTPException(429, "too many attempts — wait a minute")
con = _con(request)
username = (payload.get("username") or "").strip().lower()
password = payload.get("password") or ""
u = auth.get_user_by_name(con, username)
if not u or not auth.verify_pw(password, u["pw_hash"]):
auth.note_fail(ip)
raise HTTPException(401, "invalid username or password")
token = auth.make_session(con, u["id"])
secure = request.headers.get("x-forwarded-proto", "http") == "https"
response.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_MAX_AGE,
httponly=True, samesite="lax", secure=secure, path="/")
return auth.public_user(u)
@app.post("/api/logout")
async def logout(response: Response):
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
@app.get("/api/me")
def me(user=Depends(auth.current_user)):
return auth.public_user(user)
# -- users (owner only) --------------------------------------------------------
@app.get("/api/users")
def get_users(request: Request, owner=Depends(auth.require_owner)):
return auth.list_users(_con(request))
@app.post("/api/users")
def add_user(payload: dict, request: Request, owner=Depends(auth.require_owner)):
try:
u = auth.create_user(_con(request), payload.get("username", ""),
payload.get("password", ""), role=payload.get("role", "guest"),
max_active_jobs=int(payload.get("max_active_jobs", 4)))
except ValueError as e:
raise HTTPException(400, str(e))
return auth.public_user(u)
@app.patch("/api/users/{username}")
def patch_user(username: str, payload: dict, request: Request, owner=Depends(auth.require_owner)):
con = _con(request)
u = auth.get_user_by_name(con, username)
if not u:
raise HTTPException(404, "no such user")
if payload.get("password"):
try:
auth.set_password(con, u["id"], payload["password"])
except ValueError as e:
raise HTTPException(400, str(e))
if "max_active_jobs" in payload:
con.execute("UPDATE users SET max_active_jobs = ? WHERE id = ?",
(int(payload["max_active_jobs"]), u["id"]))
con.commit()
return auth.public_user(auth.get_user(con, u["id"]))
@app.delete("/api/users/{username}")
def del_user(username: str, request: Request, owner=Depends(auth.require_owner)):
con = _con(request)
u = auth.get_user_by_name(con, username)
if not u:
raise HTTPException(404, "no such user")
if u["role"] == "owner":
raise HTTPException(400, "cannot delete an owner")
auth.delete_user(con, u["id"])
return {"ok": True}
@app.post("/api/tokens")
def make_token(payload: dict, request: Request, owner=Depends(auth.require_owner)):
# owner mints a token for a user (default: self)
con = _con(request)
target = payload.get("username")
u = auth.get_user_by_name(con, target) if target else owner
if not u:
raise HTTPException(404, "no such user")
raw = auth.create_token(con, u["id"], payload.get("name", ""))
return {"token": raw, "username": u["username"]} # shown ONCE
# -- operators -----------------------------------------------------------------
@app.get("/api/operators") @app.get("/api/operators")
def list_operators(): def list_operators(user=Depends(auth.current_user)):
return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()] is_owner = user["role"] == "owner"
out = []
for op in runner.operators.values():
# guests are LOCAL-ONLY: cloud operators are filtered out entirely
if not is_owner and op.get("requires_env"):
continue
out.append({k: v for k, v in op.items() if k != "dir"})
return out
# -- settings ----------------------------------------------------------------- # -- settings (owner only) -----------------------------------------------------
@app.get("/api/settings") @app.get("/api/settings")
def get_settings(): def get_settings(request: Request, owner=Depends(auth.require_owner)):
s = settings_mod.get_all(app.state.con) s = settings_mod.get_all(_con(request))
view = settings_mod.public_view(s) view = settings_mod.public_view(s)
view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS) view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS)
view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys()) view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys())
# env var names currently satisfied — lets the UI gate operators
view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key)) view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key))
return view return view
@app.put("/api/settings") @app.put("/api/settings")
def put_settings(payload: dict): def put_settings(payload: dict, request: Request, owner=Depends(auth.require_owner)):
# ignore masked secret placeholders so re-saving the form doesn't wipe a key
# (an empty string is a deliberate clear, not a placeholder)
clean = {k: v for k, v in payload.items() clean = {k: v for k, v in payload.items()
if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {""})} if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {""})}
settings_mod.set_many(app.state.con, clean) settings_mod.set_many(_con(request), clean)
return get_settings() return get_settings(request, owner)
# -- assets --------------------------------------------------------------------- # -- assets --------------------------------------------------------------------
@app.get("/api/assets") @app.get("/api/assets")
def list_assets(): def list_assets(user=Depends(auth.current_user), request: Request = None):
return store.list_assets(app.state.con) con = _con(request)
return _with_usernames(con, store.list_assets(con))
@app.post("/api/assets") @app.post("/api/assets")
async def upload_asset(file: UploadFile = File(...)): async def upload_asset(request: Request, file: UploadFile = File(...),
user=Depends(auth.current_user)):
cl = request.headers.get("content-length")
if cl and int(cl) > MAX_UPLOAD:
raise HTTPException(413, "file too large (1 GB max)")
data = await file.read() data = await file.read()
if not data: if not data:
raise HTTPException(400, "empty file") raise HTTPException(400, "empty file")
return store.register_upload(app.state.con, file.filename or "unnamed", data) if len(data) > MAX_UPLOAD:
raise HTTPException(413, "file too large (1 GB max)")
return store.register_upload(_con(request), file.filename or "unnamed", data, user_id=user["id"])
@app.delete("/api/assets/{asset_id}") @app.delete("/api/assets/{asset_id}")
def delete_asset(asset_id: str): def delete_asset(asset_id: str, user=Depends(auth.current_user), request: Request = None):
if not store.delete_asset(app.state.con, asset_id): con = _con(request)
asset = store.get_asset(con, asset_id)
if not asset:
raise HTTPException(404, "no such asset") raise HTTPException(404, "no such asset")
if user["role"] != "owner" and asset.get("user_id") != user["id"]:
raise HTTPException(403, "not your asset")
store.delete_asset(con, asset_id)
return {"ok": True} return {"ok": True}
@app.get("/api/assets/{asset_id}/file") @app.get("/api/assets/{asset_id}/file")
def asset_file(asset_id: str, member: str | None = None): def asset_file(asset_id: str, member: str | None = None,
asset = store.get_asset(app.state.con, asset_id) user=Depends(auth.current_user), request: Request = None):
asset = store.get_asset(_con(request), asset_id)
if not asset: if not asset:
raise HTTPException(404, "no such asset") raise HTTPException(404, "no such asset")
path = Path(asset["path"]) path = Path(asset["path"])
@ -90,66 +227,99 @@ def asset_file(asset_id: str, member: str | None = None):
return FileResponse(path, filename=asset["name"]) return FileResponse(path, filename=asset["name"])
# -- jobs ----------------------------------------------------------------------- # -- jobs ----------------------------------------------------------------------
@app.get("/api/jobs") @app.get("/api/jobs")
def list_jobs(): def list_jobs(user=Depends(auth.current_user), request: Request = None):
return runner.list_jobs(app.state.con) con = _con(request)
return _with_usernames(con, runner.list_jobs(con))
@app.get("/api/jobs/recent")
def recent_jobs(user=Depends(auth.current_user), request: Request = None):
con = _con(request)
return sysinfo.recent_jobs(con, runner)
@app.post("/api/jobs") @app.post("/api/jobs")
async def create_job(payload: dict): async def create_job(payload: dict, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
operator = payload.get("operator") operator = payload.get("operator")
if operator not in runner.operators: if operator not in runner.operators:
raise HTTPException(400, f"unknown operator: {operator}") raise HTTPException(400, f"unknown operator: {operator}")
op = runner.operators[operator]
# HARD RULE: guests run local models only — enforced here regardless of UI
if op.get("requires_env") and user["role"] != "owner":
raise HTTPException(403, "guests run local models only")
if user["role"] != "owner" and runner.active_job_count(con, user["id"]) >= user["max_active_jobs"]:
raise HTTPException(429, "too many active jobs — wait for some to finish")
asset_ids = payload.get("asset_ids") asset_ids = payload.get("asset_ids")
if not asset_ids: if not asset_ids:
asset_ids = [payload["asset_id"]] if payload.get("asset_id") else [] asset_ids = [payload["asset_id"]] if payload.get("asset_id") else []
for aid in asset_ids: for aid in asset_ids:
if not store.get_asset(app.state.con, aid): if not store.get_asset(con, aid):
raise HTTPException(400, f"unknown asset: {aid}") raise HTTPException(400, f"unknown asset: {aid}")
params = payload.get("params") or {} params = payload.get("params") or {}
job = runner.create_job(app.state.con, operator, asset_ids, params) job = runner.create_job(con, operator, asset_ids, params, user_id=user["id"])
await runner.broadcast({"type": "job", "job": job}) await runner.broadcast({"type": "job", "job": job})
return job return job
@app.get("/api/jobs/{job_id}") @app.get("/api/jobs/{job_id}")
def get_job(job_id: str): def get_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
job = runner.get_job(app.state.con, job_id) job = runner.get_job(_con(request), job_id)
if not job: if not job:
raise HTTPException(404, "no such job") raise HTTPException(404, "no such job")
return job return job
def _own_job_or_owner(con, job_id, user):
job = runner.get_job(con, job_id)
if not job:
raise HTTPException(404, "no such job")
if user["role"] != "owner" and job.get("user_id") != user["id"]:
raise HTTPException(403, "not your job")
return job
@app.post("/api/jobs/{job_id}/cancel") @app.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str): async def cancel_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
if not await runner.cancel(app.state.con, job_id): con = _con(request)
_own_job_or_owner(con, job_id, user)
if not await runner.cancel(con, job_id):
raise HTTPException(400, "job not cancellable") raise HTTPException(400, "job not cancellable")
return {"ok": True} return {"ok": True}
@app.post("/api/jobs/{job_id}/retry") @app.post("/api/jobs/{job_id}/retry")
async def retry_job(job_id: str): async def retry_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
job = runner.get_job(app.state.con, job_id) con = _con(request)
if not job: job = _own_job_or_owner(con, job_id, user)
raise HTTPException(404, "no such job") op = runner.operators.get(job["operator"], {})
new = runner.create_job(app.state.con, job["operator"], if op.get("requires_env") and user["role"] != "owner":
job.get("asset_ids") or [], job["params"]) raise HTTPException(403, "guests run local models only")
new = runner.create_job(con, job["operator"], job.get("asset_ids") or [],
job["params"], user_id=user["id"])
await runner.broadcast({"type": "job", "job": new}) await runner.broadcast({"type": "job", "job": new})
return new return new
@app.delete("/api/jobs/{job_id}") @app.delete("/api/jobs/{job_id}")
def delete_job(job_id: str): def delete_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
if not runner.delete_job(app.state.con, job_id): con = _con(request)
_own_job_or_owner(con, job_id, user)
if not runner.delete_job(con, job_id):
raise HTTPException(400, "job running or missing") raise HTTPException(400, "job running or missing")
return {"ok": True} return {"ok": True}
# -- inbox watch folder --------------------------------------------------------- # -- system / dashboard --------------------------------------------------------
@app.get("/api/system")
def system(user=Depends(auth.current_user), request: Request = None):
return sysinfo.snapshot(_con(request), runner)
# -- inbox watch folder --------------------------------------------------------
async def watch_inbox(): async def watch_inbox():
"""Ingest files dropped into data/inbox/ once their size is stable (avoids
grabbing partial copies). Lets UE/Blender/rsync drop outputs in for pickup."""
INBOX.mkdir(parents=True, exist_ok=True) INBOX.mkdir(parents=True, exist_ok=True)
seen: dict[str, int] = {} seen: dict[str, int] = {}
while True: while True:
@ -171,9 +341,23 @@ async def watch_inbox():
print(f"[inbox] {e}") print(f"[inbox] {e}")
# -- websocket --------------------------------------------------------------- # -- websocket (auth: session cookie or ?token=) -------------------------------
@app.websocket("/ws") @app.websocket("/ws")
async def ws(websocket: WebSocket): async def ws(websocket: WebSocket):
con = websocket.app.state.con
user = None
tok = websocket.cookies.get(auth.SESSION_COOKIE)
if tok:
uid = auth.read_session(con, tok)
if uid:
user = auth.get_user(con, uid)
if not user:
qtok = websocket.query_params.get("token")
if qtok:
user = auth.user_by_token(con, qtok)
if not user:
await websocket.close(code=1008) # policy violation
return
await websocket.accept() await websocket.accept()
runner.subscribers.add(websocket) runner.subscribers.add(websocket)
try: try:
@ -183,6 +367,6 @@ async def ws(websocket: WebSocket):
runner.subscribers.discard(websocket) runner.subscribers.discard(websocket)
# -- frontend (built) ----------------------------------------------------------- # -- frontend (built) — public so the SPA can load and show the login screen ----
if WEB_DIST.exists(): if WEB_DIST.exists():
app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web") app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web")

View File

@ -52,21 +52,28 @@ class Runner:
return lane if lane in LANE_LIMITS else "cpu" return lane if lane in LANE_LIMITS else "cpu"
# -- job lifecycle -------------------------------------------------------- # -- job lifecycle --------------------------------------------------------
def create_job(self, con, operator: str, asset_ids: list[str], params: dict) -> dict: def create_job(self, con, operator: str, asset_ids: list[str], params: dict,
user_id: str | None = None) -> dict:
job_id = db.new_id() job_id = db.new_id()
outdir = JOBS_DIR / job_id outdir = JOBS_DIR / job_id
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
primary = asset_ids[0] if asset_ids else None primary = asset_ids[0] if asset_ids else None
con.execute( con.execute(
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) " "INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at, user_id) "
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)", "VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?)",
(job_id, operator, primary, json.dumps(asset_ids), (job_id, operator, primary, json.dumps(asset_ids),
json.dumps(params), str(outdir), db.now()), json.dumps(params), str(outdir), db.now(), user_id),
) )
con.commit() con.commit()
self.queue.put_nowait(job_id) self.queue.put_nowait(job_id)
return self.get_job(con, job_id) return self.get_job(con, job_id)
def active_job_count(self, con, user_id: str) -> int:
row = con.execute(
"SELECT COUNT(*) c FROM jobs WHERE user_id = ? AND status IN ('queued','running')",
(user_id,)).fetchone()
return row["c"]
def get_job(self, con, job_id: str) -> dict | None: def get_job(self, con, job_id: str) -> dict | None:
row = con.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() row = con.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return db.row_to_dict(row) if row else None return db.row_to_dict(row) if row else None

View File

@ -17,6 +17,9 @@ ENV_MAP = {
# keys whose values are secret: rendered as password fields, redacted from logs # keys whose values are secret: rendered as password fields, redacted from logs
SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "openrouter_key", "hf_token"} SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "openrouter_key", "hf_token"}
# internal keys never exposed to the settings API or env injection (auth signing key)
HIDDEN_KEYS = {"auth_secret"}
DEFAULTS = { DEFAULTS = {
"archive_host": "m3ultra@100.69.21.128", "archive_host": "m3ultra@100.69.21.128",
"archive_path": "~/modelbeast-archive", "archive_path": "~/modelbeast-archive",
@ -27,12 +30,16 @@ def get_all(con) -> dict:
rows = con.execute("SELECT key, value FROM settings").fetchall() rows = con.execute("SELECT key, value FROM settings").fetchall()
out = dict(DEFAULTS) out = dict(DEFAULTS)
for r in rows: for r in rows:
if r["key"] in HIDDEN_KEYS:
continue # auth_secret etc. never reach the UI or env injection
out[r["key"]] = r["value"] out[r["key"]] = r["value"]
return out return out
def set_many(con, updates: dict) -> None: def set_many(con, updates: dict) -> None:
for key, value in updates.items(): for key, value in updates.items():
if key in HIDDEN_KEYS:
continue # never settable through the settings API
con.execute( con.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) " "INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", "ON CONFLICT(key) DO UPDATE SET value = excluded.value",

View File

@ -24,7 +24,7 @@ def kind_of(name: str) -> str:
def register_file(con, src: Path, name: str | None = None, parent_job: str | None = None, def register_file(con, src: Path, name: str | None = None, parent_job: str | None = None,
move: bool = False, meta: dict | None = None) -> dict: move: bool = False, meta: dict | None = None, user_id: str | None = None) -> dict:
"""Copy/move a file (or directory) into the asset store and record it.""" """Copy/move a file (or directory) into the asset store and record it."""
name = name or src.name name = name or src.name
asset_id = db.new_id() asset_id = db.new_id()
@ -50,24 +50,24 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non
# (e.g. a splat .ply vs a mesh .ply, or a colmap_dataset folder) # (e.g. a splat .ply vs a mesh .ply, or a colmap_dataset folder)
kind = meta.pop("kind", kind) kind = meta.pop("kind", kind)
con.execute( con.execute(
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) " "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now()), (asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now(), user_id),
) )
con.commit() con.commit()
return get_asset(con, asset_id) return get_asset(con, asset_id)
def register_upload(con, name: str, data: bytes) -> dict: def register_upload(con, name: str, data: bytes, user_id: str | None = None) -> dict:
asset_id = db.new_id() asset_id = db.new_id()
dest_dir = ASSETS_DIR / asset_id dest_dir = ASSETS_DIR / asset_id
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / name dest = dest_dir / name
dest.write_bytes(data) dest.write_bytes(data)
con.execute( con.execute(
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) " "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) "
"VALUES (?, ?, ?, ?, ?, '{}', NULL, ?)", "VALUES (?, ?, ?, ?, ?, '{}', NULL, ?, ?)",
(asset_id, name, kind_of(name), str(dest), len(data), db.now()), (asset_id, name, kind_of(name), str(dest), len(data), db.now(), user_id),
) )
con.commit() con.commit()
return get_asset(con, asset_id) return get_asset(con, asset_id)

128
server/sysinfo.py Normal file
View File

@ -0,0 +1,128 @@
"""System snapshot for the dashboard — CPU/RAM/disk (psutil), Apple GPU (macmon,
no sudo), queue/lane occupancy, recent jobs. Everything cached so refresh-spam is
cheap; the slow bits (du of data/ and HF cache) refresh at most every 5 min."""
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
import psutil
from . import db, store
_cache: dict = {}
def _cached(key: str, ttl: float, fn):
now = time.time()
hit = _cache.get(key)
if hit and now - hit[0] < ttl:
return hit[1]
val = fn()
_cache[key] = (now, val)
return val
def _macmon_gpu():
def fetch():
try:
out = subprocess.run(["macmon", "pipe", "-s", "1"], capture_output=True,
text=True, timeout=6).stdout.strip().splitlines()
sample = json.loads(out[-1])
gpu = sample.get("gpu_usage") or [0, 0.0]
return {"util_percent": round(gpu[1] * 100, 1),
"power_w": round(sample.get("gpu_power", 0.0), 1),
"temp_c": round(sample.get("temp", {}).get("gpu_temp_avg", 0.0), 1),
"source": "macmon"}
except FileNotFoundError:
return {"source": "unavailable", "hint": "brew install macmon for GPU stats"}
except Exception as e:
return {"source": "unavailable", "hint": str(e)[:80]}
return _cached("gpu", 5.0, fetch)
def _du_gb(path: Path):
try:
out = subprocess.run(["du", "-sk", str(path)], capture_output=True,
text=True, timeout=60).stdout.split()
return round(int(out[0]) / (1024 * 1024), 1)
except Exception:
return None
def _lanes(con, runner):
limits = getattr(runner, "LANE_LIMITS", None) or {"gpu": 1, "cpu": 3, "net": 6}
lanes = {k: {"limit": v, "running": 0, "queued": 0} for k, v in limits.items()}
rows = con.execute("SELECT operator, status FROM jobs WHERE status IN ('queued','running')").fetchall()
for r in rows:
lane = runner.lane_of(r["operator"])
if lane not in lanes:
lane = "cpu"
lanes[lane]["running" if r["status"] == "running" else "queued"] += 1
return lanes
def _jobs_24h(con):
cutoff = time.time() - 86400
rows = con.execute(
"SELECT operator, status FROM jobs WHERE finished_at >= ?", (cutoff,)).fetchall()
out = {"done": 0, "error": 0, "cancelled": 0, "by_operator": {}}
for r in rows:
out[r["status"]] = out.get(r["status"], 0) + 1
out["by_operator"][r["operator"]] = out["by_operator"].get(r["operator"], 0) + 1
return out
def snapshot(con, runner) -> dict:
def stats():
per_core = psutil.cpu_percent(interval=0.15, percpu=True)
vm = psutil.virtual_memory()
du = shutil.disk_usage(str(db.DATA))
try:
load1 = os.getloadavg()[0]
except OSError:
load1 = 0.0
return {
"cpu": {"percent": round(sum(per_core) / len(per_core), 1),
"cores": len(per_core), "per_core": [round(c, 0) for c in per_core],
"load1": round(load1, 2)},
"ram": {"used_gb": round((vm.total - vm.available) / 1e9, 1),
"total_gb": round(vm.total / 1e9, 1), "percent": vm.percent},
"disk": {"free_gb": round(du.free / 1e9, 1), "total_gb": round(du.total / 1e9, 1)},
"uptime_s": round(time.time() - psutil.boot_time()),
}
base = _cached("stats", 5.0, stats)
hf = Path.home() / ".cache" / "huggingface"
return {
**base,
"gpu": _macmon_gpu(),
"lanes": _lanes(con, runner),
"jobs_24h": _jobs_24h(con),
"data_dir_gb": _cached("data_du", 300.0, lambda: _du_gb(db.DATA)),
"hf_cache_gb": _cached("hf_du", 300.0, lambda: _du_gb(hf) if hf.exists() else None),
"operators": len(runner.operators),
}
def recent_jobs(con, runner, limit: int = 20) -> list[dict]:
cutoff = time.time() - 86400
rows = con.execute(
"SELECT * FROM jobs WHERE status IN ('done','error','cancelled') "
"AND finished_at >= ? ORDER BY finished_at DESC LIMIT ?", (cutoff, limit)).fetchall()
umap = {u_id: name for u_id, name in
con.execute("SELECT id, username FROM users").fetchall()}
out = []
for r in rows:
j = db.row_to_dict(r)
outs = con.execute(
"SELECT id, kind, name FROM assets WHERE parent_job = ?", (j["id"],)).fetchall()
dur = (j["finished_at"] - j["started_at"]) if j.get("started_at") and j.get("finished_at") else None
out.append({
"id": j["id"], "operator": j["operator"], "status": j["status"],
"username": umap.get(j.get("user_id")) or ("" if j.get("user_id") else "owner"),
"duration_s": round(dur, 1) if dur else None,
"outputs": [{"id": o["id"], "kind": o["kind"], "name": o["name"]} for o in outs],
})
return out

View File

@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# End-to-end smoke test against a throwaway data dir on a scratch port. # End-to-end smoke test against a throwaway data dir on a scratch port.
# Validates: operators load, settings CRUD, upload, ffprobe/ffmpeg_frames/ # Validates: auth (401/login/token), operators/settings, the 3 core operators,
# blender_convert run to completion, outputs register, cancel + retry work. # retry/delete, and the GUEST security rules (local-only, owner-only settings,
# per-user cap, own-asset). Never touches the production DB/server.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@ -9,11 +10,13 @@ PORT=${PORT:-8799}
TMP=$(mktemp -d) TMP=$(mktemp -d)
BLENDER="/Applications/Blender.app/Contents/MacOS/Blender" BLENDER="/Applications/Blender.app/Contents/MacOS/Blender"
UV=/opt/homebrew/bin/uv UV=/opt/homebrew/bin/uv
OJAR="$TMP/owner.cj"; GJAR="$TMP/guest.cj"; CJAR="$TMP/capped.cj"
pass=0; fail=0 pass=0; fail=0
say() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; } say() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
ok() { printf " \033[32mPASS\033[0m %s\n" "$1"; pass=$((pass+1)); } ok() { printf " \033[32mPASS\033[0m %s\n" "$1"; pass=$((pass+1)); }
bad() { printf " \033[31mFAIL\033[0m %s\n" "$1"; fail=$((fail+1)); } bad() { printf " \033[31mFAIL\033[0m %s\n" "$1"; fail=$((fail+1)); }
j() { python3 -c "import json,sys;d=json.load(sys.stdin);print($1)"; } j() { python3 -c "import json,sys;d=json.load(sys.stdin);print($1)"; }
code(){ curl -s -o /dev/null -w "%{http_code}" "$@"; } # print HTTP status
cleanup() { kill "${SRV:-0}" 2>/dev/null || true; rm -rf "$TMP"; } cleanup() { kill "${SRV:-0}" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT trap cleanup EXIT
@ -31,47 +34,80 @@ ok "test glb"
say "start server (MODELBEAST_DATA=$TMP/data, port $PORT)" say "start server (MODELBEAST_DATA=$TMP/data, port $PORT)"
MODELBEAST_DATA="$TMP/data" "$UV" run uvicorn server.main:app --host 127.0.0.1 --port "$PORT" >"$TMP/server.log" 2>&1 & MODELBEAST_DATA="$TMP/data" "$UV" run uvicorn server.main:app --host 127.0.0.1 --port "$PORT" >"$TMP/server.log" 2>&1 &
SRV=$! SRV=$!
for i in $(seq 1 40); do curl -sf "http://127.0.0.1:$PORT/api/operators" >/dev/null 2>&1 && break; sleep 0.5; done for i in $(seq 1 40); do curl -s "http://127.0.0.1:$PORT/api/me" >/dev/null 2>&1 && break; sleep 0.5; done
B="http://127.0.0.1:$PORT"
say "operators + settings"
NOPS=$(curl -s "$B/api/operators" | j "len(d)")
[ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)"
curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null
AP=$(curl -s "$B/api/settings" | j "d['archive_path']")
[ "$AP" = "/tmp/xyz" ] && ok "settings persist" || bad "settings ($AP)"
# secret masking
curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null
FK=$(curl -s "$B/api/settings" | j "d['fal_key']")
[ "$FK" = "••••••••" ] && ok "secret masked in GET" || bad "secret leak ($FK)"
say "upload + ffprobe"
VID=$(curl -s -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']")
GLB=$(curl -s -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']")
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffprobe\",\"asset_id\":\"$VID\"}" | j "d['id']")
for i in $(seq 1 30); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "ffprobe done" || bad "ffprobe ($S)"
say "ffmpeg_frames"
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffmpeg_frames\",\"asset_id\":\"$VID\",\"params\":{\"fps\":4,\"max_frames\":8}}" | j "d['id']")
for i in $(seq 1 40); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "ffmpeg_frames done" || bad "ffmpeg_frames ($S)"
NFR=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['kind']=='frames')")
[ "$NFR" -ge 1 ] && ok "frames asset registered" || bad "no frames asset"
say "blender_convert glb->fbx (bool param regression)"
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"blender_convert\",\"asset_id\":\"$GLB\",\"params\":{\"target\":\"fbx\",\"apply_transforms\":true}}" | j "d['id']")
for i in $(seq 1 60); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "blender_convert done" || bad "blender_convert ($S)"
NFBX=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))")
[ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx"
say "retry + delete"
NEW=$(curl -s -X POST "$B/api/jobs/$JOB/retry" | j "d['id']")
[ -n "$NEW" ] && [ "$NEW" != "$JOB" ] && ok "retry spawns new job" || bad "retry"
sleep 1 sleep 1
DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$B/api/jobs/$JOB") B="http://127.0.0.1:$PORT"
[ "$DEL" = 200 ] && ok "delete job" || bad "delete ($DEL)" OWNERPW=$(grep "password:" "$TMP/server.log" | head -1 | awk '{print $2}')
say "auth"
[ "$(code $B/api/operators)" = 401 ] && ok "unauth API → 401" || bad "unauth not 401"
LOGIN=$(code -c "$OJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d "{\"username\":\"monster\",\"password\":\"$OWNERPW\"}")
[ "$LOGIN" = 200 ] && ok "owner login" || bad "owner login ($LOGIN)"
[ "$(curl -s -b "$OJAR" $B/api/me | j "d['role']")" = owner ] && ok "/api/me = owner" || bad "me"
[ "$(code -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"monster","password":"wrong"}')" = 401 ] && ok "bad password → 401" || bad "bad pw"
# token flow
TOK=$(curl -s -b "$OJAR" -X POST $B/api/tokens -H 'Content-Type: application/json' -d '{"name":"test"}' | j "d['token']")
[ "$(code -H "Authorization: Bearer $TOK" $B/api/operators)" = 200 ] && ok "bearer token works" || bad "token"
[ "$(code -H "Authorization: Bearer mbt_bogus" $B/api/operators)" = 401 ] && ok "bogus token → 401" || bad "bogus token"
say "operators + settings (owner)"
NOPS=$(curl -s -b "$OJAR" "$B/api/operators" | j "len(d)")
[ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)"
curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null
[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['archive_path']")" = "/tmp/xyz" ] && ok "settings persist" || bad "settings"
curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null
[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['fal_key']")" = "••••••••" ] && ok "secret masked" || bad "secret leak"
[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "'auth_secret' in d")" = False ] && ok "auth_secret hidden" || bad "auth_secret LEAKED"
say "core operators (owner)"
VID=$(curl -s -b "$OJAR" -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']")
GLB=$(curl -s -b "$OJAR" -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']")
post_job() { curl -s -b "$OJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$1" | j "d['id']"; }
wait_job() { local s=""; for i in $(seq 1 60); do s=$(curl -s -b "$OJAR" "$B/api/jobs/$1" | j "d.get('status','?')"); { [ "$s" = done ] || [ "$s" = error ]; } && break; sleep 0.5; done; echo "$s"; }
JID=$(post_job '{"operator":"ffprobe","asset_id":"'"$VID"'"}'); S=$(wait_job "$JID")
[ "$S" = done ] && ok "ffprobe" || bad "ffprobe ($S)"
JID=$(post_job '{"operator":"ffmpeg_frames","asset_id":"'"$VID"'","params":{"fps":4,"max_frames":8}}'); S=$(wait_job "$JID")
[ "$S" = done ] && ok "ffmpeg_frames" || bad "ffmpeg_frames ($S)"
LASTFBX=$(post_job '{"operator":"blender_convert","asset_id":"'"$GLB"'","params":{"target":"fbx","apply_transforms":true}}'); S=$(wait_job "$LASTFBX")
[ "$S" = done ] && ok "blender_convert" || bad "blender_convert ($S)"
NFBX=$(curl -s -b "$OJAR" "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))")
[ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx"
NEW=$(curl -s -b "$OJAR" -X POST "$B/api/jobs/$LASTFBX/retry" | j "d['id']")
{ [ -n "$NEW" ] && [ "$NEW" != "$LASTFBX" ]; } && ok "retry" || bad "retry"
DC=$(code -b "$OJAR" -X DELETE "$B/api/jobs/$LASTFBX")
[ "$DC" = 200 ] && ok "delete job" || bad "delete ($DC)"
say "GUEST security (local-only hard rule)"
curl -s -b "$OJAR" -X POST $B/api/users -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null
curl -s -c "$GJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null
GOPS=$(curl -s -b "$GJAR" "$B/api/operators" | j "len(d)")
[ "$GOPS" -lt "$NOPS" ] && ok "guest sees fewer operators ($GOPS < $NOPS)" || bad "guest op count ($GOPS)"
SEES=$(curl -s -b "$GJAR" "$B/api/operators" | j "any(o['id']=='fal_trellis' for o in d)")
[ "$SEES" = False ] && ok "guest cannot see fal_trellis" || bad "guest sees cloud op"
# the load-bearing test: direct POST of a cloud operator by a guest must 403
FALPAY='{"operator":"fal_trellis","asset_id":"'"$VID"'"}'
c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FALPAY")
[ "$c" = 403 ] && ok "guest fal_trellis POST → 403 (server-side)" || bad "guest cloud NOT blocked ($c)"
c=$(code -b "$GJAR" "$B/api/settings"); [ "$c" = 403 ] && ok "guest settings → 403" || bad "guest settings ($c)"
c=$(code -b "$GJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"x","password":"yyyyyy"}')
[ "$c" = 403 ] && ok "guest cannot create users → 403" || bad "guest user-create ($c)"
# guest CAN run a local operator
FFPAY='{"operator":"ffprobe","asset_id":"'"$VID"'"}'
c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY")
[ "$c" = 200 ] && ok "guest can run local ffprobe" || bad "guest local blocked ($c)"
# guest cannot delete owner's asset
c=$(code -b "$GJAR" -X DELETE "$B/api/assets/$GLB"); [ "$c" = 403 ] && ok "guest cannot delete owner asset → 403" || bad "guest deleted owner asset ($c)"
# per-user cap: capped user with max_active_jobs=0 → any job 429
curl -s -b "$OJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null
curl -s -b "$OJAR" -X PATCH "$B/api/users/capped" -H 'Content-Type: application/json' -d '{"max_active_jobs":0}' >/dev/null
curl -s -c "$CJAR" -X POST "$B/api/login" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null
c=$(code -b "$CJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY")
[ "$c" = 429 ] && ok "per-user cap → 429" || bad "cap not enforced ($c)"
say "dashboard"
SHAPE=$(curl -s -b "$OJAR" "$B/api/system" | j "'gpu' in d and 'lanes' in d")
[ "$SHAPE" = True ] && ok "/api/system shape" || bad "system shape"
c=$(code -b "$GJAR" "$B/api/system"); [ "$c" = 200 ] && ok "guest can view dashboard" || bad "guest dashboard ($c)"
printf "\n\033[1mResult: %d passed, %d failed\033[0m\n" "$pass" "$fail" printf "\n\033[1mResult: %d passed, %d failed\033[0m\n" "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

109
uv.lock generated
View File

@ -51,6 +51,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/50/463443651ddb0ba66c289d5649369099ab72b4665888b337ed25ea622550/asyncstdlib-3.14.0-py3-none-any.whl", hash = "sha256:cad90c590ce357da6e70c56f0e2ad9e9baaf146a8fc2ede189879da57eb8bd87", size = 44255, upload-time = "2026-03-20T21:06:45.033Z" }, { url = "https://files.pythonhosted.org/packages/93/50/463443651ddb0ba66c289d5649369099ab72b4665888b337ed25ea622550/asyncstdlib-3.14.0-py3-none-any.whl", hash = "sha256:cad90c590ce357da6e70c56f0e2ad9e9baaf146a8fc2ede189879da57eb8bd87", size = 44255, upload-time = "2026-03-20T21:06:45.033Z" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.6.17" version = "2026.6.17"
@ -205,14 +271,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
] ]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]] [[package]]
name = "modelbeast" name = "modelbeast"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "bcrypt" },
{ name = "fal-client" }, { name = "fal-client" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "itsdangerous" },
{ name = "psutil" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@ -220,8 +298,11 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiofiles", specifier = ">=25.1.0" }, { name = "aiofiles", specifier = ">=25.1.0" },
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "fal-client", specifier = ">=1.0.0" }, { name = "fal-client", specifier = ">=1.0.0" },
{ name = "fastapi", specifier = ">=0.139.0" }, { name = "fastapi", specifier = ">=0.139.0" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "psutil", specifier = ">=7.2.2" },
{ name = "python-multipart", specifier = ">=0.0.32" }, { name = "python-multipart", specifier = ">=0.0.32" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" },
] ]
@ -278,6 +359,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" },
] ]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"

View File

@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings, api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getMe, getSettings,
listAssets, listJobs, listOperators, retryJob, runJob, uploadFile, listAssets, listJobs, listOperators, logout, retryJob, runJob, uploadFile,
} from "./api"; } from "./api";
import Viewer from "./Viewer"; import Viewer from "./Viewer";
import SplatViewer from "./SplatViewer"; import SplatViewer from "./SplatViewer";
import Settings from "./Settings"; import Settings from "./Settings";
import Login from "./Login";
import Dashboard from "./Dashboard";
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"]; const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
const fmtSize = (n) => const fmtSize = (n) =>
@ -97,6 +99,8 @@ function CompareGrid({ assets }) {
} }
export default function App() { export default function App() {
const [user, setUser] = useState(undefined); // undefined=loading, null=logged out
const [view, setView] = useState("workspace"); // workspace | dashboard
const [assets, setAssets] = useState([]); const [assets, setAssets] = useState([]);
const [operators, setOperators] = useState([]); const [operators, setOperators] = useState([]);
const [jobs, setJobs] = useState([]); const [jobs, setJobs] = useState([]);
@ -109,26 +113,42 @@ export default function App() {
const [compareMode, setCompareMode] = useState(false); const [compareMode, setCompareMode] = useState(false);
const [compareSel, setCompareSel] = useState([]); const [compareSel, setCompareSel] = useState([]);
const fileInput = useRef(null); const fileInput = useRef(null);
const wsRef = useRef(null);
const refreshAssets = useCallback(() => listAssets().then(setAssets), []); const refreshAssets = useCallback(() => listAssets().then(setAssets).catch(() => {}), []);
const refreshJobs = useCallback(() => listJobs().then(setJobs), []); const refreshJobs = useCallback(() => listJobs().then(setJobs).catch(() => {}), []);
const refreshSettings = useCallback(() => getSettings().then(setSettings), []); const refreshSettings = useCallback(() => getSettings().then(setSettings).catch(() => {}), []);
// auth bootstrap
useEffect(() => { useEffect(() => {
refreshAssets(); refreshJobs(); refreshSettings(); getMe().then(setUser).catch(() => setUser(null));
listOperators().then(setOperators); const onUnauth = () => setUser(null);
connectWS((msg) => { window.addEventListener("mb-unauth", onUnauth);
if (msg.type === "job") { return () => window.removeEventListener("mb-unauth", onUnauth);
setJobs((js) => {
const i = js.findIndex((j) => j.id === msg.job.id);
if (i === -1) return [msg.job, ...js];
const copy = [...js]; copy[i] = msg.job; return copy;
});
}
if (msg.type === "assets_changed") refreshAssets();
});
}, []); }, []);
// load workspace data + connect the live socket once, after login
useEffect(() => {
if (!user) return;
refreshAssets(); refreshJobs();
if (user.is_owner) refreshSettings();
listOperators().then(setOperators).catch(() => {});
if (!wsRef.current) {
wsRef.current = connectWS((msg) => {
if (msg.type === "job") {
setJobs((js) => {
const i = js.findIndex((j) => j.id === msg.job.id);
if (i === -1) return [msg.job, ...js];
const copy = [...js]; copy[i] = msg.job; return copy;
});
}
if (msg.type === "assets_changed") refreshAssets();
});
}
}, [user?.username]);
const doLogout = async () => { try { await logout(); } catch { /* ignore */ } window.location.reload(); };
useEffect(() => { useEffect(() => {
const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); }; const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); };
const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); }; const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); };
@ -180,17 +200,26 @@ export default function App() {
}; };
const compareAssets = compareSel.map((id) => assets.find((a) => a.id === id)).filter(Boolean); const compareAssets = compareSel.map((id) => assets.find((a) => a.id === id)).filter(Boolean);
if (user === undefined) return <div className="login-screen"><p className="dim"></p></div>;
if (user === null) return <Login onLogin={setUser} />;
return ( return (
<div className="layout"> <div className="layout">
<header> <header>
<h1>MODEL<span>BEAST</span></h1> <h1>MODEL<span>BEAST</span></h1>
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span> <span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
<div className="spacer" /> <div className="spacer" />
<button className={compareMode ? "active" : ""} onClick={() => { setCompareMode(!compareMode); setCompareSel([]); }}> <button className={view === "dashboard" ? "active" : ""}
onClick={() => setView(view === "dashboard" ? "workspace" : "dashboard")}>📊 Dashboard</button>
<button className={compareMode ? "active" : ""} onClick={() => { setCompareMode(!compareMode); setCompareSel([]); setView("workspace"); }}>
Compare{compareSel.length ? ` (${compareSel.length})` : ""} Compare{compareSel.length ? ` (${compareSel.length})` : ""}
</button> </button>
<button onClick={() => setShowSettings(true)}> Settings</button> {user.is_owner && <button onClick={() => setShowSettings(true)}> Settings</button>}
<button onClick={() => fileInput.current.click()}>+ Add files</button> <button onClick={() => fileInput.current.click()}>+ Add files</button>
<span className="user-chip" title={user.is_owner ? "owner" : "guest — local models only"}>
{user.username}{user.is_owner ? " · owner" : ""}
</span>
<button onClick={doLogout} title="log out"></button>
<input ref={fileInput} type="file" multiple hidden <input ref={fileInput} type="file" multiple hidden
onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} /> onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} />
</header> </header>
@ -218,9 +247,11 @@ export default function App() {
</aside> </aside>
<main> <main>
{compareMode && compareAssets.length {view === "dashboard"
? <CompareGrid assets={compareAssets} /> ? <Dashboard jobs={jobs} />
: <AssetPreview asset={selectedAsset} />} : compareMode && compareAssets.length
? <CompareGrid assets={compareAssets} />
: <AssetPreview asset={selectedAsset} />}
</main> </main>
<aside className="workbench"> <aside className="workbench">
@ -239,6 +270,7 @@ export default function App() {
<button className="go" disabled={!activeOp || gated || (needsInput && !selectedAsset)} onClick={launch}> <button className="go" disabled={!activeOp || gated || (needsInput && !selectedAsset)} onClick={launch}>
Run {activeOp?.name || ""} Run {activeOp?.name || ""}
</button> </button>
{!user.is_owner && <p className="dim guest-note">local models only everything you run is generated on this machine</p>}
<h2>Jobs</h2> <h2>Jobs</h2>
<div className="jobs"> <div className="jobs">
@ -247,7 +279,7 @@ export default function App() {
<div className="job-row" onClick={() => setOpenJob(openJob === j.id ? null : j.id)}> <div className="job-row" onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : j.status === "cancelled" ? "⛔" : "🕐"}</span> <span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : j.status === "cancelled" ? "⛔" : "🕐"}</span>
<span className="jop">{j.operator}</span> <span className="jop">{j.operator}</span>
<span className="dim jid">{j.id}</span> <span className="dim jid">{j.username ? `${j.username}` : j.id}</span>
<span className="job-actions" onClick={(e) => e.stopPropagation()}> <span className="job-actions" onClick={(e) => e.stopPropagation()}>
{j.status === "running" && <button onClick={() => cancelJob(j.id)}>cancel</button>} {j.status === "running" && <button onClick={() => cancelJob(j.id)}>cancel</button>}
{(j.status === "error" || j.status === "done" || j.status === "cancelled") && <button onClick={() => retryJob(j.id).then(refreshJobs)}>retry</button>} {(j.status === "error" || j.status === "done" || j.status === "cancelled") && <button onClick={() => retryJob(j.id).then(refreshJobs)}>retry</button>}
@ -263,7 +295,7 @@ export default function App() {
</div> </div>
</aside> </aside>
{showSettings && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />} {showSettings && user.is_owner && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
</div> </div>
); );
} }

121
web/src/Dashboard.jsx Normal file
View File

@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from "react";
import { assetFileURL, cancelJob, getRecentJobs, getSystem } from "./api";
const gb = (n) => (n == null ? "—" : Math.round(n) + " GB");
const dur = (s) => (s == null ? "" : s < 90 ? `${s.toFixed(0)}s` : `${(s / 60).toFixed(1)}m`);
const STATUS_ICON = { done: "✅", error: "❌", cancelled: "⛔", running: "⏳", queued: "🕐" };
function Bar({ pct, warn }) {
return (
<div className="bar"><div className="bar-fill" style={{
width: `${Math.min(100, pct)}%`,
background: warn ? "var(--bad)" : pct > 85 ? "#ffcf6b" : "var(--accent)",
}} /></div>
);
}
export default function Dashboard({ jobs }) {
const [sys, setSys] = useState(null);
const [recent, setRecent] = useState([]);
const [loading, setLoading] = useState(false);
const load = useCallback(() => {
setLoading(true);
Promise.all([getSystem(), getRecentJobs()])
.then(([s, r]) => { setSys(s); setRecent(r); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const active = jobs.filter((j) => j.status === "running" || j.status === "queued");
const upDays = sys ? Math.floor(sys.uptime_s / 86400) : 0;
const upHrs = sys ? Math.floor((sys.uptime_s % 86400) / 3600) : 0;
return (
<div className="dash">
<div className="dash-head">
<div className="dim">
{sys ? `M3 Ultra · ${Math.round(sys.ram.total_gb)} GB · up ${upDays}d ${upHrs}h · ${sys.operators} operators` : "…"}
</div>
<button onClick={load} disabled={loading}>{loading ? "…" : "⟳ Refresh"}</button>
</div>
{!sys ? <p className="dim">Loading system snapshot</p> : (
<>
<div className="stat-cards">
<div className="stat">
<div className="stat-top"><span>CPU</span><b>{sys.cpu.percent}%</b></div>
<div className="core-strip">
{sys.cpu.per_core.map((c, i) => (
<div key={i} className="core" title={`core ${i}: ${c}%`}
style={{ height: `${Math.max(4, c)}%` }} />
))}
</div>
<div className="dim">{sys.cpu.cores} cores · load {sys.cpu.load1}</div>
</div>
<div className="stat">
<div className="stat-top"><span>RAM</span><b>{sys.ram.used_gb} / {sys.ram.total_gb} GB</b></div>
<Bar pct={sys.ram.percent} />
<div className="dim">{sys.ram.percent}% used</div>
</div>
<div className="stat">
<div className="stat-top"><span>GPU</span><b>
{sys.gpu.source === "macmon" ? `${sys.gpu.util_percent}%` : "—"}
</b></div>
{sys.gpu.source === "macmon"
? <><Bar pct={sys.gpu.util_percent} /><div className="dim">{sys.gpu.power_w} W · {sys.gpu.temp_c}°C</div></>
: <div className="dim">{sys.gpu.hint || "unavailable"}</div>}
</div>
<div className="stat">
<div className="stat-top"><span>Disk</span><b>{gb(sys.disk.free_gb)} free</b></div>
<Bar pct={100 - (sys.disk.free_gb / sys.disk.total_gb) * 100} warn={sys.disk.free_gb < 100} />
<div className="dim">data {gb(sys.data_dir_gb)} · HF cache {gb(sys.hf_cache_gb)}</div>
</div>
</div>
<div className="lane-strip">
{Object.entries(sys.lanes).map(([lane, l]) => (
<div key={lane} className={"lane-card" + (l.running ? " busy" : "")}>
<b>{lane.toUpperCase()}</b>
<span>{l.running}/{l.limit} busy{l.queued ? ` · ${l.queued} waiting` : ""}</span>
</div>
))}
</div>
<h2>Now running / queued</h2>
<table className="dash-table">
<tbody>
{active.length === 0 && <tr><td className="dim">idle</td></tr>}
{active.map((j) => (
<tr key={j.id}>
<td>{STATUS_ICON[j.status]}</td>
<td>{j.operator}</td>
<td className="dim">{j.username || "—"}</td>
<td className="dim">{j.status}</td>
<td>{j.status === "running" && <button onClick={() => cancelJob(j.id)}>cancel</button>}</td>
</tr>
))}
</tbody>
</table>
<h2>Recent (24h) · {sys.jobs_24h.done} done · {sys.jobs_24h.error} error</h2>
<div className="recent-grid">
{recent.map((j) => {
const img = j.outputs.find((o) => o.kind === "image");
return (
<div key={j.id} className="recent-cell">
{img
? <img src={assetFileURL(img.id)} alt={img.name} loading="lazy" />
: <div className="recent-icon">{STATUS_ICON[j.status]}</div>}
<div className="recent-cap">{j.operator}<span className="dim"> · {j.username} · {dur(j.duration_s)}</span></div>
</div>
);
})}
{!recent.length && <p className="dim">No finished jobs in the last 24h.</p>}
</div>
</>
)}
</div>
);
}

36
web/src/Login.jsx Normal file
View File

@ -0,0 +1,36 @@
import { useState } from "react";
import { login } from "./api";
export default function Login({ onLogin }) {
const [u, setU] = useState("");
const [p, setP] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setBusy(true);
setErr("");
try {
onLogin(await login(u.trim(), p));
} catch {
setErr("Invalid username or password");
setBusy(false);
}
};
return (
<div className="login-screen">
<form className="login-card" onSubmit={submit}>
<h1>MODEL<span>BEAST</span></h1>
<p className="dim">local 3D + image generation · M3 Ultra</p>
<input placeholder="username" value={u} autoFocus autoCapitalize="none"
autoComplete="username" onChange={(e) => setU(e.target.value)} />
<input type="password" placeholder="password" value={p}
autoComplete="current-password" onChange={(e) => setP(e.target.value)} />
{err && <div className="login-err">{err}</div>}
<button className="go" disabled={busy || !u || !p}>{busy ? "…" : "Sign in"}</button>
</form>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSettings, putSettings } from "./api"; import { addUser, createToken, delUser, getSettings, listUsers, patchUser, putSettings } from "./api";
const LABELS = { const LABELS = {
fal_key: "fal.ai API key", fal_key: "fal.ai API key",
@ -58,7 +58,59 @@ export default function Settings({ onClose, onSaved }) {
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </button>
</div> </div>
<Users />
</div> </div>
</div> </div>
); );
} }
function Users() {
const [users, setUsers] = useState([]);
const [nu, setNu] = useState("");
const [np, setNp] = useState("");
const [msg, setMsg] = useState("");
const load = () => listUsers().then(setUsers).catch(() => {});
useEffect(() => { load(); }, []);
const create = async () => {
setMsg("");
try {
await addUser({ username: nu.trim(), password: np });
setNu(""); setNp(""); load();
} catch (e) { setMsg(String(e.message || e).slice(0, 120)); }
};
const mkToken = async (username) => {
const r = await createToken({ username });
setMsg(`token for ${username} (copy now, shown once): ${r.token}`);
};
return (
<div className="users">
<h2>Users · friends can log in and run LOCAL models only</h2>
<table className="users-table">
<tbody>
{users.map((u) => (
<tr key={u.username}>
<td>{u.username}</td>
<td className="dim">{u.role}</td>
<td className="dim">cap {u.max_active_jobs}</td>
<td>
<button onClick={() => mkToken(u.username)} title="mint API token">token</button>
{u.role !== "owner" && <button onClick={() => delUser(u.username).then(load)}>×</button>}
</td>
</tr>
))}
</tbody>
</table>
<div className="user-add">
<input placeholder="new username" value={nu} autoCapitalize="none"
onChange={(e) => setNu(e.target.value)} />
<input type="password" placeholder="password (6+)" value={np}
onChange={(e) => setNp(e.target.value)} />
<button onClick={create} disabled={!nu || np.length < 6}>Add guest</button>
</div>
{msg && <p className="dim user-msg">{msg}</p>}
</div>
);
}

View File

@ -1,12 +1,36 @@
const BASE = ""; const BASE = "";
export async function api(path, opts = {}) { export async function api(path, opts = {}) {
const res = await fetch(BASE + path, opts); const res = await fetch(BASE + path, { credentials: "same-origin", ...opts });
if (res.status === 401) {
// session gone — tell App to show the login screen
window.dispatchEvent(new CustomEvent("mb-unauth"));
throw new Error("401 unauthorized");
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
return ct.includes("json") ? res.json() : res; return ct.includes("json") ? res.json() : res;
} }
// -- auth --------------------------------------------------------------------
export const getMe = () => api("/api/me");
export const login = (username, password) =>
api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) });
export const logout = () => api("/api/logout", { method: "POST" });
export const listUsers = () => api("/api/users");
export const addUser = (u) =>
api("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(u) });
export const patchUser = (username, u) =>
api(`/api/users/${username}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(u) });
export const delUser = (username) => api(`/api/users/${username}`, { method: "DELETE" });
export const createToken = (body) =>
api("/api/tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
export const getSystem = () => api("/api/system");
export const getRecentJobs = () => api("/api/jobs/recent");
export const listAssets = () => api("/api/assets"); export const listAssets = () => api("/api/assets");
export const listOperators = () => api("/api/operators"); export const listOperators = () => api("/api/operators");
export const listJobs = () => api("/api/jobs"); export const listJobs = () => api("/api/jobs");

View File

@ -113,3 +113,59 @@ header button.active { border-color: var(--accent); color: var(--accent); }
border-radius: 6px; padding: 7px 9px; width: 240px; } border-radius: 6px; padding: 7px 9px; width: 240px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-actions .go { width: auto; padding: 8px 18px; margin: 0; } .modal-actions .go { width: auto; padding: 8px 18px; margin: 0; }
/* ---- auth / login ---- */
.login-screen { position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; background: var(--bg); }
.login-card { background: var(--panel); border: 1px solid var(--edge); border-radius: 16px;
padding: 34px 36px; width: 320px; display: flex; flex-direction: column; gap: 12px; }
.login-card h1 { font-size: 24px; letter-spacing: 2px; text-align: center; }
.login-card h1 span { color: var(--accent); }
.login-card p { text-align: center; margin-top: -6px; }
.login-card input { background: var(--panel2); border: 1px solid var(--edge); color: var(--text);
border-radius: 8px; padding: 11px 12px; font-size: 14px; }
.login-card .go { width: 100%; margin-top: 6px; }
.login-err { color: var(--bad); font-size: 13px; text-align: center; }
.user-chip { font-size: 12.5px; color: var(--text); background: var(--panel2);
border: 1px solid var(--edge); border-radius: 20px; padding: 4px 12px; }
.guest-note { margin-top: 8px; font-style: italic; opacity: 0.8; }
/* ---- dashboard ---- */
.dash { flex: 1; overflow-y: auto; padding: 16px 20px; min-width: 0; }
.dash-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.dash h2 { margin: 18px 0 8px; }
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
.stat { background: var(--panel); border: 1px solid var(--edge); border-radius: 12px; padding: 14px; }
.stat-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.stat-top span { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.stat-top b { font-size: 18px; }
.bar { height: 8px; background: var(--panel2); border-radius: 6px; overflow: hidden; margin: 6px 0; }
.bar-fill { height: 100%; border-radius: 6px; transition: width 0.3s; }
.core-strip { display: flex; align-items: flex-end; gap: 2px; height: 40px; margin: 6px 0; }
.core { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; min-height: 3px; align-self: flex-end; opacity: 0.85; }
.lane-strip { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
.lane-card { background: var(--panel); border: 1px solid var(--edge); border-left: 3px solid var(--edge);
border-radius: 10px; padding: 10px 14px; display: flex; flex-direction: column; gap: 3px; min-width: 150px; }
.lane-card.busy { border-left-color: var(--accent); }
.lane-card b { font-size: 12px; letter-spacing: 1px; }
.lane-card span { font-size: 13px; color: var(--dim); }
.dash-table { width: 100%; border-collapse: collapse; }
.dash-table td { padding: 6px 10px 6px 0; font-size: 13px; border-bottom: 1px solid var(--edge); }
.dash-table button { padding: 2px 10px; font-size: 11px; }
.recent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.recent-cell { background: var(--panel); border: 1px solid var(--edge); border-radius: 10px; overflow: hidden; }
.recent-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.recent-icon { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; font-size: 28px; background: var(--panel2); }
.recent-cap { padding: 6px 8px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ---- users panel in settings ---- */
.users { margin-top: 22px; border-top: 1px solid var(--edge); padding-top: 14px; }
.users-table { width: 100%; border-collapse: collapse; }
.users-table td { padding: 5px 8px 5px 0; font-size: 13px; }
.users-table button { padding: 2px 9px; font-size: 11px; margin-left: 4px; }
.user-add { display: flex; gap: 8px; margin-top: 10px; }
.user-add input { flex: 1; background: var(--panel2); border: 1px solid var(--edge); color: var(--text);
border-radius: 6px; padding: 7px 9px; }
.user-msg { word-break: break-all; margin-top: 8px; background: var(--panel2); padding: 8px; border-radius: 6px;
font-family: "SF Mono", Menlo, monospace; font-size: 11.5px; }