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:
parent
4e66cc632e
commit
810400fb60
@ -4,8 +4,11 @@ version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"bcrypt>=5.0.0",
|
||||
"fal-client>=1.0.0",
|
||||
"fastapi>=0.139.0",
|
||||
"itsdangerous>=2.2.0",
|
||||
"psutil>=7.2.2",
|
||||
"python-multipart>=0.0.32",
|
||||
"uvicorn[standard]>=0.51.0",
|
||||
]
|
||||
|
||||
53
scripts/users.py
Normal file
53
scripts/users.py
Normal 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
188
server/auth.py
Normal 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
|
||||
16
server/db.py
16
server/db.py
@ -39,12 +39,28 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
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
|
||||
# existing data/modelbeast.db upgrades in place.
|
||||
MIGRATIONS = [
|
||||
("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"),
|
||||
("jobs", "user_id", "TEXT"), # nullable; legacy rows = owner-era
|
||||
("assets", "user_id", "TEXT"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
276
server/main.py
276
server/main.py
@ -1,17 +1,20 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
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.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
|
||||
|
||||
app = FastAPI(title="MODELBEAST")
|
||||
WEB_DIST = db.ROOT / "web" / "dist"
|
||||
INBOX = db.DATA / "inbox"
|
||||
MAX_UPLOAD = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@ -20,62 +23,196 @@ async def startup():
|
||||
con = db.connect()
|
||||
app.state.con = 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(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")
|
||||
def list_operators():
|
||||
return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()]
|
||||
def list_operators(user=Depends(auth.current_user)):
|
||||
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")
|
||||
def get_settings():
|
||||
s = settings_mod.get_all(app.state.con)
|
||||
def get_settings(request: Request, owner=Depends(auth.require_owner)):
|
||||
s = settings_mod.get_all(_con(request))
|
||||
view = settings_mod.public_view(s)
|
||||
view["_secret_keys"] = sorted(settings_mod.SECRET_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))
|
||||
return view
|
||||
|
||||
|
||||
@app.put("/api/settings")
|
||||
def put_settings(payload: dict):
|
||||
# ignore masked secret placeholders so re-saving the form doesn't wipe a key
|
||||
# (an empty string is a deliberate clear, not a placeholder)
|
||||
def put_settings(payload: dict, request: Request, owner=Depends(auth.require_owner)):
|
||||
clean = {k: v for k, v in payload.items()
|
||||
if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {"•"})}
|
||||
settings_mod.set_many(app.state.con, clean)
|
||||
return get_settings()
|
||||
settings_mod.set_many(_con(request), clean)
|
||||
return get_settings(request, owner)
|
||||
|
||||
|
||||
# -- assets ---------------------------------------------------------------------
|
||||
# -- assets --------------------------------------------------------------------
|
||||
@app.get("/api/assets")
|
||||
def list_assets():
|
||||
return store.list_assets(app.state.con)
|
||||
def list_assets(user=Depends(auth.current_user), request: Request = None):
|
||||
con = _con(request)
|
||||
return _with_usernames(con, store.list_assets(con))
|
||||
|
||||
|
||||
@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()
|
||||
if not data:
|
||||
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}")
|
||||
def delete_asset(asset_id: str):
|
||||
if not store.delete_asset(app.state.con, asset_id):
|
||||
def delete_asset(asset_id: str, user=Depends(auth.current_user), request: Request = None):
|
||||
con = _con(request)
|
||||
asset = store.get_asset(con, asset_id)
|
||||
if not 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}
|
||||
|
||||
|
||||
@app.get("/api/assets/{asset_id}/file")
|
||||
def asset_file(asset_id: str, member: str | None = None):
|
||||
asset = store.get_asset(app.state.con, asset_id)
|
||||
def asset_file(asset_id: str, member: str | None = None,
|
||||
user=Depends(auth.current_user), request: Request = None):
|
||||
asset = store.get_asset(_con(request), asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(404, "no such asset")
|
||||
path = Path(asset["path"])
|
||||
@ -90,66 +227,99 @@ def asset_file(asset_id: str, member: str | None = None):
|
||||
return FileResponse(path, filename=asset["name"])
|
||||
|
||||
|
||||
# -- jobs -----------------------------------------------------------------------
|
||||
# -- jobs ----------------------------------------------------------------------
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs():
|
||||
return runner.list_jobs(app.state.con)
|
||||
def list_jobs(user=Depends(auth.current_user), request: Request = None):
|
||||
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")
|
||||
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")
|
||||
if operator not in runner.operators:
|
||||
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")
|
||||
if not asset_ids:
|
||||
asset_ids = [payload["asset_id"]] if payload.get("asset_id") else []
|
||||
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}")
|
||||
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})
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: str):
|
||||
job = runner.get_job(app.state.con, job_id)
|
||||
def get_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
|
||||
job = runner.get_job(_con(request), job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "no such 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")
|
||||
async def cancel_job(job_id: str):
|
||||
if not await runner.cancel(app.state.con, job_id):
|
||||
async def cancel_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
|
||||
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")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/retry")
|
||||
async def retry_job(job_id: str):
|
||||
job = runner.get_job(app.state.con, job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "no such job")
|
||||
new = runner.create_job(app.state.con, job["operator"],
|
||||
job.get("asset_ids") or [], job["params"])
|
||||
async def retry_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
|
||||
con = _con(request)
|
||||
job = _own_job_or_owner(con, job_id, user)
|
||||
op = runner.operators.get(job["operator"], {})
|
||||
if op.get("requires_env") and user["role"] != "owner":
|
||||
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})
|
||||
return new
|
||||
|
||||
|
||||
@app.delete("/api/jobs/{job_id}")
|
||||
def delete_job(job_id: str):
|
||||
if not runner.delete_job(app.state.con, job_id):
|
||||
def delete_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
|
||||
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")
|
||||
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():
|
||||
"""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)
|
||||
seen: dict[str, int] = {}
|
||||
while True:
|
||||
@ -171,9 +341,23 @@ async def watch_inbox():
|
||||
print(f"[inbox] {e}")
|
||||
|
||||
|
||||
# -- websocket ---------------------------------------------------------------
|
||||
# -- websocket (auth: session cookie or ?token=) -------------------------------
|
||||
@app.websocket("/ws")
|
||||
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()
|
||||
runner.subscribers.add(websocket)
|
||||
try:
|
||||
@ -183,6 +367,6 @@ async def ws(websocket: WebSocket):
|
||||
runner.subscribers.discard(websocket)
|
||||
|
||||
|
||||
# -- frontend (built) -----------------------------------------------------------
|
||||
# -- frontend (built) — public so the SPA can load and show the login screen ----
|
||||
if WEB_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web")
|
||||
|
||||
@ -52,21 +52,28 @@ class Runner:
|
||||
return lane if lane in LANE_LIMITS else "cpu"
|
||||
|
||||
# -- 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()
|
||||
outdir = JOBS_DIR / job_id
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
primary = asset_ids[0] if asset_ids else None
|
||||
con.execute(
|
||||
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) "
|
||||
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at, user_id) "
|
||||
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?)",
|
||||
(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()
|
||||
self.queue.put_nowait(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:
|
||||
row = con.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
return db.row_to_dict(row) if row else None
|
||||
|
||||
@ -17,6 +17,9 @@ ENV_MAP = {
|
||||
# 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"}
|
||||
|
||||
# internal keys never exposed to the settings API or env injection (auth signing key)
|
||||
HIDDEN_KEYS = {"auth_secret"}
|
||||
|
||||
DEFAULTS = {
|
||||
"archive_host": "m3ultra@100.69.21.128",
|
||||
"archive_path": "~/modelbeast-archive",
|
||||
@ -27,12 +30,16 @@ def get_all(con) -> dict:
|
||||
rows = con.execute("SELECT key, value FROM settings").fetchall()
|
||||
out = dict(DEFAULTS)
|
||||
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"]
|
||||
return out
|
||||
|
||||
|
||||
def set_many(con, updates: dict) -> None:
|
||||
for key, value in updates.items():
|
||||
if key in HIDDEN_KEYS:
|
||||
continue # never settable through the settings API
|
||||
con.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
|
||||
@ -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,
|
||||
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."""
|
||||
name = name or src.name
|
||||
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)
|
||||
kind = meta.pop("kind", kind)
|
||||
con.execute(
|
||||
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now()),
|
||||
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now(), user_id),
|
||||
)
|
||||
con.commit()
|
||||
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()
|
||||
dest_dir = ASSETS_DIR / asset_id
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / name
|
||||
dest.write_bytes(data)
|
||||
con.execute(
|
||||
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, '{}', NULL, ?)",
|
||||
(asset_id, name, kind_of(name), str(dest), len(data), db.now()),
|
||||
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, '{}', NULL, ?, ?)",
|
||||
(asset_id, name, kind_of(name), str(dest), len(data), db.now(), user_id),
|
||||
)
|
||||
con.commit()
|
||||
return get_asset(con, asset_id)
|
||||
|
||||
128
server/sysinfo.py
Normal file
128
server/sysinfo.py
Normal 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
|
||||
120
tests/smoke.sh
120
tests/smoke.sh
@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test against a throwaway data dir on a scratch port.
|
||||
# Validates: operators load, settings CRUD, upload, ffprobe/ffmpeg_frames/
|
||||
# blender_convert run to completion, outputs register, cancel + retry work.
|
||||
# Validates: auth (401/login/token), operators/settings, the 3 core operators,
|
||||
# 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
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
@ -9,11 +10,13 @@ PORT=${PORT:-8799}
|
||||
TMP=$(mktemp -d)
|
||||
BLENDER="/Applications/Blender.app/Contents/MacOS/Blender"
|
||||
UV=/opt/homebrew/bin/uv
|
||||
OJAR="$TMP/owner.cj"; GJAR="$TMP/guest.cj"; CJAR="$TMP/capped.cj"
|
||||
pass=0; fail=0
|
||||
say() { printf "\n\033[1m== %s ==\033[0m\n" "$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)); }
|
||||
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"; }
|
||||
trap cleanup EXIT
|
||||
@ -31,47 +34,80 @@ ok "test glb"
|
||||
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 &
|
||||
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
|
||||
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"
|
||||
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
|
||||
sleep 1
|
||||
DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$B/api/jobs/$JOB")
|
||||
[ "$DEL" = 200 ] && ok "delete job" || bad "delete ($DEL)"
|
||||
B="http://127.0.0.1:$PORT"
|
||||
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"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
109
uv.lock
generated
109
uv.lock
generated
@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "modelbeast"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fal-client" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@ -220,8 +298,11 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "fal-client", specifier = ">=1.0.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 = "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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings,
|
||||
listAssets, listJobs, listOperators, retryJob, runJob, uploadFile,
|
||||
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getMe, getSettings,
|
||||
listAssets, listJobs, listOperators, logout, retryJob, runJob, uploadFile,
|
||||
} from "./api";
|
||||
import Viewer from "./Viewer";
|
||||
import SplatViewer from "./SplatViewer";
|
||||
import Settings from "./Settings";
|
||||
import Login from "./Login";
|
||||
import Dashboard from "./Dashboard";
|
||||
|
||||
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
|
||||
const fmtSize = (n) =>
|
||||
@ -97,6 +99,8 @@ function CompareGrid({ assets }) {
|
||||
}
|
||||
|
||||
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 [operators, setOperators] = useState([]);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
@ -109,26 +113,42 @@ export default function App() {
|
||||
const [compareMode, setCompareMode] = useState(false);
|
||||
const [compareSel, setCompareSel] = useState([]);
|
||||
const fileInput = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
|
||||
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
|
||||
const refreshSettings = useCallback(() => getSettings().then(setSettings), []);
|
||||
const refreshAssets = useCallback(() => listAssets().then(setAssets).catch(() => {}), []);
|
||||
const refreshJobs = useCallback(() => listJobs().then(setJobs).catch(() => {}), []);
|
||||
const refreshSettings = useCallback(() => getSettings().then(setSettings).catch(() => {}), []);
|
||||
|
||||
// auth bootstrap
|
||||
useEffect(() => {
|
||||
refreshAssets(); refreshJobs(); refreshSettings();
|
||||
listOperators().then(setOperators);
|
||||
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();
|
||||
});
|
||||
getMe().then(setUser).catch(() => setUser(null));
|
||||
const onUnauth = () => setUser(null);
|
||||
window.addEventListener("mb-unauth", onUnauth);
|
||||
return () => window.removeEventListener("mb-unauth", onUnauth);
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
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(); };
|
||||
@ -180,17 +200,26 @@ export default function App() {
|
||||
};
|
||||
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 (
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>MODEL<span>BEAST</span></h1>
|
||||
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
|
||||
<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})` : ""}
|
||||
</button>
|
||||
<button onClick={() => setShowSettings(true)}>⚙ Settings</button>
|
||||
{user.is_owner && <button onClick={() => setShowSettings(true)}>⚙ Settings</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
|
||||
onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} />
|
||||
</header>
|
||||
@ -218,9 +247,11 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
{compareMode && compareAssets.length
|
||||
? <CompareGrid assets={compareAssets} />
|
||||
: <AssetPreview asset={selectedAsset} />}
|
||||
{view === "dashboard"
|
||||
? <Dashboard jobs={jobs} />
|
||||
: compareMode && compareAssets.length
|
||||
? <CompareGrid assets={compareAssets} />
|
||||
: <AssetPreview asset={selectedAsset} />}
|
||||
</main>
|
||||
|
||||
<aside className="workbench">
|
||||
@ -239,6 +270,7 @@ export default function App() {
|
||||
<button className="go" disabled={!activeOp || gated || (needsInput && !selectedAsset)} onClick={launch}>
|
||||
▶ Run {activeOp?.name || ""}
|
||||
</button>
|
||||
{!user.is_owner && <p className="dim guest-note">local models only — everything you run is generated on this machine</p>}
|
||||
|
||||
<h2>Jobs</h2>
|
||||
<div className="jobs">
|
||||
@ -247,7 +279,7 @@ export default function App() {
|
||||
<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="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()}>
|
||||
{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>}
|
||||
@ -263,7 +295,7 @@ export default function App() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{showSettings && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
|
||||
{showSettings && user.is_owner && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
121
web/src/Dashboard.jsx
Normal file
121
web/src/Dashboard.jsx
Normal 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
36
web/src/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSettings, putSettings } from "./api";
|
||||
import { addUser, createToken, delUser, getSettings, listUsers, patchUser, putSettings } from "./api";
|
||||
|
||||
const LABELS = {
|
||||
fal_key: "fal.ai API key",
|
||||
@ -58,7 +58,59 @@ export default function Settings({ onClose, onSaved }) {
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Users />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,36 @@
|
||||
const BASE = "";
|
||||
|
||||
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()}`);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
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 listOperators = () => api("/api/operators");
|
||||
export const listJobs = () => api("/api/jobs");
|
||||
|
||||
@ -113,3 +113,59 @@ header button.active { border-color: var(--accent); color: var(--accent); }
|
||||
border-radius: 6px; padding: 7px 9px; width: 240px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
.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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user