mrfakename commited on
Commit
b028028
·
1 Parent(s): 1ab323c
Files changed (6) hide show
  1. app.py +102 -136
  2. hf_api.py +122 -0
  3. jobs.py +168 -0
  4. requirements.txt +2 -1
  5. templates/dashboard.html +53 -16
  6. templates/loading.html +147 -0
app.py CHANGED
@@ -5,8 +5,7 @@ import sqlite3
5
  import json
6
  import time
7
  from datetime import datetime, timezone
8
- from concurrent.futures import ThreadPoolExecutor, as_completed
9
- from flask import Flask, redirect, request, session, render_template, url_for, g
10
  import requests
11
 
12
  app = Flask(__name__)
@@ -83,6 +82,21 @@ def init_db():
83
  expires_at REAL
84
  )
85
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  conn.commit()
87
 
88
 
@@ -112,108 +126,21 @@ def get_base_url():
112
  return f"https://{SPACE_HOST}"
113
 
114
 
115
- def fetch_user_spaces(username, token=None):
116
- cache_key = f"spaces:{username}"
117
- cached = cache_get(cache_key)
118
- if cached is not None:
119
- return cached
120
-
121
- headers = {}
122
- if token:
123
- headers["Authorization"] = f"Bearer {token}"
124
-
125
- spaces = []
126
- url = f"https://huggingface.co/api/spaces?author={username}"
127
-
128
- try:
129
- resp = requests.get(url, headers=headers, timeout=10)
130
- if resp.status_code == 200:
131
- space_list = resp.json()
132
- space_ids = [s.get("id", "") for s in space_list if s.get("id")]
133
-
134
- # Fetch individual space details in parallel
135
- with ThreadPoolExecutor(max_workers=10) as executor:
136
- future_to_id = {
137
- executor.submit(fetch_space_detail, sid, token): sid
138
- for sid in space_ids
139
- }
140
- details_map = {}
141
- for future in as_completed(future_to_id):
142
- sid = future_to_id[future]
143
- try:
144
- details_map[sid] = future.result()
145
- except Exception:
146
- details_map[sid] = None
147
-
148
- # Build list and sort by likes
149
- for space in space_list:
150
- sid = space.get("id", "")
151
- detail = details_map.get(sid)
152
- spaces.append(detail if detail else space)
153
-
154
- spaces.sort(key=lambda x: x.get("likes", 0), reverse=True)
155
- except Exception:
156
- pass
157
-
158
- cache_set(cache_key, spaces)
159
- return spaces
160
-
161
-
162
- def fetch_space_detail(space_id, token=None):
163
- cache_key = f"space_detail:{space_id}"
164
- cached = cache_get(cache_key)
165
- if cached is not None:
166
- return cached
167
-
168
- headers = {}
169
- if token:
170
- headers["Authorization"] = f"Bearer {token}"
171
-
172
- try:
173
- url = f"https://huggingface.co/api/spaces/{space_id}"
174
- resp = requests.get(url, headers=headers, timeout=10)
175
- if resp.status_code == 200:
176
- detail = resp.json()
177
- cache_set(cache_key, detail)
178
- return detail
179
- except Exception:
180
- pass
181
-
182
- return None
183
-
184
-
185
- def fetch_space_discussions(space_id, token=None):
186
- cache_key = f"discussions:{space_id}"
187
- cached = cache_get(cache_key)
188
- if cached is not None:
189
- return cached
190
-
191
- headers = {}
192
- if token:
193
- headers["Authorization"] = f"Bearer {token}"
194
-
195
- discussions = []
196
- url = f"https://huggingface.co/api/spaces/{space_id}/discussions"
197
-
198
- try:
199
- resp = requests.get(url, headers=headers, timeout=10)
200
- if resp.status_code == 200:
201
- data = resp.json()
202
- discussions = data.get("discussions", [])
203
- except Exception:
204
- pass
205
-
206
- cache_set(cache_key, discussions)
207
- return discussions
208
 
209
 
210
- def get_discussions_feed(username, token=None, logged_in_user=None):
211
- spaces = fetch_user_spaces(username, token)
212
-
213
  all_discussions = []
 
214
  for space in spaces:
