Samfredoly commited on
Commit
270462f
Β·
verified Β·
1 Parent(s): 794573a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1184 -0
app.py ADDED
@@ -0,0 +1,1184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ hf_spaces_status.py β€” HuggingFace Spaces Manager
4
+ FastAPI web app to create, monitor, and manage HuggingFace Spaces for miners.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ import json
10
+ import threading
11
+ import requests
12
+ from pathlib import Path
13
+ from datetime import datetime, timezone
14
+ from typing import Optional, Dict, List
15
+ from contextlib import asynccontextmanager
16
+
17
+ import uvicorn
18
+ from fastapi import FastAPI, Request, BackgroundTasks
19
+ from fastapi.responses import HTMLResponse, JSONResponse
20
+
21
+ # ── Constants ─────────────────────────────────────────────────────────────────
22
+ MINE_DIR = Path(__file__).parent / "mine_dep"
23
+ DATABASE_URL = "postgresql://postgres.wqbxzautyxwsnxxhowfb:Lovyelias5584.@aws-1-eu-west-1.pooler.supabase.com:6543/postgres"
24
+
25
+ UPLOAD_EXCLUDES = {
26
+ "__pycache__", ".cache", "xmrig", "result.json",
27
+ "pool_job.json", "xmrig_local_config.json", "Procfile",
28
+ "railway.toml", ".railwayignore",
29
+ }
30
+
31
+ # ── Shared State ──────────────────────────────────────────────────────────────
32
+ state: Dict = {
33
+ "accounts": [], # [{username, token}]
34
+ "spaces": [], # [{id, username, name, status, prefix, ...}]
35
+ "log": [],
36
+ "monitor_running": False,
37
+ "monitor_stop": False,
38
+ "monitor_wakeup": threading.Event(), # signal to wake monitor early
39
+ "creation_in_progress": False,
40
+ }
41
+ state_lock = threading.Lock()
42
+
43
+
44
+ def _log(msg: str, level: str = "INFO"):
45
+ entry = {
46
+ "time": datetime.now(timezone.utc).isoformat(),
47
+ "level": level,
48
+ "msg": msg,
49
+ }
50
+ with state_lock:
51
+ state["log"].append(entry)
52
+ if len(state["log"]) > 600:
53
+ state["log"] = state["log"][-600:]
54
+ print(f"[{level}] {msg}", flush=True)
55
+
56
+
57
+ # ── HF API Helpers ────────────────────────────────────────────────────────────
58
+
59
+ def hf_headers(token: str) -> Dict:
60
+ return {"Authorization": f"Bearer {token}"}
61
+
62
+
63
+ def hf_create_space(username: str, token: str, space_name: str) -> Optional[Dict]:
64
+ url = "https://huggingface.co/api/repos/create"
65
+ payload = {
66
+ "type": "space", "name": space_name,
67
+ "private": True, "sdk": "docker",
68
+ "hardware": "cpu-basic",
69
+ }
70
+ resp = requests.post(url, json=payload, headers=hf_headers(token), timeout=30)
71
+ if resp.status_code in (200, 201):
72
+ return resp.json()
73
+ _log(f"Failed to create {username}/{space_name}: {resp.status_code} {resp.text[:200]}", "ERROR")
74
+ return None
75
+
76
+
77
+ def hf_upload_file(username, token, space_name, local_path, repo_path) -> bool:
78
+ url = f"https://huggingface.co/api/spaces/{username}/{space_name}/upload/{repo_path}"
79
+ try:
80
+ with open(local_path, "rb") as f:
81
+ content = f.read()
82
+ resp = requests.post(
83
+ url,
84
+ headers={**hf_headers(token), "Content-Type": "application/octet-stream"},
85
+ data=content, timeout=60,
86
+ )
87
+ return resp.status_code in (200, 201)
88
+ except Exception as e:
89
+ _log(f"Upload error for {repo_path}: {e}", "ERROR")
90
+ return False
91
+
92
+
93
+ def hf_set_secret(username, token, space_name, key, value) -> bool:
94
+ url = f"https://huggingface.co/api/spaces/{username}/{space_name}/secrets"
95
+ resp = requests.post(url, json={"key": key, "value": value},
96
+ headers=hf_headers(token), timeout=30)
97
+ return resp.status_code in (200, 201, 204)
98
+
99
+
100
+ def hf_get_runtime(space_id: str, token: str) -> Dict:
101
+ url = f"https://huggingface.co/api/spaces/{space_id}/runtime"
102
+ try:
103
+ resp = requests.get(url, headers=hf_headers(token), timeout=15)
104
+ if resp.status_code == 200:
105
+ return resp.json()
106
+ except Exception:
107
+ pass
108
+ return {}
109
+
110
+
111
+ def hf_restart_space(space_id: str, token: str) -> bool:
112
+ url = f"https://huggingface.co/api/spaces/{space_id}/restart"
113
+ try:
114
+ resp = requests.post(url, headers=hf_headers(token), timeout=30)
115
+ return resp.status_code in (200, 201, 204)
116
+ except Exception:
117
+ return False
118
+
119
+
120
+ def hf_list_spaces(username: str, token: str) -> List[Dict]:
121
+ url = f"https://huggingface.co/api/spaces?author={username}&limit=200&full=true"
122
+ try:
123
+ resp = requests.get(url, headers=hf_headers(token), timeout=30)
124
+ if resp.status_code == 200:
125
+ return resp.json()
126
+ except Exception:
127
+ pass
128
+ return []
129
+
130
+
131
+ def hf_delete_space(space_id: str, token: str) -> bool:
132
+ url = "https://huggingface.co/api/repos/delete"
133
+ space_name = space_id.split("/")[-1]
134
+ payload = {"type": "space", "name": space_name}
135
+ resp = requests.delete(url, json=payload, headers=hf_headers(token), timeout=30)
136
+ if resp.status_code in (200, 201, 204):
137
+ return True
138
+
139
+ _log(f"Delete API Error for {space_id}: {resp.status_code} {resp.text[:200]}", "WARN")
140
+
141
+ # Alternative endpoint
142
+ if resp.status_code == 404:
143
+ url2 = f"https://huggingface.co/api/spaces/{space_id}"
144
+ resp2 = requests.delete(url2, headers=hf_headers(token), timeout=30)
145
+ if resp2.status_code in (200, 201, 204):
146
+ return True
147
+ _log(f"Delete API Error 2 for {space_id}: {resp2.status_code} {resp2.text[:200]}", "WARN")
148
+
149
+ return False
150
+
151
+
152
+ # ── Creation Worker ───────────────────────────────────────────────────────────
153
+
154
+ def create_spaces_worker(username: str, token: str, prefix: str, count: int):
155
+ with state_lock:
156
+ state["creation_in_progress"] = True
157
+ existing = [a["username"] for a in state["accounts"]]
158
+ if username not in existing:
159
+ state["accounts"].append({"username": username, "token": token})
160
+
161
+ # We use HfApi for folder upload
162
+ from huggingface_hub import HfApi
163
+ api = HfApi(token=token)
164
+
165
+ for i in range(1, count + 1):
166
+ space_name = f"{prefix}{i}"
167
+ space_id = f"{username}/{space_name}"
168
+ _log(f"[{i}/{count}] Creating space: {space_id}")
169
+
170
+ result = hf_create_space(username, token, space_name)
171
+ if result is None:
172
+ with state_lock:
173
+ state["spaces"].append({
174
+ "id": space_id, "username": username, "name": space_name,
175
+ "status": "CREATE_FAILED", "prefix": prefix,
176
+ "created_at": datetime.now(timezone.utc).isoformat(), "error": "Creation failed",
177
+ })
178
+ continue
179
+
180
+ _log(f" βœ… Space created: {space_id}")
181
+
182
+ _log(f" ⬆️ Uploading files from {MINE_DIR.name}/ to {space_id}...")
183
+ try:
184
+ api.upload_folder(
185
+ folder_path=str(MINE_DIR),
186
+ repo_id=space_id,
187
+ repo_type="space",
188
+ ignore_patterns=list(UPLOAD_EXCLUDES) + ["*.pyc"]
189
+ )
190
+ _log(f" πŸ“ Uploaded files successfully")
191
+ ok_ct = len(list(MINE_DIR.glob("*"))) # just visual
192
+ except Exception as e:
193
+ _log(f" ❌ Upload failed: {e}", "ERROR")
194
+ ok_ct = 0
195
+
196
+ env_ok = hf_set_secret(username, token, space_name, "DATABASE_URL", DATABASE_URL)
197
+ _log(f" {'πŸ”‘ DATABASE_URL set' if env_ok else '⚠️ Failed to set DATABASE_URL'} on {space_id}",
198
+ "INFO" if env_ok else "WARN")
199
+
200
+ with state_lock:
201
+ state["spaces"].append({
202
+ "id": space_id, "username": username, "name": space_name,
203
+ "status": "BUILDING", "prefix": prefix,
204
+ "created_at": datetime.now(timezone.utc).isoformat(),
205
+ "error": "", "files_uploaded": ok_ct,
206
+ })
207
+ time.sleep(1)
208
+
209
+ with state_lock:
210
+ state["creation_in_progress"] = False
211
+ _log(f"πŸŽ‰ Done creating {count} spaces with prefix '{prefix}'")
212
+
213
+
214
+ # ── Delete Worker ─────────────────────────────────────────────────────────────
215
+
216
+ def delete_spaces_worker(username: str, token: str):
217
+ """Delete ALL spaces for this account (both tracked and fetched from HF)."""
218
+ _log(f"πŸ—‘οΈ Fetching all spaces for @{username} to delete...")
219
+
220
+ remote_spaces = hf_list_spaces(username, token)
221
+ remote_ids = [s["id"] for s in remote_spaces]
222
+ _log(f" Found {len(remote_ids)} spaces on HuggingFace for @{username}")
223
+
224
+ deleted = 0
225
+ failed = 0
226
+ for sid in remote_ids:
227
+ ok = hf_delete_space(sid, token)
228
+ if ok:
229
+ _log(f" πŸ—‘οΈ Deleted {sid}")
230
+ deleted += 1
231
+ else:
232
+ _log(f" ❌ Failed to delete {sid}", "WARN")
233
+ failed += 1
234
+
235
+ # Remove from local state
236
+ with state_lock:
237
+ state["spaces"] = [s for s in state["spaces"] if s["id"] != sid]
238
+
239
+ _log(f"βœ… Deletion complete: {deleted} deleted, {failed} failed for @{username}")
240
+
241
+
242
+ # ── Monitor Worker ────────────────────────────────────────────────────────────
243
+
244
+ def _run_monitor_cycle():
245
+ """One scan cycle: fetch all spaces for all accounts, check runtimes, restart errors."""
246
+ with state_lock:
247
+ accounts = list(state["accounts"])
248
+
249
+ if not accounts:
250
+ return
251
+
252
+ token_map = {a["username"]: a["token"] for a in accounts}
253
+ _log(f"πŸ”„ Monitor scanning {len(accounts)} account(s)...")
254
+
255
+ # For each account, pull ALL spaces from HF (not just ones we created)
256
+ for acc in accounts:
257
+ username = acc["username"]
258
+ token = acc["token"]
259
+ remote = hf_list_spaces(username, token)
260
+
261
+ for rs in remote:
262
+ sid = rs.get("id") or f"{username}/{rs.get('modelId','?')}"
263
+
264
+ # Get runtime
265
+ rt = hf_get_runtime(sid, token)
266
+ stage = rt.get("stage", "UNKNOWN")
267
+ error = rt.get("errorMessage", "")
268
+ hw = rt.get("hardware", {})
269
+ hw_curr = hw.get("current", "?") if isinstance(hw, dict) else str(hw)
270
+
271
+ # Upsert into state
272
+ with state_lock:
273
+ existing = next((i for i, s in enumerate(state["spaces"]) if s["id"] == sid), None)
274
+ entry = {
275
+ "id": sid, "username": username,
276
+ "name": sid.split("/")[-1],
277
+ "status": stage, "error": error,
278
+ "hardware": hw_curr,
279
+ "prefix": "",
280
+ "last_checked": datetime.now(timezone.utc).isoformat(),
281
+ }
282
+ if existing is None:
283
+ entry["created_at"] = datetime.now(timezone.utc).isoformat()
284
+ state["spaces"].append(entry)
285
+ else:
286
+ # preserve creation info
287
+ state["spaces"][existing].update({
288
+ "status": stage, "error": error,
289
+ "hardware": hw_curr,
290
+ "last_checked": entry["last_checked"],
291
+ })
292
+
293
+ if stage == "RUNTIME_ERROR":
294
+ _log(f" ❌ {sid} β†’ RUNTIME_ERROR β€” restarting...", "WARN")
295
+ ok = hf_restart_space(sid, token)
296
+ with state_lock:
297
+ for s in state["spaces"]:
298
+ if s["id"] == sid:
299
+ s["status"] = "RESTARTING" if ok else "RESTART_FAILED"
300
+ _log(f" {'βœ… Restarted' if ok else '❌ Restart failed'}: {sid}")
301
+
302
+
303
+ def monitor_worker():
304
+ """Background: scan all accounts immediately, then loop every 5 min."""
305
+ _log("πŸ” Monitor thread started")
306
+ with state_lock:
307
+ state["monitor_running"] = True
308
+ state["monitor_stop"] = False
309
+
310
+ while True:
311
+ with state_lock:
312
+ if state["monitor_stop"]:
313
+ break
314
+
315
+ _run_monitor_cycle()
316
+
317
+ # Wait up to 5 minutes but wake up early if signalled
318
+ wakeup_event = state["monitor_wakeup"]
319
+ wakeup_event.clear()
320
+ wakeup_event.wait(timeout=300) # 5 min or wake signal
321
+
322
+ with state_lock:
323
+ if state["monitor_stop"]:
324
+ break
325
+
326
+ with state_lock:
327
+ state["monitor_running"] = False
328
+ _log("πŸ›‘ Monitor thread stopped")
329
+
330
+
331
+ _monitor_thread: Optional[threading.Thread] = None
332
+
333
+
334
+ def ensure_monitor_running():
335
+ """Start monitor if not running."""
336
+ global _monitor_thread
337
+ with state_lock:
338
+ running = state["monitor_running"]
339
+ if not running:
340
+ with state_lock:
341
+ state["monitor_stop"] = False
342
+ _monitor_thread = threading.Thread(target=monitor_worker, daemon=True)
343
+ _monitor_thread.start()
344
+
345
+
346
+ def wake_monitor():
347
+ """Signal monitor to run a cycle immediately."""
348
+ state["monitor_wakeup"].set()
349
+
350
+
351
+ # ── FastAPI App ───────────────────────────────────────────────────────────────
352
+
353
+ @asynccontextmanager
354
+ async def lifespan(app: FastAPI):
355
+ global _monitor_thread
356
+ _monitor_thread = threading.Thread(target=monitor_worker, daemon=True)
357
+ _monitor_thread.start()
358
+ yield
359
+ with state_lock:
360
+ state["monitor_stop"] = True
361
+ wake_monitor()
362
+
363
+
364
+ app = FastAPI(title="HF Spaces Manager", lifespan=lifespan)
365
+
366
+
367
+ @app.get("/", response_class=HTMLResponse)
368
+ async def dashboard():
369
+ return HTMLResponse(content=get_html())
370
+
371
+
372
+ @app.post("/api/create")
373
+ async def api_create(req: Request, background_tasks: BackgroundTasks):
374
+ body = await req.json()
375
+ username = body.get("username", "").strip()
376
+ token = body.get("token", "").strip()
377
+ prefix = body.get("prefix", "").strip()
378
+ count = int(body.get("count", 20))
379
+
380
+ if not username or not token or not prefix:
381
+ return JSONResponse({"ok": False, "error": "username, token and prefix are required"}, status_code=400)
382
+ if count < 1 or count > 50:
383
+ return JSONResponse({"ok": False, "error": "count must be 1-50"}, status_code=400)
384
+
385
+ with state_lock:
386
+ if state["creation_in_progress"]:
387
+ return JSONResponse({"ok": False, "error": "Creation already in progress"}, status_code=409)
388
+
389
+ background_tasks.add_task(create_spaces_worker, username, token, prefix, count)
390
+ return JSONResponse({"ok": True, "msg": f"Creating {count} spaces with prefix '{prefix}'"})
391
+
392
+
393
+ @app.post("/api/add-account")
394
+ async def api_add_account(req: Request):
395
+ body = await req.json()
396
+ username = body.get("username", "").strip()
397
+ token = body.get("token", "").strip()
398
+
399
+ if not username or not token:
400
+ return JSONResponse({"ok": False, "error": "username and token are required"}, status_code=400)
401
+
402
+ with state_lock:
403
+ existing = [a["username"] for a in state["accounts"]]
404
+ if username in existing:
405
+ # Update token if changed
406
+ for a in state["accounts"]:
407
+ if a["username"] == username:
408
+ a["token"] = token
409
+ added = False
410
+ else:
411
+ state["accounts"].append({"username": username, "token": token})
412
+ added = True
413
+
414
+ _log(f"{'Account added' if added else 'Account token updated'}: @{username}")
415
+
416
+ # Immediately trigger a monitor scan to pick up all spaces for this account
417
+ ensure_monitor_running()
418
+ wake_monitor()
419
+
420
+ return JSONResponse({"ok": True, "added": added})
421
+
422
+
423
+ @app.get("/api/accounts")
424
+ async def api_accounts():
425
+ with state_lock:
426
+ return JSONResponse([{"username": a["username"]} for a in state["accounts"]])
427
+
428
+
429
+ # ── File Manager API ──────────────────────────────────────────────────────────
430
+
431
+ @app.get("/api/files")
432
+ async def api_list_files():
433
+ """List all files in the mine_dep directory"""
434
+ if not MINE_DIR.exists():
435
+ MINE_DIR.mkdir(parents=True, exist_ok=True)
436
+
437
+ files = []
438
+ for f in MINE_DIR.rglob("*"):
439
+ if f.is_file():
440
+ # Filter out ignored/hidden files
441
+ rel_path = f.relative_to(MINE_DIR).as_posix()
442
+ if any(part.startswith('.') for part in rel_path.split('/')) or rel_path in UPLOAD_EXCLUDES or '__pycache__' in rel_path:
443
+ continue
444
+ files.append(rel_path)
445
+
446
+ return JSONResponse({"ok": True, "files": sorted(files)})
447
+
448
+
449
+ @app.get("/api/files/{filename:path}")
450
+ async def api_get_file(filename: str):
451
+ """Read contents of a file in mine_dep"""
452
+ target = (MINE_DIR / filename).resolve()
453
+ if not str(target).startswith(str(MINE_DIR.resolve())):
454
+ return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
455
+
456
+ if not target.exists() or not target.is_file():
457
+ return JSONResponse({"ok": False, "error": "File not found"}, status_code=404)
458
+
459
+ try:
460
+ content = target.read_text(encoding="utf-8")
461
+ return JSONResponse({"ok": True, "content": content})
462
+ except Exception as e:
463
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
464
+
465
+
466
+ @app.post("/api/files/{filename:path}")
467
+ async def api_save_file(filename: str, req: Request):
468
+ """Create or update a file in mine_dep"""
469
+ target = (MINE_DIR / filename).resolve()
470
+ if not str(target).startswith(str(MINE_DIR.resolve())):
471
+ return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
472
+
473
+ body = await req.json()
474
+ content = body.get("content", "")
475
+
476
+ try:
477
+ target.parent.mkdir(parents=True, exist_ok=True)
478
+ target.write_text(content, encoding="utf-8")
479
+ _log(f"πŸ’Ύ File saved: {filename}")
480
+ return JSONResponse({"ok": True, "msg": f"Saved {filename}"})
481
+ except Exception as e:
482
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
483
+
484
+
485
+ @app.delete("/api/files/{filename:path}")
486
+ async def api_delete_file(filename: str):
487
+ """Delete a file from mine_dep"""
488
+ target = (MINE_DIR / filename).resolve()
489
+ if not str(target).startswith(str(MINE_DIR.resolve())):
490
+ return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
491
+
492
+ if not target.exists() or not target.is_file():
493
+ return JSONResponse({"ok": False, "error": "File not found"}, status_code=404)
494
+
495
+ try:
496
+ target.unlink()
497
+ _log(f"πŸ—‘οΈ File deleted: {filename}")
498
+ return JSONResponse({"ok": True, "msg": f"Deleted {filename}"})
499
+ except Exception as e:
500
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
501
+
502
+ with state_lock:
503
+ existing = [a["username"] for a in state["accounts"]]
504
+ if username in existing:
505
+ # Update token if changed
506
+ for a in state["accounts"]:
507
+ if a["username"] == username:
508
+ a["token"] = token
509
+ added = False
510
+ else:
511
+ state["accounts"].append({"username": username, "token": token})
512
+ added = True
513
+
514
+ _log(f"{'Account added' if added else 'Account token updated'}: @{username}")
515
+
516
+ # Immediately trigger a monitor scan to pick up all spaces for this account
517
+ ensure_monitor_running()
518
+ wake_monitor()
519
+
520
+ return JSONResponse({"ok": True, "added": added})
521
+
522
+
523
+ @app.get("/api/accounts")
524
+ async def api_accounts():
525
+ with state_lock:
526
+ return JSONResponse([{"username": a["username"]} for a in state["accounts"]])
527
+
528
+
529
+ @app.post("/api/delete-spaces")
530
+ async def api_delete_spaces(req: Request, background_tasks: BackgroundTasks):
531
+ """Delete all HF spaces for a given account (uses saved token by default)."""
532
+ body = await req.json()
533
+ username = body.get("username", "").strip()
534
+ token = body.get("token", "").strip()
535
+
536
+ # Try to use saved token if none provided
537
+ if not token:
538
+ with state_lock:
539
+ for a in state["accounts"]:
540
+ if a["username"] == username:
541
+ token = a["token"]
542
+ break
543
+
544
+ if not username or not token:
545
+ return JSONResponse({"ok": False, "error": "username required (token from saved account or provide manually)"}, status_code=400)
546
+
547
+ background_tasks.add_task(delete_spaces_worker, username, token)
548
+ return JSONResponse({"ok": True, "msg": f"Deletion started for @{username}"})
549
+
550
+
551
+ @app.get("/api/stats")
552
+ async def api_stats():
553
+ with state_lock:
554
+ spaces = list(state["spaces"])
555
+ accounts = list(state["accounts"])
556
+ log = list(state["log"][-80:])
557
+ monitor = state["monitor_running"]
558
+ creating = state["creation_in_progress"]
559
+
560
+ running = sum(1 for s in spaces if s.get("status") == "RUNNING")
561
+ building = sum(1 for s in spaces if s.get("status") in ("BUILDING", "APP_STARTING", "RESTARTING"))
562
+ errors = sum(1 for s in spaces if "ERROR" in s.get("status", ""))
563
+ sleeping = sum(1 for s in spaces if s.get("status") == "SLEEPING")
564
+
565
+ return JSONResponse({
566
+ "accounts": len(accounts),
567
+ "account_names": [a["username"] for a in accounts],
568
+ "total_spaces": len(spaces),
569
+ "running": running, "building": building,
570
+ "error": errors, "sleeping": sleeping,
571
+ "monitor_running": monitor,
572
+ "creation_in_progress": creating,
573
+ "spaces": spaces,
574
+ "log": log,
575
+ })
576
+
577
+
578
+ @app.post("/api/restart/{space_id:path}")
579
+ async def api_restart_space(space_id: str):
580
+ with state_lock:
581
+ accounts = list(state["accounts"])
582
+ spaces = list(state["spaces"])
583
+ username = space_id.split("/")[0]
584
+ token_map = {a["username"]: a["token"] for a in accounts}
585
+ token = token_map.get(username)
586
+ if not token:
587
+ return JSONResponse({"ok": False, "error": "No saved token for that account"}, status_code=404)
588
+ ok = hf_restart_space(space_id, token)
589
+ if ok:
590
+ with state_lock:
591
+ for s in state["spaces"]:
592
+ if s["id"] == space_id:
593
+ s["status"] = "RESTARTING"
594
+ _log(f"Manual restart triggered: {space_id}")
595
+ return JSONResponse({"ok": ok})
596
+
597
+
598
+ @app.post("/api/monitor/stop")
599
+ async def api_monitor_stop():
600
+ with state_lock:
601
+ state["monitor_stop"] = True
602
+ wake_monitor()
603
+ return JSONResponse({"ok": True})
604
+
605
+
606
+ @app.post("/api/monitor/start")
607
+ async def api_monitor_start():
608
+ ensure_monitor_running()
609
+ wake_monitor()
610
+ return JSONResponse({"ok": True})
611
+
612
+
613
+ @app.post("/api/monitor/scan-now")
614
+ async def api_scan_now():
615
+ ensure_monitor_running()
616
+ wake_monitor()
617
+ _log("πŸ”” Instant scan triggered by user")
618
+ return JSONResponse({"ok": True})
619
+
620
+
621
+ # ── HTML UI ───────────────────────────────────────────────────────────────────
622
+
623
+ def get_html() -> str:
624
+ return r"""<!DOCTYPE html>
625
+ <html lang="en">
626
+ <head>
627
+ <meta charset="UTF-8"/>
628
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
629
+ <title>HF Spaces Manager</title>
630
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
631
+ <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"/>
632
+ <style>
633
+ :root {
634
+ --bg:#080c14; --surface:#0e1623; --card:#111927; --border:#1e2d42;
635
+ --accent:#3b82f6; --accent2:#8b5cf6; --green:#22c55e; --yellow:#eab308;
636
+ --red:#ef4444; --orange:#f97316; --text:#e2e8f0; --muted:#64748b; --radius:12px;
637
+ }
638
+ *{margin:0;padding:0;box-sizing:border-box;}
639
+ body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;min-height:100vh;overflow-x:hidden;}
640
+ body::before{content:'';position:fixed;inset:0;z-index:-1;
641
+ background:radial-gradient(ellipse 80% 80% at 20% 10%,rgba(59,130,246,.07) 0%,transparent 60%),
642
+ radial-gradient(ellipse 60% 60% at 80% 80%,rgba(139,92,246,.07) 0%,transparent 60%);}
643
+
644
+ .app{display:grid;grid-template-columns:310px 1fr;grid-template-rows:auto 1fr;height:100vh;}
645
+ header{
646
+ grid-column:1/-1;display:flex;align-items:center;gap:14px;
647
+ padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border);
648
+ }
649
+ .logo{font-size:19px;font-weight:700;background:linear-gradient(135deg,#3b82f6,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
650
+ .tagline{color:var(--muted);font-size:12px;}
651
+ .header-right{margin-left:auto;display:flex;gap:10px;align-items:center;}
652
+ .pill{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:600;border:1px solid;display:flex;align-items:center;gap:5px;}
653
+ .pill.green{color:var(--green);border-color:rgba(34,197,94,.3);background:rgba(34,197,94,.08);}
654
+ .pill.blue{color:var(--accent);border-color:rgba(59,130,246,.3);background:rgba(59,130,246,.08);}
655
+ .pill.yellow{color:var(--yellow);border-color:rgba(234,179,8,.3);background:rgba(234,179,8,.08);}
656
+ .pill.red{color:var(--red);border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08);}
657
+ .pill.purple{color:var(--accent2);border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.08);}
658
+ .dot{width:7px;height:7px;border-radius:50%;background:currentColor;}
659
+ .dot.pulse{animation:pulse 1.8s ease-in-out infinite;}
660
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
661
+
662
+ aside{
663
+ background:var(--surface);border-right:1px solid var(--border);
664
+ padding:16px;overflow-y:auto;display:flex;flex-direction:column;gap:18px;
665
+ }
666
+ .section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:8px;}
667
+ .field{display:flex;flex-direction:column;gap:4px;}
668
+ label{font-size:11px;font-weight:500;color:var(--muted);}
669
+ input,select{
670
+ background:#0a111e;border:1px solid var(--border);color:var(--text);
671
+ border-radius:8px;padding:8px 11px;font-size:12px;font-family:inherit;transition:border-color .2s;
672
+ }
673
+ input:focus,select:focus{outline:none;border-color:var(--accent);}
674
+ input::placeholder{color:var(--muted);}
675
+ .row{display:flex;gap:7px;}
676
+ .row input{flex:1;}
677
+
678
+ .btn{
679
+ padding:9px 14px;border-radius:8px;font-size:12px;font-weight:600;
680
+ cursor:pointer;border:none;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:7px;
681
+ }
682
+ .btn-primary{background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;}
683
+ .btn-primary:hover:not(:disabled){filter:brightness(1.15);transform:translateY(-1px);}
684
+ .btn-primary:disabled{opacity:.45;cursor:not-allowed;}
685
+ .btn-secondary{background:var(--card);color:var(--text);border:1px solid var(--border);}
686
+ .btn-secondary:hover{border-color:var(--accent);}
687
+ .btn-danger{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3);}
688
+ .btn-danger:hover{background:rgba(239,68,68,.25);}
689
+ .btn-sm{padding:4px 9px;font-size:11px;border-radius:6px;}
690
+ .btn-warn{background:rgba(234,179,8,.15);color:var(--yellow);border:1px solid rgba(234,179,8,.3);}
691
+ .btn-warn:hover{background:rgba(234,179,8,.25);}
692
+
693
+ .accounts-list{display:flex;flex-direction:column;gap:5px;}
694
+ .account-item{
695
+ background:var(--card);border:1px solid var(--border);border-radius:8px;
696
+ padding:7px 10px;font-size:11px;display:flex;align-items:center;gap:7px;
697
+ }
698
+ .account-item .u{font-weight:600;color:var(--accent);flex:1;}
699
+ .account-item .badge{font-size:9px;color:var(--muted);font-family:'JetBrains Mono',monospace;}
700
+
701
+ /* Progress */
702
+ .prog-wrap{width:100%;background:var(--border);border-radius:4px;height:5px;overflow:hidden;display:none;margin-top:4px;}
703
+ .prog-bar{height:100%;background:linear-gradient(90deg,#3b82f6,#8b5cf6);border-radius:4px;transition:width .4s;}
704
+
705
+ main{display:flex;flex-direction:column;overflow:hidden;}
706
+ .tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surface);}
707
+ .tab{padding:11px 18px;font-size:12px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent;color:var(--muted);transition:all .2s;}
708
+ .tab.active{color:var(--text);border-color:var(--accent);}
709
+ .tab-content{flex:1;overflow:hidden;display:none;flex-direction:column;}
710
+ .tab-content.active{display:flex;}
711
+
712
+ .stats-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;padding:14px;}
713
+ .stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;}
714
+ .stat-label{font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
715
+ .stat-value{font-size:26px;font-weight:700;line-height:1.1;margin-top:2px;}
716
+ .stat-value.green{color:var(--green);}
717
+ .stat-value.blue{color:var(--accent);}
718
+ .stat-value.yellow{color:var(--yellow);}
719
+ .stat-value.red{color:var(--red);}
720
+ .stat-value.purple{color:var(--accent2);}
721
+
722
+ .monitor-bar{display:flex;gap:8px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);background:var(--surface);}
723
+ .monitor-status{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted);}
724
+
725
+ .scroll-wrap{flex:1;overflow-y:auto;padding:0 14px 14px;}
726
+ .spaces-header{
727
+ display:grid;grid-template-columns:2.2fr 1fr 130px 90px 70px;gap:8px;
728
+ padding:7px 10px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;
729
+ border-bottom:1px solid var(--border);margin-bottom:3px;font-weight:700;
730
+ }
731
+ .space-row{
732
+ display:grid;grid-template-columns:2.2fr 1fr 130px 90px 70px;gap:8px;
733
+ padding:9px 10px;border-radius:7px;font-size:11px;align-items:center;
734
+ background:var(--card);border:1px solid var(--border);margin-bottom:3px;transition:all .15s;
735
+ }
736
+ .space-row:hover{border-color:var(--accent);background:#131f33;}
737
+ .space-id{font-family:'JetBrains Mono',monospace;font-weight:500;color:var(--accent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
738
+ .space-hw{color:var(--muted);font-size:10px;}
739
+ .status-badge{
740
+ display:inline-flex;align-items:center;gap:4px;
741
+ padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;
742
+ }
743
+ .status-badge.RUNNING{background:rgba(34,197,94,.12);color:var(--green);}
744
+ .status-badge.BUILDING,.status-badge.APP_STARTING{background:rgba(59,130,246,.12);color:var(--accent);}
745
+ .status-badge.RESTARTING{background:rgba(234,179,8,.12);color:var(--yellow);}
746
+ .status-badge.SLEEPING{background:rgba(100,116,139,.12);color:var(--muted);}
747
+ .status-badge.RUNTIME_ERROR,.status-badge.CREATE_FAILED,.status-badge.RESTART_FAILED{background:rgba(239,68,68,.12);color:var(--red);}
748
+ .status-badge.UNKNOWN{background:rgba(100,116,139,.12);color:var(--muted);}
749
+ .restart-btn{
750
+ visibility:hidden;background:rgba(59,130,246,.15);color:var(--accent);border:none;
751
+ border-radius:5px;padding:3px 7px;font-size:10px;cursor:pointer;font-weight:700;
752
+ }
753
+ .space-row:hover .restart-btn{visibility:visible;}
754
+
755
+ .log-box{
756
+ flex:1;background:#060a11;border:1px solid var(--border);border-radius:9px;
757
+ padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px;
758
+ line-height:1.7;overflow-y:auto;
759
+ }
760
+ .log-entry.INFO{color:#94a3b8;}
761
+ .log-entry.WARN{color:var(--yellow);}
762
+ .log-entry.ERROR{color:var(--red);}
763
+ .log-time{color:#3b5068;margin-right:6px;}
764
+
765
+ /* Delete section */
766
+ .del-section{background:rgba(239,68,68,.05);border:1px solid rgba(239,68,68,.2);border-radius:10px;padding:12px;}
767
+
768
+ #toast{
769
+ position:fixed;bottom:20px;right:20px;background:#111927;border:1px solid var(--border);
770
+ border-radius:9px;padding:10px 16px;font-size:12px;z-index:9999;
771
+ opacity:0;transform:translateY(10px);transition:all .3s;pointer-events:none;
772
+ }
773
+ #toast.show{opacity:1;transform:translateY(0);}
774
+ ::-webkit-scrollbar{width:5px;}
775
+ ::-webkit-scrollbar-track{background:transparent;}
776
+ ::-webkit-scrollbar-thumb{background:#1e2d42;border-radius:3px;}
777
+ </style>
778
+ </head>
779
+ <body>
780
+ <div class="app">
781
+
782
+ <!-- Header -->
783
+ <header>
784
+ <span class="logo">⚑ HF Spaces Manager</span>
785
+ <span class="tagline">Deploy Β· Monitor Β· Auto-Restart</span>
786
+ <div class="header-right">
787
+ <span class="pill blue"><span class="dot"></span><span id="h-accounts">0</span> Accounts</span>
788
+ <span class="pill green"><span class="dot pulse"></span><span id="h-running">0</span> Running</span>
789
+ <span class="pill purple"><span class="dot"></span><span id="h-total">0</span> Spaces</span>
790
+ <span class="pill yellow" id="h-monitor-pill"><span class="dot pulse"></span>Monitor Active</span>
791
+ </div>
792
+ </header>
793
+
794
+ <!-- Sidebar -->
795
+ <aside>
796
+
797
+ <!-- Create -->
798
+ <div>
799
+ <div class="section-title">πŸš€ Create Spaces</div>
800
+ <div style="display:flex;flex-direction:column;gap:9px;">
801
+ <div class="field"><label>HuggingFace Username</label><input id="f-username" placeholder="e.g. Eliasishere"/></div>
802
+ <div class="field"><label>HF Token</label><input id="f-token" type="password" placeholder="hf_..."/></div>
803
+ <div class="field"><label>Space Prefix (prefix + number)</label><input id="f-prefix" placeholder="e.g. ike β†’ ike1, ike2..."/></div>
804
+ <div class="field">
805
+ <label>Number of Spaces</label>
806
+ <div class="row">
807
+ <input id="f-count" type="number" min="1" max="50" value="20"/>
808
+ <button class="btn btn-primary" id="create-btn" onclick="createSpaces()">Create</button>
809
+ </div>
810
+ </div>
811
+ <div class="prog-wrap" id="prog-wrap"><div class="prog-bar" id="prog-bar" style="width:0%"></div></div>
812
+ </div>
813
+ </div>
814
+
815
+ <!-- Add Account -->
816
+ <div>
817
+ <div class="section-title">πŸ‘€ Add / Update Account</div>
818
+ <div style="display:flex;flex-direction:column;gap:9px;">
819
+ <div class="field"><label>Username</label><input id="a-username" placeholder="Username"/></div>
820
+ <div class="field"><label>HF Token</label><input id="a-token" type="password" placeholder="hf_..."/></div>
821
+ <button class="btn btn-secondary" style="width:100%" onclick="addAccount()">Save Account</button>
822
+ </div>
823
+ </div>
824
+
825
+ <!-- Delete All Spaces -->
826
+ <div class="del-section">
827
+ <div class="section-title">πŸ—‘οΈ Delete All Spaces</div>
828
+ <div style="display:flex;flex-direction:column;gap:9px;">
829
+ <div class="field">
830
+ <label>Select Account</label>
831
+ <select id="d-account"><option value="">β€” choose account β€”</option></select>
832
+ </div>
833
+ <div class="field"><label>Manual Token Override (optional)</label><input id="d-token" type="password" placeholder="leave empty to use saved"/></div>
834
+ <button class="btn btn-danger" style="width:100%" onclick="deleteSpaces()">πŸ—‘οΈ Delete ALL Spaces</button>
835
+ </div>
836
+ </div>
837
+
838
+ <!-- Accounts -->
839
+ <div>
840
+ <div class="section-title">πŸ“‹ Connected Accounts</div>
841
+ <div class="accounts-list" id="accounts-list">
842
+ <div style="color:var(--muted);font-size:11px;">None added yet.</div>
843
+ </div>
844
+ </div>
845
+
846
+ </aside>
847
+
848
+ <!-- Main -->
849
+ <main>
850
+ <div class="tabs">
851
+ <div class="tab active" onclick="switchTab('dashboard')">πŸ“Š Dashboard</div>
852
+ <div class="tab" onclick="switchTab('spaces')">πŸ–₯️ Spaces</div>
853
+ <div class="tab" onclick="switchTab('payload')">πŸ“ Payload</div>
854
+ <div class="tab" onclick="switchTab('logs')">πŸ“„ Logs</div>
855
+ </div>
856
+
857
+ <!-- Dashboard -->
858
+ <div class="tab-content active" id="tab-dashboard">
859
+ <div class="stats-grid">
860
+ <div class="stat-card"><div class="stat-label">Accounts</div><div class="stat-value blue" id="s-accounts">0</div></div>
861
+ <div class="stat-card"><div class="stat-label">Total Spaces</div><div class="stat-value purple" id="s-total">0</div></div>
862
+ <div class="stat-card"><div class="stat-label">Running</div><div class="stat-value green" id="s-running">0</div></div>
863
+ <div class="stat-card"><div class="stat-label">Building</div><div class="stat-value blue" id="s-building">0</div></div>
864
+ <div class="stat-card"><div class="stat-label">Errors</div><div class="stat-value red" id="s-error">0</div></div>
865
+ </div>
866
+ <div class="monitor-bar">
867
+ <button class="btn btn-secondary btn-sm" onclick="startMonitor()">β–Ά Start</button>
868
+ <button class="btn btn-danger btn-sm" onclick="stopMonitor()">β–  Stop</button>
869
+ <button class="btn btn-warn btn-sm" onclick="scanNow()">⚑ Scan Now</button>
870
+ <div class="monitor-status">
871
+ <span class="dot pulse" id="monitor-dot" style="background:var(--green)"></span>
872
+ <span id="monitor-label">Monitor Active β€” every 5 min</span>
873
+ </div>
874
+ </div>
875
+ <div class="scroll-wrap" style="padding-top:12px;">
876
+ <div class="section-title" style="padding-bottom:8px;">Recent Activity</div>
877
+ <div class="log-box" id="dash-log" style="height:calc(100vh - 370px)"></div>
878
+ </div>
879
+ </div>
880
+
881
+ <!-- Spaces -->
882
+ <div class="tab-content" id="tab-spaces">
883
+ <div class="spaces-header">
884
+ <div>Space ID</div><div>Prefix / Account</div><div>Status</div><div>Hardware</div><div>Action</div>
885
+ </div>
886
+ <div class="scroll-wrap" id="spaces-list" style="padding-top:4px;">
887
+ <div style="color:var(--muted);font-size:12px;padding:20px;text-align:center;">No spaces yet.</div>
888
+ </div>
889
+ </div>
890
+
891
+ <!-- Payload Editor -->
892
+ <div class="tab-content" id="tab-payload" style="flex:1;flex-direction:row;overflow:hidden;">
893
+ <div style="width:250px;border-right:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;">
894
+ <div style="padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
895
+ <div class="section-title" style="margin:0">Files (<span id="pl-count">0</span>)</div>
896
+ <button class="btn btn-secondary btn-sm" onclick="newFile()">βž• New</button>
897
+ </div>
898
+ <div id="pl-list" style="flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:4px;">
899
+ <!-- File list renders here -->
900
+ </div>
901
+ </div>
902
+ <div style="flex:1;display:flex;flex-direction:column;background:var(--bg);">
903
+ <div style="padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;background:var(--surface);">
904
+ <input id="pl-filename" style="flex:1;font-family:monospace;font-size:13px;" placeholder="Filename (e.g. script.py)">
905
+ <button class="btn btn-primary btn-sm" onclick="saveFile()">πŸ’Ύ Save</button>
906
+ <button class="btn btn-danger btn-sm" onclick="deleteFile()">πŸ—‘οΈ Delete</button>
907
+ </div>
908
+ <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>
909
+ </div>
910
+ </div>
911
+
912
+ <!-- Logs -->
913
+ <div class="tab-content" id="tab-logs">
914
+ <div style="padding:14px;flex:1;display:flex;flex-direction:column;">
915
+ <div class="log-box" id="full-log" style="height:100%"></div>
916
+ </div>
917
+ </div>
918
+ </main>
919
+ </div>
920
+
921
+ <div id="toast"></div>
922
+
923
+ <script>
924
+ const STATUS_ICONS={RUNNING:'🟒',SLEEPING:'πŸ’€',BUILDING:'πŸ”¨',APP_STARTING:'⏳',
925
+ RUNTIME_ERROR:'❌',CREATE_FAILED:'❌',RESTART_FAILED:'❌',RESTARTING:'πŸ”„',PAUSED:'⏸',UNKNOWN:'❓'};
926
+
927
+ function switchTab(name){
928
+ const names=['dashboard','spaces','payload','logs'];
929
+ document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name));
930
+ document.querySelectorAll('.tab-content').forEach(tc=>tc.classList.remove('active'));
931
+ document.getElementById('tab-'+name).classList.add('active');
932
+ if(name==='payload') pollFiles();
933
+ }
934
+
935
+ function toast(msg,col=''){
936
+ const t=document.getElementById('toast');
937
+ t.textContent=msg; t.style.borderColor=col||'var(--border)';
938
+ t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),3500);
939
+ }
940
+
941
+ async function json_post(url,body={}){
942
+ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
943
+ return r.json();
944
+ }
945
+
946
+ async function createSpaces(){
947
+ const username=document.getElementById('f-username').value.trim();
948
+ const token=document.getElementById('f-token').value.trim();
949
+ const prefix=document.getElementById('f-prefix').value.trim();
950
+ const count=parseInt(document.getElementById('f-count').value)||20;
951
+ if(!username||!token||!prefix){toast('Fill all fields','var(--red)');return;}
952
+ document.getElementById('create-btn').disabled=true;
953
+ document.getElementById('prog-wrap').style.display='block';
954
+ document.getElementById('prog-bar').style.width='8%';
955
+ const d=await json_post('/api/create',{username,token,prefix,count});
956
+ if(d.ok){toast('πŸš€ '+d.msg,'var(--accent)');}
957
+ else{toast('❌ '+d.error,'var(--red)');document.getElementById('create-btn').disabled=false;document.getElementById('prog-wrap').style.display='none';}
958
+ }
959
+
960
+ async function addAccount(){
961
+ const username=document.getElementById('a-username').value.trim();
962
+ const token=document.getElementById('a-token').value.trim();
963
+ if(!username||!token){toast('Username and token required','var(--red)');return;}
964
+ const d=await json_post('/api/add-account',{username,token});
965
+ toast(d.ok?(d.added?'βœ… Account added':'πŸ”„ Token updated'):'❌ '+d.error, d.ok?'':'var(--red)');
966
+ }
967
+
968
+ async function deleteSpaces(){
969
+ const username=document.getElementById('d-account').value.trim();
970
+ const token=document.getElementById('d-token').value.trim();
971
+ if(!username){toast('Select an account','var(--red)');return;}
972
+ if(!confirm(`Delete ALL spaces for @${username}? This is irreversible!`))return;
973
+ const d=await json_post('/api/delete-spaces',{username,token});
974
+ toast(d.ok?'πŸ—‘οΈ '+d.msg:'❌ '+d.error, d.ok?'var(--red)':'var(--red)');
975
+ }
976
+
977
+ async function restartSpace(id){
978
+ toast('πŸ”„ Restarting '+id);
979
+ const d=await json_post('/api/restart/'+id);
980
+ toast(d.ok?'βœ… Restart triggered for '+id:'❌ '+d.error);
981
+ }
982
+
983
+ // ── File Manager ───────────────────────────────────────────────────────────
984
+ let currentFile = '';
985
+
986
+ async function pollFiles(){
987
+ try{
988
+ const r=await fetch('/api/files');
989
+ const d=await r.json();
990
+ if(d.ok){
991
+ document.getElementById('pl-count').textContent=d.files.length;
992
+ document.getElementById('pl-list').innerHTML=d.files.map(f=>
993
+ `<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>`
994
+ ).join('');
995
+ highlightCurrentFile();
996
+ }
997
+ }catch(e){console.error('File poll error:',e);}
998
+ }
999
+
1000
+ function highlightCurrentFile(){
1001
+ document.querySelectorAll('.pl-item').forEach(el=>{
1002
+ if(el.dataset.id===currentFile){
1003
+ el.style.background='var(--surface)';
1004
+ el.style.borderColor='var(--accent)';
1005
+ el.style.color='var(--accent)';
1006
+ }else{
1007
+ el.style.background='transparent';
1008
+ el.style.borderColor='transparent';
1009
+ el.style.color='var(--text)';
1010
+ }
1011
+ });
1012
+ }
1013
+
1014
+ async function loadFile(name){
1015
+ document.getElementById('pl-filename').value=name;
1016
+ document.getElementById('pl-editor').value='Loading...';
1017
+ currentFile=name;
1018
+ highlightCurrentFile();
1019
+ try{
1020
+ const r=await fetch('/api/files/'+encodeURIComponent(name));
1021
+ const d=await r.json();
1022
+ if(d.ok) document.getElementById('pl-editor').value=d.content;
1023
+ else toast('❌ '+d.error,'var(--red)');
1024
+ }catch(e){toast('❌ Error loading file','var(--red)');}
1025
+ }
1026
+
1027
+ function newFile(){
1028
+ currentFile='';
1029
+ highlightCurrentFile();
1030
+ document.getElementById('pl-filename').value='new_script.py';
1031
+ document.getElementById('pl-editor').value='';
1032
+ document.getElementById('pl-filename').focus();
1033
+ document.getElementById('pl-filename').select();
1034
+ }
1035
+
1036
+ async function saveFile(){
1037
+ const name=document.getElementById('pl-filename').value.trim();
1038
+ const content=document.getElementById('pl-editor').value;
1039
+ if(!name){toast('Filename required','var(--red)');return;}
1040
+ try{
1041
+ const r=await fetch('/api/files/'+encodeURIComponent(name), {
1042
+ method:'POST',
1043
+ headers:{'Content-Type':'application/json'},
1044
+ body:JSON.stringify({content})
1045
+ });
1046
+ const d=await r.json();
1047
+ if(d.ok){
1048
+ toast('πŸ’Ύ '+d.msg,'var(--green)');
1049
+ currentFile=name;
1050
+ pollFiles();
1051
+ }else toast('❌ '+d.error,'var(--red)');
1052
+ }catch(e){toast('❌ Error saving file','var(--red)');}
1053
+ }
1054
+
1055
+ async function deleteFile(){
1056
+ const name=document.getElementById('pl-filename').value.trim();
1057
+ if(!name){toast('Nothing to delete','var(--red)');return;}
1058
+ if(!confirm(`Delete ${name} permanently?`))return;
1059
+ try{
1060
+ const r=await fetch('/api/files/'+encodeURIComponent(name),{method:'DELETE'});
1061
+ const d=await r.json();
1062
+ if(d.ok){
1063
+ toast('πŸ—‘οΈ '+d.msg,'var(--red)');
1064
+ if(currentFile===name) newFile();
1065
+ pollFiles();
1066
+ }else toast('❌ '+d.error,'var(--red)');
1067
+ }catch(e){toast('❌ Error deleting file','var(--red)');}
1068
+ }
1069
+
1070
+
1071
+ async function startMonitor(){const d=await json_post('/api/monitor/start');toast(d.ok?'β–Ά Monitor started':'⚠️ '+d.error);}
1072
+ async function stopMonitor(){await json_post('/api/monitor/stop');toast('β–  Monitor stop requested');}
1073
+ async function scanNow(){await json_post('/api/monitor/scan-now');toast('⚑ Instant scan triggered');}
1074
+
1075
+ function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
1076
+
1077
+ function renderSpaces(spaces){
1078
+ const el=document.getElementById('spaces-list');
1079
+ if(!spaces.length){el.innerHTML='<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center;">No spaces yet.</div>';return;}
1080
+ el.innerHTML=spaces.map(s=>{
1081
+ const st=s.status||'UNKNOWN';
1082
+ const ic=STATUS_ICONS[st]||'❓';
1083
+ const sub=s.prefix?`prefix: ${s.prefix}`:`@${s.username}`;
1084
+ return `<div class="space-row">
1085
+ <div class="space-id" title="${s.id}">${ic} ${s.id}</div>
1086
+ <div style="color:var(--muted);font-size:10px;">${escHtml(sub)}</div>
1087
+ <div><span class="status-badge ${st}">${st.replace('_',' ')}</span></div>
1088
+ <div class="space-hw">${s.hardware||'β€”'}</div>
1089
+ <div><button class="btn restart-btn" onclick="restartSpace('${s.id}')">β†Ί Restart</button></div>
1090
+ </div>`;
1091
+ }).join('');
1092
+ }
1093
+
1094
+ function renderAccounts(names){
1095
+ const el=document.getElementById('accounts-list');
1096
+ // Also update dropdown
1097
+ const dd=document.getElementById('d-account');
1098
+ const prev=dd.value;
1099
+ dd.innerHTML='<option value="">β€” choose account β€”</option>';
1100
+ if(!names||!names.length){
1101
+ el.innerHTML='<div style="color:var(--muted);font-size:11px;">None added yet.</div>';
1102
+ return;
1103
+ }
1104
+ names.forEach((u,i)=>{
1105
+ const opt=document.createElement('option');
1106
+ opt.value=u; opt.textContent='@'+u;
1107
+ dd.appendChild(opt);
1108
+ });
1109
+ if(prev) dd.value=prev;
1110
+ el.innerHTML=names.map((u,i)=>`<div class="account-item">
1111
+ <span class="u">@${escHtml(u)}</span>
1112
+ <span class="badge">Acct ${i+1}</span>
1113
+ </div>`).join('');
1114
+ }
1115
+
1116
+ function renderLogs(logs,boxId){
1117
+ const box=document.getElementById(boxId);
1118
+ if(!box)return;
1119
+ const atBottom=box.scrollHeight-box.scrollTop-box.clientHeight<80;
1120
+ box.innerHTML=logs.map(e=>
1121
+ `<div class="log-entry ${e.level}"><span class="log-time">${e.time.substring(11,19)}</span>${escHtml(e.msg)}</div>`
1122
+ ).join('');
1123
+ if(atBottom) box.scrollTop=box.scrollHeight;
1124
+ }
1125
+
1126
+ async function pollStats(){
1127
+ try{
1128
+ const r=await fetch('/api/stats');
1129
+ const d=await r.json();
1130
+
1131
+ document.getElementById('h-accounts').textContent=d.accounts;
1132
+ document.getElementById('h-running').textContent=d.running;
1133
+ document.getElementById('h-total').textContent=d.total_spaces;
1134
+ document.getElementById('s-accounts').textContent=d.accounts;
1135
+ document.getElementById('s-total').textContent=d.total_spaces;
1136
+ document.getElementById('s-running').textContent=d.running;
1137
+ document.getElementById('s-building').textContent=d.building;
1138
+ document.getElementById('s-error').textContent=d.error;
1139
+
1140
+ const ml=document.getElementById('monitor-label');
1141
+ const md=document.getElementById('monitor-dot');
1142
+ const hp=document.getElementById('h-monitor-pill');
1143
+ if(d.monitor_running){
1144
+ ml.textContent='Monitor Active β€” every 5 min';
1145
+ md.style.background='var(--green)';
1146
+ hp.style.display='';
1147
+ } else {
1148
+ ml.textContent='Monitor Stopped';
1149
+ md.style.background='var(--muted)';
1150
+ hp.style.display='none';
1151
+ }
1152
+
1153
+ if(!d.creation_in_progress){
1154
+ document.getElementById('create-btn').disabled=false;
1155
+ const pw=document.getElementById('prog-wrap');
1156
+ if(pw.style.display!=='none'){
1157
+ document.getElementById('prog-bar').style.width='100%';
1158
+ setTimeout(()=>{pw.style.display='none';document.getElementById('prog-bar').style.width='0%';},700);
1159
+ }
1160
+ } else {
1161
+ const pct=Math.min(92, 8+(d.total_spaces*4));
1162
+ document.getElementById('prog-bar').style.width=pct+'%';
1163
+ }
1164
+
1165
+ renderSpaces(d.spaces||[]);
1166
+ renderAccounts(d.account_names||[]);
1167
+ renderLogs(d.log||[],'dash-log');
1168
+ renderLogs(d.log||[],'full-log');
1169
+ } catch(e){console.error('Poll error:',e);}
1170
+ }
1171
+
1172
+ setInterval(pollStats,4000);
1173
+ pollStats();
1174
+ </script>
1175
+ </body>
1176
+ </html>"""
1177
+
1178
+
1179
+ # ── Entry Point ───────────────────────────────────────────────────────────────
1180
+
1181
+ if __name__ == "__main__":
1182
+ port = int(os.getenv("PORT", 7860))
1183
+ print(f"Starting HF Spaces Manager on http://0.0.0.0:{port}")
1184
+ uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)