215
  space_id = space.get("id", "")
216
- discussions = fetch_space_discussions(space_id, token)
217
 
218
  for d in discussions:
219
  owner_responded = d.get("repoOwner", {}).get("isParticipating", False)
@@ -222,12 +149,11 @@ def get_discussions_feed(username, token=None, logged_in_user=None):
222
 
223
  base_score = d.get("numComments", 0) + d.get("numReactionUsers", 0) * 2
224
  score = base_score
225
- # Demote if owner has responded
226
  if owner_responded:
227
  score -= 100
228
- # Heavily demote if user started it themselves
229
  if is_own_discussion:
230
  score -= 1000
 
231
  all_discussions.append({
232
  "space_id": space_id,
233
  "space_name": space_id.split("/")[-1] if "/" in space_id else space_id,
@@ -333,10 +259,41 @@ def callback():
333
  "avatar_url": user_info.get("picture"),
334
  }
335
  session["access_token"] = access_token
336
-
337
  session.pop("oauth_state", None)
338
 
339
- return redirect(url_for("dashboard"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
 
342
  @app.route("/dashboard")
@@ -344,14 +301,39 @@ def dashboard():
344
  if "user" not in session:
345
  return redirect(url_for("index"))
346
 
 
 
 
 
 
 
 
 
347
  username = session["user"]["username"]
348
  token = session.get("access_token")
349
 
350
  sort_by = request.args.get("sort", "score")
351
  filter_status = request.args.get("status", "open")
352
 
353
- spaces = fetch_user_spaces(username, token)
354
- discussions = get_discussions_feed(username, token, logged_in_user=username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
  # Filter by status
357
  if filter_status == "open":
@@ -386,48 +368,32 @@ def logout():
386
  @app.route("/api/wake-all", methods=["POST"])
387
  def wake_all_spaces():
388
  if "user" not in session:
389
- return {"error": "Not authenticated"}, 401
390
 
391
  username = session["user"]["username"]
392
  token = session.get("access_token")
393
 
394
  if not token:
395
- return {"error": "No access token"}, 401
396
 
397
- spaces = fetch_user_spaces(username, token)
398
- sleeping_spaces = [
399
- s for s in spaces
 
 
 
 
400
  if s.get("runtime", {}).get("stage", "").upper() == "SLEEPING"
401
  ]
402
 
403
- results = []
404
- for space in sleeping_spaces:
405
- space_id = space.get("id", "")
406
- try:
407
- resp = requests.post(
408
- f"https://huggingface.co/api/spaces/{space_id}/restart",
409
- headers={"Authorization": f"Bearer {token}"},
410
- timeout=10,
411
- )
412
- results.append({
413
- "id": space_id,
414
- "success": resp.status_code == 200,
415
- "status": resp.status_code,
416
- })
417
- except Exception as e:
418
- results.append({
419
- "id": space_id,
420
- "success": False,
421
- "error": str(e),
422
- })
423
 
424
- # Clear cache so next load shows updated status
425
- cache_key = f"spaces:{username}"
426
- db = get_db()
427
- db.execute("DELETE FROM cache WHERE key = ?", (cache_key,))
428
- db.commit()
429
 
430
- return {"results": results, "total": len(sleeping_spaces)}
431
 
432
 
433
  # Initialize database on startup
 
5
  import json
6
  import time
7
  from datetime import datetime, timezone
8
+ from flask import Flask, redirect, request, session, render_template, url_for, g, jsonify
 
9
  import requests
10
 
11
  app = Flask(__name__)
 
82
  expires_at REAL
83
  )
84
  """)
85
+ conn.execute("""
86
+ CREATE TABLE IF NOT EXISTS jobs (
87
+ id TEXT PRIMARY KEY,
88
+ type TEXT,
89
+ user_id TEXT,
90
+ status TEXT DEFAULT 'pending',
91
+ progress_current INTEGER DEFAULT 0,
92
+ progress_total INTEGER DEFAULT 0,
93
+ progress_stage TEXT DEFAULT '',
94
+ result TEXT,
95
+ error TEXT,
96
+ created_at REAL,
97
+ updated_at REAL
98
+ )
99
+ """)
100
  conn.commit()
101
 
102
 
 
126
  return f"https://{SPACE_HOST}"
127
 
128
 
129
+ # Import jobs module after app is defined
130
+ from jobs import (
131
+ create_job, get_job, update_job_progress, complete_job,
132
+ start_job_thread, run_initial_load_job, run_wake_job
133
+ )
134
+ from hf_api import HuggingFaceAPI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
 
137
+ def get_discussions_feed(spaces, discussions_map, logged_in_user=None):
138
+ """Build discussions feed from cached data."""
 
139
  all_discussions = []
140
+
141
  for space in spaces:
142
  space_id = space.get("id", "")
143
+ discussions = discussions_map.get(space_id, [])
144
 
145
  for d in discussions:
146
  owner_responded = d.get("repoOwner", {}).get("isParticipating", False)
 
149
 
150
  base_score = d.get("numComments", 0) + d.get("numReactionUsers", 0) * 2
151
  score = base_score
 
152
  if owner_responded:
153
  score -= 100
 
154
  if is_own_discussion:
155
  score -= 1000
156
+
157
  all_discussions.append({
158
  "space_id": space_id,
159
  "space_name": space_id.split("/")[-1] if "/" in space_id else space_id,
 
259
  "avatar_url": user_info.get("picture"),
260
  }
261
  session["access_token"] = access_token
 
262
  session.pop("oauth_state", None)
263
 
264
+ # Start background job to load data
265
+ username = session["user"]["username"]
266
+ job_id = create_job("initial_load", username)
267
+ session["loading_job_id"] = job_id
268
+
269
+ start_job_thread(run_initial_load_job, job_id, username, access_token)
270
+
271
+ return redirect(url_for("loading"))
272
+
273
+
274
+ @app.route("/loading")
275
+ def loading():
276
+ if "user" not in session:
277
+ return redirect(url_for("index"))
278
+
279
+ job_id = session.get("loading_job_id")
280
+ if not job_id:
281
+ return redirect(url_for("dashboard"))
282
+
283
+ job = get_job(job_id)
284
+ if job and job["status"] == "completed":
285
+ session.pop("loading_job_id", None)
286
+ return redirect(url_for("dashboard"))
287
+
288
+ return render_template("loading.html", job_id=job_id)
289
+
290
+
291
+ @app.route("/api/job/<job_id>")
292
+ def get_job_status(job_id):
293
+ job = get_job(job_id)
294
+ if not job:
295
+ return jsonify({"error": "Job not found"}), 404
296
+ return jsonify(job)
297
 
298
 
299
  @app.route("/dashboard")
 
301
  if "user" not in session:
302
  return redirect(url_for("index"))
303
 
304
+ # Check if still loading
305
+ job_id = session.get("loading_job_id")
306
+ if job_id:
307
+ job = get_job(job_id)
308
+ if job and job["status"] not in ("completed", "failed"):
309
+ return redirect(url_for("loading"))
310
+ session.pop("loading_job_id", None)
311
+
312
  username = session["user"]["username"]
313
  token = session.get("access_token")
314
 
315
  sort_by = request.args.get("sort", "score")
316
  filter_status = request.args.get("status", "open")
317
 
318
+ # Try to get from cache first
319
+ spaces = cache_get(f"spaces:{username}")
320
+
321
+ # If no cache, trigger a reload
322
+ if spaces is None:
323
+ job_id = create_job("initial_load", username)
324
+ session["loading_job_id"] = job_id
325
+ start_job_thread(run_initial_load_job, job_id, username, token)
326
+ return redirect(url_for("loading"))
327
+
328
+ # Build discussions from cache
329
+ discussions_map = {}
330
+ for space in spaces:
331
+ space_id = space.get("id", "")
332
+ cached_discussions = cache_get(f"discussions:{space_id}")
333
+ if cached_discussions is not None:
334
+ discussions_map[space_id] = cached_discussions
335
+
336
+ discussions = get_discussions_feed(spaces, discussions_map, logged_in_user=username)
337
 
338
  # Filter by status
339
  if filter_status == "open":
 
368
  @app.route("/api/wake-all", methods=["POST"])
369
  def wake_all_spaces():
370
  if "user" not in session:
371
+ return jsonify({"error": "Not authenticated"}), 401
372
 
373
  username = session["user"]["username"]
374
  token = session.get("access_token")
375
 
376
  if not token:
377
+ return jsonify({"error": "No access token"}), 401
378
 
379
+ # Get sleeping spaces from cache
380
+ spaces = cache_get(f"spaces:{username}")
381
+ if not spaces:
382
+ return jsonify({"error": "No spaces cached, please refresh"}), 400
383
+
384
+ sleeping_space_ids = [
385
+ s.get("id", "") for s in spaces
386
  if s.get("runtime", {}).get("stage", "").upper() == "SLEEPING"
387
  ]
388
 
389
+ if not sleeping_space_ids:
390
+ return jsonify({"job_id": None, "total": 0, "message": "No sleeping spaces"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ # Create background job
393
+ job_id = create_job("wake_spaces", username)
394
+ start_job_thread(run_wake_job, job_id, username, token, sleeping_space_ids)
 
 
395
 
396
+ return jsonify({"job_id": job_id, "total": len(sleeping_space_ids)})
397
 
398
 
399
  # Initialize database on startup
hf_api.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+
5
+
6
+ class HuggingFaceAPI:
7
+ BASE_URL = "https://huggingface.co/api"
8
+
9
+ def __init__(self, token=None):
10
+ self.token = token
11
+ self.session = requests.Session()
12
+ if token:
13
+ self.session.headers["Authorization"] = f"Bearer {token}"
14
+
15
+ @retry(
16
+ stop=stop_after_attempt(3),
17
+ wait=wait_exponential(multiplier=1, min=1, max=10),
18
+ retry=retry_if_exception_type((requests.RequestException, requests.Timeout)),
19
+ )
20
+ def _request(self, method, endpoint, **kwargs):
21
+ kwargs.setdefault("timeout", 15)
22
+ url = f"{self.BASE_URL}/{endpoint}"
23
+ resp = self.session.request(method, url, **kwargs)
24
+ resp.raise_for_status()
25
+ return resp.json()
26
+
27
+ def _get(self, endpoint, **kwargs):
28
+ return self._request("GET", endpoint, **kwargs)
29
+
30
+ def _post(self, endpoint, **kwargs):
31
+ return self._request("POST", endpoint, **kwargs)
32
+
33
+ def list_spaces(self, author):
34
+ return self._get(f"spaces?author={author}")
35
+
36
+ def get_space(self, space_id):
37
+ return self._get(f"spaces/{space_id}")
38
+
39
+ def get_space_discussions(self, space_id):
40
+ data = self._get(f"spaces/{space_id}/discussions")
41
+ return data.get("discussions", [])
42
+
43
+ def restart_space(self, space_id):
44
+ return self._post(f"spaces/{space_id}/restart")
45
+
46
+ def fetch_spaces_with_details(self, author, progress_callback=None):
47
+ """Fetch all spaces for a user with full details (parallel)."""
48
+ space_list = self.list_spaces(author)
49
+ space_ids = [s.get("id", "") for s in space_list if s.get("id")]
50
+ total = len(space_ids)
51
+ spaces = []
52
+ completed = 0
53
+
54
+ with ThreadPoolExecutor(max_workers=10) as executor:
55
+ future_to_id = {
56
+ executor.submit(self._safe_get_space, sid): sid
57
+ for sid in space_ids
58
+ }
59
+ details_map = {}
60
+ for future in as_completed(future_to_id):
61
+ sid = future_to_id[future]
62
+ details_map[sid] = future.result()
63
+ completed += 1
64
+ if progress_callback:
65
+ progress_callback("spaces", completed, total)
66
+
67
+ for space in space_list:
68
+ sid = space.get("id", "")
69
+ detail = details_map.get(sid)
70
+ spaces.append(detail if detail else space)
71
+
72
+ spaces.sort(key=lambda x: x.get("likes", 0), reverse=True)
73
+ return spaces
74
+
75
+ def _safe_get_space(self, space_id):
76
+ try:
77
+ return self.get_space(space_id)
78
+ except Exception:
79
+ return None
80
+
81
+ def fetch_all_discussions(self, spaces, progress_callback=None):
82
+ """Fetch discussions for all spaces (parallel)."""
83
+ total = len(spaces)
84
+ completed = 0
85
+ all_discussions = {}
86
+
87
+ with ThreadPoolExecutor(max_workers=10) as executor:
88
+ future_to_id = {
89
+ executor.submit(self._safe_get_discussions, s.get("id", "")): s.get("id", "")
90
+ for s in spaces
91
+ }
92
+ for future in as_completed(future_to_id):
93
+ sid = future_to_id[future]
94
+ all_discussions[sid] = future.result() or []
95
+ completed += 1
96
+ if progress_callback:
97
+ progress_callback("discussions", completed, total)
98
+
99
+ return all_discussions
100
+
101
+ def _safe_get_discussions(self, space_id):
102
+ try:
103
+ return self.get_space_discussions(space_id)
104
+ except Exception:
105
+ return []
106
+
107
+ def wake_spaces(self, space_ids, progress_callback=None):
108
+ """Wake multiple spaces, returning results."""
109
+ total = len(space_ids)
110
+ results = []
111
+
112
+ for i, space_id in enumerate(space_ids):
113
+ try:
114
+ self.restart_space(space_id)
115
+ results.append({"id": space_id, "success": True})
116
+ except Exception as e:
117
+ results.append({"id": space_id, "success": False, "error": str(e)})
118
+
119
+ if progress_callback:
120
+ progress_callback("wake", i + 1, total)
121
+
122
+ return results
jobs.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ import time
4
+ import threading
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from hf_api import HuggingFaceAPI
8
+
9
+
10
+ DB_PATH = "cache.db"
11
+
12
+
13
+ def get_db_connection():
14
+ conn = sqlite3.connect(DB_PATH)
15
+ conn.row_factory = sqlite3.Row
16
+ return conn
17
+
18
+
19
+ def init_jobs_table():
20
+ with get_db_connection() as conn:
21
+ conn.execute("""
22
+ CREATE TABLE IF NOT EXISTS jobs (
23
+ id TEXT PRIMARY KEY,
24
+ type TEXT,
25
+ user_id TEXT,
26
+ status TEXT DEFAULT 'pending',
27
+ progress_current INTEGER DEFAULT 0,
28
+ progress_total INTEGER DEFAULT 0,
29
+ progress_stage TEXT DEFAULT '',
30
+ result TEXT,
31
+ error TEXT,
32
+ created_at REAL,
33
+ updated_at REAL
34
+ )
35
+ """)
36
+ conn.commit()
37
+
38
+
39
+ def create_job(job_type, user_id):
40
+ job_id = str(uuid.uuid4())
41
+ now = time.time()
42
+ with get_db_connection() as conn:
43
+ conn.execute(
44
+ """INSERT INTO jobs (id, type, user_id, status, created_at, updated_at)
45
+ VALUES (?, ?, ?, 'pending', ?, ?)""",
46
+ (job_id, job_type, user_id, now, now),
47
+ )
48
+ conn.commit()
49
+ return job_id
50
+
51
+
52
+ def update_job_progress(job_id, stage, current, total):
53
+ with get_db_connection() as conn:
54
+ conn.execute(
55
+ """UPDATE jobs SET progress_stage = ?, progress_current = ?,
56
+ progress_total = ?, status = 'running', updated_at = ? WHERE id = ?""",
57
+ (stage, current, total, time.time(), job_id),
58
+ )
59
+ conn.commit()
60
+
61
+
62
+ def complete_job(job_id, result=None, error=None):
63
+ status = "failed" if error else "completed"
64
+ with get_db_connection() as conn:
65
+ conn.execute(
66
+ """UPDATE jobs SET status = ?, result = ?, error = ?, updated_at = ? WHERE id = ?""",
67
+ (status, json.dumps(result) if result else None, error, time.time(), job_id),
68
+ )
69
+ conn.commit()
70
+
71
+
72
+ def get_job(job_id):
73
+ with get_db_connection() as conn:
74
+ row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
75
+ if row:
76
+ return dict(row)
77
+ return None
78
+
79
+
80
+ def get_user_job(user_id, job_type):
81
+ """Get the most recent job of a type for a user."""
82
+ with get_db_connection() as conn:
83
+ row = conn.execute(
84
+ """SELECT * FROM jobs WHERE user_id = ? AND type = ?
85
+ ORDER BY created_at DESC LIMIT 1""",
86
+ (user_id, job_type),
87
+ ).fetchone()
88
+ if row:
89
+ return dict(row)
90
+ return None
91
+
92
+
93
+ def run_initial_load_job(job_id, username, token):
94
+ """Background job to load spaces and discussions."""
95
+ def progress_callback(stage, current, total):
96
+ update_job_progress(job_id, stage, current, total)
97
+
98
+ try:
99
+ api = HuggingFaceAPI(token)
100
+
101
+ # Fetch spaces with details
102
+ update_job_progress(job_id, "spaces", 0, 1)
103
+ spaces = api.fetch_spaces_with_details(username, progress_callback)
104
+
105
+ # Fetch discussions for all spaces
106
+ update_job_progress(job_id, "discussions", 0, len(spaces))
107
+ discussions_map = api.fetch_all_discussions(spaces, progress_callback)
108
+
109
+ # Store in cache
110
+ cache_result(username, spaces, discussions_map)
111
+
112
+ complete_job(job_id, {"spaces_count": len(spaces), "discussions_count": sum(len(d) for d in discussions_map.values())})
113
+ except Exception as e:
114
+ complete_job(job_id, error=str(e))
115
+
116
+
117
+ def run_wake_job(job_id, username, token, space_ids):
118
+ """Background job to wake sleeping spaces."""
119
+ def progress_callback(stage, current, total):
120
+ update_job_progress(job_id, stage, current, total)
121
+
122
+ try:
123
+ api = HuggingFaceAPI(token)
124
+ results = api.wake_spaces(space_ids, progress_callback)
125
+
126
+ # Clear spaces cache
127
+ clear_user_cache(username)
128
+
129
+ succeeded = sum(1 for r in results if r["success"])
130
+ failed = sum(1 for r in results if not r["success"])
131
+ complete_job(job_id, {"results": results, "succeeded": succeeded, "failed": failed})
132
+ except Exception as e:
133
+ complete_job(job_id, error=str(e))
134
+
135
+
136
+ def cache_result(username, spaces, discussions_map):
137
+ """Store fetched data in cache."""
138
+ import time
139
+ expires_at = time.time() + 300
140
+ with get_db_connection() as conn:
141
+ conn.execute(
142
+ "INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
143
+ (f"spaces:{username}", json.dumps(spaces), expires_at),
144
+ )
145
+ for space_id, discussions in discussions_map.items():
146
+ conn.execute(
147
+ "INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
148
+ (f"discussions:{space_id}", json.dumps(discussions), expires_at),
149
+ )
150
+ conn.commit()
151
+
152
+
153
+ def clear_user_cache(username):
154
+ """Clear user's spaces cache."""
155
+ with get_db_connection() as conn:
156
+ conn.execute("DELETE FROM cache WHERE key = ?", (f"spaces:{username}",))
157
+ conn.commit()
158
+
159
+
160
+ def start_job_thread(target, *args):
161
+ """Start a background thread for a job."""
162
+ thread = threading.Thread(target=target, args=args, daemon=True)
163
+ thread.start()
164
+ return thread
165
+
166
+
167
+ # Initialize jobs table
168
+ init_jobs_table()
requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  flask
2
- requests
 
 
1
  flask
2
+ requests
3
+ tenacity
templates/dashboard.html CHANGED
@@ -693,7 +693,8 @@
693
  wakeAllBtn.disabled = true;
694
  wakeProgress.classList.add('active');
695
  wakeProgressFill.style.width = '0%';
696
- wakeProgressText.textContent = 'Waking spaces...';
 
697
  wakeResults.innerHTML = '';
698
 
699
  try {
@@ -704,29 +705,65 @@
704
  wakeProgressText.textContent = 'Error: ' + data.error;
705
  wakeProgressFill.style.background = '#ef4444';
706
  wakeProgressFill.style.width = '100%';
707
- } else if (data.total === 0) {
 
 
 
 
708
  wakeProgressText.textContent = 'No sleeping spaces found';
709
  wakeProgressFill.style.width = '100%';
710
- } else {
711
- const succeeded = data.results.filter(r => r.success).length;
712
- const failed = data.results.filter(r => !r.success).length;
713
- wakeProgressFill.style.width = '100%';
714
- wakeProgressText.textContent = `Done: ${succeeded} succeeded, ${failed} failed`;
715
-
716
- data.results.forEach(r => {
717
- const div = document.createElement('div');
718
- div.className = 'progress-result ' + (r.success ? 'success' : 'error');
719
- div.innerHTML = `<span>${r.id.split('/')[1]}</span><span>${r.success ? 'OK' : 'Failed'}</span>`;
720
- wakeResults.appendChild(div);
721
- });
722
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  } catch (err) {
724
  wakeProgressText.textContent = 'Error: ' + err.message;
725
  wakeProgressFill.style.background = '#ef4444';
726
  wakeProgressFill.style.width = '100%';
 
727
  }
728
-
729
- wakeAllBtn.disabled = false;
730
  });
731
  </script>
732
  </body>
 
693
  wakeAllBtn.disabled = true;
694
  wakeProgress.classList.add('active');
695
  wakeProgressFill.style.width = '0%';
696
+ wakeProgressFill.style.background = '#22c55e';
697
+ wakeProgressText.textContent = 'Starting...';
698
  wakeResults.innerHTML = '';
699
 
700
  try {
 
705
  wakeProgressText.textContent = 'Error: ' + data.error;
706
  wakeProgressFill.style.background = '#ef4444';
707
  wakeProgressFill.style.width = '100%';
708
+ wakeAllBtn.disabled = false;
709
+ return;
710
+ }
711
+
712
+ if (data.total === 0 || !data.job_id) {
713
  wakeProgressText.textContent = 'No sleeping spaces found';
714
  wakeProgressFill.style.width = '100%';
715
+ wakeAllBtn.disabled = false;
716
+ return;
 
 
 
 
 
 
 
 
 
 
717
  }
718
+
719
+ // Poll for job progress
720
+ const jobId = data.job_id;
721
+ const pollJob = async () => {
722
+ const jobResp = await fetch('/api/job/' + jobId);
723
+ const job = await jobResp.json();
724
+
725
+ if (job.status === 'completed') {
726
+ wakeProgressFill.style.width = '100%';
727
+ const result = JSON.parse(job.result || '{}');
728
+ wakeProgressText.textContent = `Done: ${result.succeeded || 0} succeeded, ${result.failed || 0} failed`;
729
+
730
+ if (result.results) {
731
+ result.results.forEach(r => {
732
+ const div = document.createElement('div');
733
+ div.className = 'progress-result ' + (r.success ? 'success' : 'error');
734
+ div.innerHTML = `<span>${r.id.split('/')[1]}</span><span>${r.success ? 'OK' : 'Failed'}</span>`;
735
+ wakeResults.appendChild(div);
736
+ });
737
+ }
738
+ wakeAllBtn.disabled = false;
739
+ return;
740
+ }
741
+
742
+ if (job.status === 'failed') {
743
+ wakeProgressText.textContent = 'Error: ' + (job.error || 'Unknown error');
744
+ wakeProgressFill.style.background = '#ef4444';
745
+ wakeProgressFill.style.width = '100%';
746
+ wakeAllBtn.disabled = false;
747
+ return;
748
+ }
749
+
750
+ // Update progress
751
+ if (job.progress_total > 0) {
752
+ const pct = Math.round((job.progress_current / job.progress_total) * 100);
753
+ wakeProgressFill.style.width = pct + '%';
754
+ wakeProgressText.textContent = `Waking ${job.progress_current} / ${job.progress_total}`;
755
+ }
756
+
757
+ setTimeout(pollJob, 500);
758
+ };
759
+
760
+ pollJob();
761
  } catch (err) {
762
  wakeProgressText.textContent = 'Error: ' + err.message;
763
  wakeProgressFill.style.background = '#ef4444';
764
  wakeProgressFill.style.width = '100%';
765
+ wakeAllBtn.disabled = false;
766
  }
 
 
767
  });
768
  </script>
769
  </body>
templates/loading.html ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Loading - Spaces Dashboard</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
15
+ background: #0a0a0a;
16
+ color: #e5e5e5;
17
+ min-height: 100vh;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ }
22
+ .container {
23
+ text-align: center;
24
+ max-width: 400px;
25
+ width: 90%;
26
+ }
27
+ h1 {
28
+ font-size: 1.25rem;
29
+ font-weight: 500;
30
+ margin-bottom: 0.5rem;
31
+ }
32
+ .subtitle {
33
+ color: #666;
34
+ font-size: 0.875rem;
35
+ margin-bottom: 2rem;
36
+ }
37
+ .progress-container {
38
+ margin-bottom: 1.5rem;
39
+ }
40
+ .progress-bar {
41
+ height: 4px;
42
+ background: #222;
43
+ border-radius: 2px;
44
+ overflow: hidden;
45
+ margin-bottom: 0.75rem;
46
+ }
47
+ .progress-fill {
48
+ height: 100%;
49
+ background: #22c55e;
50
+ width: 0%;
51
+ transition: width 0.3s;
52
+ }
53
+ .progress-text {
54
+ font-size: 0.875rem;
55
+ color: #888;
56
+ }
57
+ .stage {
58
+ font-size: 0.75rem;
59
+ color: #555;
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.05em;
62
+ margin-bottom: 0.5rem;
63
+ }
64
+ .error {
65
+ color: #ef4444;
66
+ margin-top: 1rem;
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="container">
72
+ <h1>Loading your spaces</h1>
73
+ <p class="subtitle">Fetching data from Hugging Face...</p>
74
+ <div class="progress-container">
75
+ <div class="stage" id="stage">Initializing</div>
76
+ <div class="progress-bar">
77
+ <div class="progress-fill" id="progress-fill"></div>
78
+ </div>
79
+ <div class="progress-text" id="progress-text">0%</div>
80
+ </div>
81
+ <div class="error" id="error" style="display: none;"></div>
82
+ </div>
83
+
84
+ <script>
85
+ const jobId = "{{ job_id }}";
86
+ const progressFill = document.getElementById('progress-fill');
87
+ const progressText = document.getElementById('progress-text');
88
+ const stageEl = document.getElementById('stage');
89
+ const errorEl = document.getElementById('error');
90
+
91
+ const stageNames = {
92
+ 'spaces': 'Fetching spaces',
93
+ 'discussions': 'Loading discussions',
94
+ 'pending': 'Initializing',
95
+ '': 'Initializing'
96
+ };
97
+
98
+ async function poll() {
99
+ try {
100
+ const resp = await fetch('/api/job/' + jobId);
101
+ const job = await resp.json();
102
+
103
+ if (job.error) {
104
+ errorEl.textContent = 'Error: ' + job.error;
105
+ errorEl.style.display = 'block';
106
+ return;
107
+ }
108
+
109
+ if (job.status === 'completed') {
110
+ progressFill.style.width = '100%';
111
+ progressText.textContent = 'Done!';
112
+ stageEl.textContent = 'Complete';
113
+ setTimeout(() => {
114
+ window.location.href = '/dashboard';
115
+ }, 500);
116
+ return;
117
+ }
118
+
119
+ if (job.status === 'failed') {
120
+ errorEl.textContent = 'Error: ' + (job.error || 'Unknown error');
121
+ errorEl.style.display = 'block';
122
+ progressFill.style.background = '#ef4444';
123
+ return;
124
+ }
125
+
126
+ // Update progress
127
+ const stage = job.progress_stage || '';
128
+ stageEl.textContent = stageNames[stage] || stage;
129
+
130
+ if (job.progress_total > 0) {
131
+ const pct = Math.round((job.progress_current / job.progress_total) * 100);
132
+ progressFill.style.width = pct + '%';
133
+ progressText.textContent = `${job.progress_current} / ${job.progress_total}`;
134
+ }
135
+
136
+ setTimeout(poll, 500);
137
+ } catch (err) {
138
+ errorEl.textContent = 'Connection error: ' + err.message;
139
+ errorEl.style.display = 'block';
140
+ setTimeout(poll, 2000);
141
+ }
142
+ }
143
+
144
+ poll();
145
+ </script>
146
+ </body>
147
+ </html>