ysharma HF Staff commited on
Commit
78efe23
Β·
verified Β·
1 Parent(s): 35251be

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +299 -114
app.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Space Keeper - Keeps HuggingFace Spaces alive during hackathon evaluation
3
  Pings all Spaces in an organization on a schedule to prevent them from sleeping.
4
  """
5
 
@@ -8,6 +8,7 @@ import json
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from datetime import datetime, timezone
10
  from pathlib import Path
 
11
 
12
  import gradio as gr
13
  import requests
@@ -15,47 +16,61 @@ from huggingface_hub import HfApi
15
  from apscheduler.schedulers.background import BackgroundScheduler
16
  from apscheduler.triggers.interval import IntervalTrigger
17
 
18
- # Configuration
 
 
19
  ORG_NAME = os.environ.get("ORG_NAME", "MCP-1st-Birthday")
20
- PING_INTERVAL_HOURS = int(os.environ.get("PING_INTERVAL_HOURS", "6"))
21
  REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "30"))
22
- PARALLEL_REQUESTS = int(os.environ.get("PARALLEL_REQUESTS", "10")) # Ping 10 Spaces at once
23
- HF_TOKEN = os.environ.get("HF_TOKEN", None) # Optional: needed for private Spaces
 
24
  LOG_FILE = Path("run_logs.json")
25
- MAX_LOG_ENTRIES = 100 # Keep last N runs
26
 
27
- # Global state
 
 
28
  scheduler = BackgroundScheduler()
29
  api = HfApi(token=HF_TOKEN) if HF_TOKEN else HfApi()
 
 
 
 
30
 
31
 
 
 
 
32
  def load_logs() -> list:
33
  """Load logs from file."""
34
- if LOG_FILE.exists():
35
- try:
36
- with open(LOG_FILE, "r") as f:
37
- return json.load(f)
38
- except Exception:
39
- return []
40
- return []
 
41
 
42
 
43
  def save_logs(logs: list):
44
  """Save logs to file, keeping only the most recent entries."""
45
- logs = logs[-MAX_LOG_ENTRIES:]
46
- with open(LOG_FILE, "w") as f:
47
- json.dump(logs, f, indent=2)
 
48
 
49
 
 
 
 
50
  def ping_space(space_id: str) -> dict:
51
  """Ping a single Space and return the result."""
52
- # Convert space_id (org/name) to the actual app URL
53
- # e.g., "MCP-1st-Birthday/my-app" -> "mcp-1st-birthday-my-app.hf.space"
54
  org, name = space_id.split("/")
55
  app_url = f"https://{org.lower()}-{name.lower()}.hf.space"
56
  hf_page_url = f"https://huggingface.co/spaces/{space_id}"
57
 
58
- # Try the app URL first (this actually wakes up the Space)
59
  try:
60
  response = requests.get(app_url, timeout=REQUEST_TIMEOUT)
61
  return {
@@ -71,10 +86,9 @@ def ping_space(space_id: str) -> dict:
71
  "status": "timeout",
72
  "status_code": None,
73
  "url_pinged": app_url,
74
- "error": f"Request timed out after {REQUEST_TIMEOUT}s"
75
  }
76
  except Exception as e:
77
- # Fallback: try the HF page URL
78
  try:
79
  response = requests.get(hf_page_url, timeout=REQUEST_TIMEOUT)
80
  return {
@@ -84,7 +98,7 @@ def ping_space(space_id: str) -> dict:
84
  "url_pinged": hf_page_url,
85
  "error": None
86
  }
87
- except Exception as e2:
88
  return {
89
  "space_id": space_id,
90
  "status": "error",
@@ -94,8 +108,8 @@ def ping_space(space_id: str) -> dict:
94
  }
95
 
96
 
97
- def run_ping_job(triggered_by: str = "scheduler") -> dict:
98
- """Run the ping job for all Spaces in the org using parallel requests."""
99
  start_time = datetime.now(timezone.utc)
100
 
101
  # Get all Spaces in the org
@@ -118,18 +132,21 @@ def run_ping_job(triggered_by: str = "scheduler") -> dict:
118
  save_logs(logs)
119
  return run_result
120
 
121
- # Ping Spaces in parallel (10 at a time by default)
122
- results = []
123
  space_ids = [space.id for space in spaces]
 
 
 
124
 
125
  with ThreadPoolExecutor(max_workers=PARALLEL_REQUESTS) as executor:
126
- # Submit all ping tasks
127
- future_to_space = {executor.submit(ping_space, space_id): space_id for space_id in space_ids}
128
 
129
- # Collect results as they complete
130
  for future in as_completed(future_to_space):
131
  result = future.result()
132
  results.append(result)
 
 
 
 
133
 
134
  end_time = datetime.now(timezone.utc)
135
  duration = (end_time - start_time).total_seconds()
@@ -149,7 +166,6 @@ def run_ping_job(triggered_by: str = "scheduler") -> dict:
149
  "results": results
150
  }
151
 
152
- # Save to logs
153
  logs = load_logs()
154
  logs.append(run_result)
155
  save_logs(logs)
@@ -163,136 +179,303 @@ def scheduled_job():
163
  run_ping_job(triggered_by="scheduler")
164
 
165
 
166
- def manual_trigger():
167
- """Manually trigger a ping run."""
168
- result = run_ping_job(triggered_by="manual")
169
- return format_run_result(result), get_logs_display()
170
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
- def format_run_result(result: dict) -> str:
173
- """Format a single run result for display."""
174
- if result["status"] == "error":
175
- return f"""## ❌ Run Failed
176
 
177
- **Time:** {result['timestamp']}
178
- **Triggered by:** {result['triggered_by']}
179
- **Error:** {result['error']}
180
- """
181
-
182
- status_emoji = "βœ…" if result["failed"] == 0 else "⚠️"
183
 
184
- output = f"""## {status_emoji} Run Completed
185
-
186
- **Time:** {result['timestamp']}
187
- **Triggered by:** {result['triggered_by']}
188
- **Duration:** {result['duration_seconds']}s
189
- **Spaces pinged:** {result['total_spaces']} ({result['successful']} successful, {result['failed']} failed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  """
191
-
192
- if result["failed"] > 0:
193
- output += "\n### Failed Spaces:\n"
194
- for r in result["results"]:
195
- if r["status"] != "success":
196
- output += f"- `{r['space_id']}`: {r['error']}\n"
197
-
198
- return output
199
 
200
 
201
- def get_logs_display() -> str:
202
- """Get formatted logs for display."""
203
  logs = load_logs()
204
 
205
  if not logs:
206
- return "No runs recorded yet. Click 'Run Now' to trigger a manual run."
207
-
208
- # Reverse to show most recent first
209
- logs = list(reversed(logs))
210
 
211
- output = "# Run History\n\n"
212
 
213
- for i, run in enumerate(logs[:20]): # Show last 20 runs
214
  timestamp = run["timestamp"]
215
  triggered_by = run["triggered_by"]
216
 
217
  if run["status"] == "error":
218
- output += f"### ❌ {timestamp}\n"
219
- output += f"Triggered by: {triggered_by} | Error: {run['error']}\n\n"
220
  else:
221
- status_emoji = "βœ…" if run["failed"] == 0 else "⚠️"
222
- output += f"### {status_emoji} {timestamp}\n"
223
- output += f"Triggered by: {triggered_by} | "
224
- output += f"Spaces: {run['total_spaces']} | "
225
- output += f"Success: {run['successful']} | "
226
- output += f"Failed: {run['failed']} | "
227
- output += f"Duration: {run['duration_seconds']}s\n"
228
 
229
  if run["failed"] > 0:
230
- failed_spaces = [r["space_id"] for r in run["results"] if r["status"] != "success"]
231
- output += f"Failed: {', '.join(failed_spaces)}\n"
 
 
 
232
  output += "\n"
233
 
234
- if len(logs) > 20:
235
- output += f"\n*...and {len(logs) - 20} more runs (showing last 20)*\n"
236
-
237
  return output
238
 
239
 
240
- def get_status() -> str:
241
- """Get current scheduler status."""
242
- next_run = scheduler.get_jobs()[0].next_run_time if scheduler.get_jobs() else None
243
- token_status = "βœ… Configured" if HF_TOKEN else "❌ Not set (only public Spaces will be listed)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- return f"""## Space Keeper Status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
- **Organization:** `{ORG_NAME}`
248
- **Ping Interval:** Every {PING_INTERVAL_HOURS} hours
249
- **Parallel Requests:** {PARALLEL_REQUESTS} Spaces at once
250
- **Request Timeout:** {REQUEST_TIMEOUT} seconds
251
- **HF Token:** {token_status}
252
- **Next Scheduled Run:** {next_run.strftime('%Y-%m-%d %H:%M:%S UTC') if next_run else 'Not scheduled'}
253
 
254
- ---
 
255
 
256
- This Space automatically pings all Spaces in the `{ORG_NAME}` organization to keep them from sleeping during the evaluation period.
 
257
  """
 
 
258
 
259
 
260
- def refresh_logs():
261
- """Refresh the logs display."""
262
- return get_logs_display()
263
 
264
 
265
- # Build the Gradio interface
 
 
266
  with gr.Blocks() as demo:
267
- gr.Markdown("# πŸ”„ Space Keeper")
268
- gr.Markdown("Keeps HuggingFace Spaces alive by pinging them on a schedule.")
 
 
 
269
 
270
  with gr.Row():
271
  with gr.Column(scale=1):
272
- status_display = gr.Markdown(get_status())
273
 
274
  with gr.Row():
275
  run_btn = gr.Button("πŸš€ Run Now", variant="primary", size="lg")
276
- refresh_btn = gr.Button("πŸ”„ Refresh Logs", size="lg")
277
-
278
- last_run_display = gr.Markdown("Click 'Run Now' to trigger a manual ping run.")
279
 
280
- with gr.Column(scale=2):
281
- logs_display = gr.Markdown(get_logs_display())
 
 
 
 
282
 
283
  # Event handlers
284
  run_btn.click(
285
- fn=manual_trigger,
286
- outputs=[last_run_display, logs_display]
287
  )
288
 
289
  refresh_btn.click(
290
- fn=refresh_logs,
291
- outputs=[logs_display]
292
  )
293
 
294
 
295
- # Start the scheduler
 
 
296
  scheduler.add_job(
297
  scheduled_job,
298
  trigger=IntervalTrigger(hours=PING_INTERVAL_HOURS),
@@ -302,8 +485,10 @@ scheduler.add_job(
302
  )
303
  scheduler.start()
304
 
305
- print(f"Space Keeper started for org: {ORG_NAME}")
306
- print(f"Ping interval: {PING_INTERVAL_HOURS} hours")
 
 
307
 
308
  if __name__ == "__main__":
309
  demo.launch()
 
1
  """
2
+ Space Keeper v2 - Keeps HuggingFace Spaces alive during hackathon evaluation
3
  Pings all Spaces in an organization on a schedule to prevent them from sleeping.
4
  """
5
 
 
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from datetime import datetime, timezone
10
  from pathlib import Path
11
+ import threading
12
 
13
  import gradio as gr
14
  import requests
 
16
  from apscheduler.schedulers.background import BackgroundScheduler
17
  from apscheduler.triggers.interval import IntervalTrigger
18
 
19
+ # =============================================================================
20
+ # CONFIGURATION
21
+ # =============================================================================
22
  ORG_NAME = os.environ.get("ORG_NAME", "MCP-1st-Birthday")
23
+ PING_INTERVAL_HOURS = int(os.environ.get("PING_INTERVAL_HOURS", "12"))
24
  REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "30"))
25
+ PARALLEL_REQUESTS = int(os.environ.get("PARALLEL_REQUESTS", "10"))
26
+ HF_TOKEN = os.environ.get("HF_TOKEN", None)
27
+
28
  LOG_FILE = Path("run_logs.json")
29
+ MAX_LOG_ENTRIES = 100
30
 
31
+ # =============================================================================
32
+ # GLOBAL STATE
33
+ # =============================================================================
34
  scheduler = BackgroundScheduler()
35
  api = HfApi(token=HF_TOKEN) if HF_TOKEN else HfApi()
36
+ APP_START_TIME = datetime.now(timezone.utc)
37
+
38
+ # Lock for thread-safe log updates
39
+ log_lock = threading.Lock()
40
 
41
 
42
+ # =============================================================================
43
+ # LOGGING FUNCTIONS
44
+ # =============================================================================
45
  def load_logs() -> list:
46
  """Load logs from file."""
47
+ with log_lock:
48
+ if LOG_FILE.exists():
49
+ try:
50
+ with open(LOG_FILE, "r") as f:
51
+ return json.load(f)
52
+ except Exception:
53
+ return []
54
+ return []
55
 
56
 
57
  def save_logs(logs: list):
58
  """Save logs to file, keeping only the most recent entries."""
59
+ with log_lock:
60
+ logs = logs[-MAX_LOG_ENTRIES:]
61
+ with open(LOG_FILE, "w") as f:
62
+ json.dump(logs, f, indent=2)
63
 
64
 
65
+ # =============================================================================
66
+ # PING FUNCTIONS
67
+ # =============================================================================
68
  def ping_space(space_id: str) -> dict:
69
  """Ping a single Space and return the result."""
 
 
70
  org, name = space_id.split("/")
71
  app_url = f"https://{org.lower()}-{name.lower()}.hf.space"
72
  hf_page_url = f"https://huggingface.co/spaces/{space_id}"
73
 
 
74
  try:
75
  response = requests.get(app_url, timeout=REQUEST_TIMEOUT)
76
  return {
 
86
  "status": "timeout",
87
  "status_code": None,
88
  "url_pinged": app_url,
89
+ "error": f"Timed out after {REQUEST_TIMEOUT}s"
90
  }
91
  except Exception as e:
 
92
  try:
93
  response = requests.get(hf_page_url, timeout=REQUEST_TIMEOUT)
94
  return {
 
98
  "url_pinged": hf_page_url,
99
  "error": None
100
  }
101
+ except Exception:
102
  return {
103
  "space_id": space_id,
104
  "status": "error",
 
108
  }
109
 
110
 
111
+ def run_ping_job(triggered_by: str = "scheduler", progress_callback=None) -> dict:
112
+ """Run the ping job for all Spaces in the org."""
113
  start_time = datetime.now(timezone.utc)
114
 
115
  # Get all Spaces in the org
 
132
  save_logs(logs)
133
  return run_result
134
 
 
 
135
  space_ids = [space.id for space in spaces]
136
+ total = len(space_ids)
137
+ results = []
138
+ completed = 0
139
 
140
  with ThreadPoolExecutor(max_workers=PARALLEL_REQUESTS) as executor:
141
+ future_to_space = {executor.submit(ping_space, sid): sid for sid in space_ids}
 
142
 
 
143
  for future in as_completed(future_to_space):
144
  result = future.result()
145
  results.append(result)
146
+ completed += 1
147
+
148
+ if progress_callback:
149
+ progress_callback(completed, total, result)
150
 
151
  end_time = datetime.now(timezone.utc)
152
  duration = (end_time - start_time).total_seconds()
 
166
  "results": results
167
  }
168
 
 
169
  logs = load_logs()
170
  logs.append(run_result)
171
  save_logs(logs)
 
179
  run_ping_job(triggered_by="scheduler")
180
 
181
 
182
+ # =============================================================================
183
+ # UI HELPER FUNCTIONS
184
+ # =============================================================================
185
+ def get_uptime() -> str:
186
+ """Get how long the app has been running."""
187
+ delta = datetime.now(timezone.utc) - APP_START_TIME
188
+ hours, remainder = divmod(int(delta.total_seconds()), 3600)
189
+ minutes, seconds = divmod(remainder, 60)
190
+ if hours > 0:
191
+ return f"{hours}h {minutes}m"
192
+ elif minutes > 0:
193
+ return f"{minutes}m {seconds}s"
194
+ else:
195
+ return f"{seconds}s"
196
+
197
+
198
+ def get_next_run_time() -> str:
199
+ """Get the next scheduled run time."""
200
+ jobs = scheduler.get_jobs()
201
+ if jobs and jobs[0].next_run_time:
202
+ return jobs[0].next_run_time.strftime('%Y-%m-%d %H:%M:%S UTC')
203
+ return "Not scheduled"
204
+
205
+
206
+ def get_stats() -> dict:
207
+ """Get statistics from logs."""
208
+ logs = load_logs()
209
+ if not logs:
210
+ return {
211
+ "total_runs": 0,
212
+ "last_run": "Never",
213
+ "last_run_status": "N/A",
214
+ "total_spaces_pinged": 0,
215
+ "total_successful": 0,
216
+ "total_failed": 0
217
+ }
218
+
219
+ last_run = logs[-1]
220
+ return {
221
+ "total_runs": len(logs),
222
+ "last_run": last_run["timestamp"],
223
+ "last_run_status": "βœ… Success" if last_run.get("failed", 0) == 0 else f"⚠️ {last_run.get('failed', 0)} failed",
224
+ "total_spaces_pinged": sum(r.get("total_spaces", 0) for r in logs),
225
+ "total_successful": sum(r.get("successful", 0) for r in logs),
226
+ "total_failed": sum(r.get("failed", 0) for r in logs)
227
+ }
228
 
 
 
 
 
229
 
230
+ def format_status_panel() -> str:
231
+ """Format the status panel with current configuration and stats."""
232
+ stats = get_stats()
233
+ token_status = "βœ… Set" if HF_TOKEN else "❌ Not set"
 
 
234
 
235
+ return f"""## βš™οΈ Configuration
236
+
237
+ | Setting | Value |
238
+ |---------|-------|
239
+ | **Organization** | `{ORG_NAME}` |
240
+ | **Ping Interval** | Every {PING_INTERVAL_HOURS} hours |
241
+ | **Parallel Requests** | {PARALLEL_REQUESTS} at once |
242
+ | **Request Timeout** | {REQUEST_TIMEOUT} seconds |
243
+ | **HF Token** | {token_status} |
244
+
245
+ ## πŸ“Š Statistics (since Space started)
246
+
247
+ | Metric | Value |
248
+ |--------|-------|
249
+ | **App Uptime** | {get_uptime()} |
250
+ | **Total Runs** | {stats['total_runs']} |
251
+ | **Last Run** | {stats['last_run']} |
252
+ | **Last Run Status** | {stats['last_run_status']} |
253
+ | **Total Pings Sent** | {stats['total_spaces_pinged']} |
254
+
255
+ ## ⏰ Scheduler
256
+
257
+ | | |
258
+ |--|--|
259
+ | **Next Scheduled Run** | {get_next_run_time()} |
260
+
261
+ > **How it works:** A background thread automatically triggers a ping run every {PING_INTERVAL_HOURS} hours.
262
+ > If this Space sleeps, the scheduler stops. Keep this Space awake for reliable scheduling!
263
  """
 
 
 
 
 
 
 
 
264
 
265
 
266
+ def format_logs_panel() -> str:
267
+ """Format the logs panel showing recent runs."""
268
  logs = load_logs()
269
 
270
  if not logs:
271
+ return """## πŸ“œ Run History
272
+
273
+ *No runs recorded yet. Click "πŸš€ Run Now" to trigger your first ping run!*
274
+ """
275
 
276
+ output = "## πŸ“œ Run History\n\n"
277
 
278
+ for run in reversed(logs[-15:]): # Show last 15 runs
279
  timestamp = run["timestamp"]
280
  triggered_by = run["triggered_by"]
281
 
282
  if run["status"] == "error":
283
+ output += f"**❌ {timestamp}** β€” {triggered_by} β€” Error: {run['error']}\n\n"
 
284
  else:
285
+ emoji = "βœ…" if run["failed"] == 0 else "⚠️"
286
+ output += f"**{emoji} {timestamp}** β€” {triggered_by} β€” "
287
+ output += f"{run['total_spaces']} spaces ({run['successful']} ok, {run['failed']} failed) β€” "
288
+ output += f"{run['duration_seconds']}s\n"
 
 
 
289
 
290
  if run["failed"] > 0:
291
+ failed_list = [r["space_id"].split("/")[1] for r in run["results"] if r["status"] != "success"][:5]
292
+ output += f" └─ Failed: {', '.join(failed_list)}"
293
+ if run["failed"] > 5:
294
+ output += f" (+{run['failed'] - 5} more)"
295
+ output += "\n"
296
  output += "\n"
297
 
 
 
 
298
  return output
299
 
300
 
301
+ # =============================================================================
302
+ # MAIN FUNCTIONS FOR UI
303
+ # =============================================================================
304
+ def manual_trigger_with_progress(progress=gr.Progress(track_tqdm=True)):
305
+ """Manually trigger a ping run with live progress updates."""
306
+
307
+ # Initial status
308
+ yield (
309
+ "## πŸ”„ Running...\n\nFetching list of Spaces from the organization...",
310
+ format_status_panel(),
311
+ format_logs_panel()
312
+ )
313
+
314
+ start_time = datetime.now(timezone.utc)
315
+
316
+ # Get spaces
317
+ try:
318
+ spaces = list(api.list_spaces(author=ORG_NAME))
319
+ except Exception as e:
320
+ error_msg = f"## ❌ Failed\n\nCould not list Spaces: {str(e)}"
321
+ run_result = {
322
+ "timestamp": start_time.isoformat(),
323
+ "triggered_by": "manual",
324
+ "status": "error",
325
+ "error": str(e),
326
+ "total_spaces": 0,
327
+ "successful": 0,
328
+ "failed": 0,
329
+ "duration_seconds": 0,
330
+ "results": []
331
+ }
332
+ logs = load_logs()
333
+ logs.append(run_result)
334
+ save_logs(logs)
335
+ yield (error_msg, format_status_panel(), format_logs_panel())
336
+ return
337
+
338
+ space_ids = [space.id for space in spaces]
339
+ total = len(space_ids)
340
+ results = []
341
+ completed = 0
342
+ successful = 0
343
+ failed = 0
344
+
345
+ yield (
346
+ f"## πŸ”„ Running...\n\nFound **{total} Spaces** to ping.\n\nStarting parallel pings ({PARALLEL_REQUESTS} at a time)...\n\n`[{completed}/{total}]` β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ 0%",
347
+ format_status_panel(),
348
+ format_logs_panel()
349
+ )
350
+
351
+ # Run pings in parallel
352
+ with ThreadPoolExecutor(max_workers=PARALLEL_REQUESTS) as executor:
353
+ future_to_space = {executor.submit(ping_space, sid): sid for sid in space_ids}
354
+
355
+ for future in as_completed(future_to_space):
356
+ result = future.result()
357
+ results.append(result)
358
+ completed += 1
359
+
360
+ if result["status"] == "success":
361
+ successful += 1
362
+ else:
363
+ failed += 1
364
+
365
+ # Update progress bar
366
+ pct = int((completed / total) * 100)
367
+ filled = int(pct / 10)
368
+ bar = "β–ˆ" * filled + "β–‘" * (10 - filled)
369
+
370
+ status_emoji = "βœ…" if result["status"] == "success" else "❌"
371
+ space_name = result["space_id"].split("/")[1]
372
+
373
+ live_status = f"""## πŸ”„ Running...
374
+
375
+ **Progress:** `[{completed}/{total}]` {bar} {pct}%
376
+
377
+ **Last pinged:** {status_emoji} `{space_name}`
378
+
379
+ **So far:** {successful} successful, {failed} failed
380
+ """
381
+
382
+ # Only yield every 5 completions to avoid too many updates
383
+ if completed % 5 == 0 or completed == total:
384
+ yield (live_status, format_status_panel(), format_logs_panel())
385
+
386
+ # Final results
387
+ end_time = datetime.now(timezone.utc)
388
+ duration = (end_time - start_time).total_seconds()
389
+
390
+ run_result = {
391
+ "timestamp": start_time.isoformat(),
392
+ "triggered_by": "manual",
393
+ "status": "completed",
394
+ "error": None,
395
+ "total_spaces": total,
396
+ "successful": successful,
397
+ "failed": failed,
398
+ "duration_seconds": round(duration, 2),
399
+ "results": results
400
+ }
401
 
402
+ logs = load_logs()
403
+ logs.append(run_result)
404
+ save_logs(logs)
405
+
406
+ # Format final summary
407
+ if failed == 0:
408
+ final_status = f"""## βœ… Completed Successfully!
409
+
410
+ **Pinged {total} Spaces** in {round(duration, 1)} seconds.
411
+
412
+ All Spaces responded successfully!
413
+ """
414
+ else:
415
+ failed_spaces = [r["space_id"].split("/")[1] for r in results if r["status"] != "success"]
416
+ failed_list = "\n".join([f"- `{s}`" for s in failed_spaces[:10]])
417
+ if len(failed_spaces) > 10:
418
+ failed_list += f"\n- ... and {len(failed_spaces) - 10} more"
419
+
420
+ final_status = f"""## ⚠️ Completed with Issues
421
 
422
+ **Pinged {total} Spaces** in {round(duration, 1)} seconds.
 
 
 
 
 
423
 
424
+ βœ… **{successful}** successful
425
+ ❌ **{failed}** failed
426
 
427
+ ### Failed Spaces:
428
+ {failed_list}
429
  """
430
+
431
+ yield (final_status, format_status_panel(), format_logs_panel())
432
 
433
 
434
+ def refresh_all():
435
+ """Refresh all panels."""
436
+ return format_status_panel(), format_logs_panel()
437
 
438
 
439
+ # =============================================================================
440
+ # GRADIO UI
441
+ # =============================================================================
442
  with gr.Blocks() as demo:
443
+ gr.Markdown("""# πŸ”„ Space Keeper
444
+
445
+ Keeps HuggingFace Spaces alive by pinging them on a schedule.
446
+ This prevents Spaces from sleeping during the hackathon evaluation period.
447
+ """)
448
 
449
  with gr.Row():
450
  with gr.Column(scale=1):
451
+ status_panel = gr.Markdown(format_status_panel())
452
 
453
  with gr.Row():
454
  run_btn = gr.Button("πŸš€ Run Now", variant="primary", size="lg")
455
+ refresh_btn = gr.Button("πŸ”„ Refresh", size="lg")
 
 
456
 
457
+ with gr.Column(scale=1):
458
+ live_output = gr.Markdown("## Ready\n\nClick **πŸš€ Run Now** to ping all Spaces in the organization.\n\nThe scheduler will also run automatically every 12 hours.")
459
+
460
+ gr.Markdown("---")
461
+
462
+ logs_panel = gr.Markdown(format_logs_panel())
463
 
464
  # Event handlers
465
  run_btn.click(
466
+ fn=manual_trigger_with_progress,
467
+ outputs=[live_output, status_panel, logs_panel]
468
  )
469
 
470
  refresh_btn.click(
471
+ fn=refresh_all,
472
+ outputs=[status_panel, logs_panel]
473
  )
474
 
475
 
476
+ # =============================================================================
477
+ # START SCHEDULER
478
+ # =============================================================================
479
  scheduler.add_job(
480
  scheduled_job,
481
  trigger=IntervalTrigger(hours=PING_INTERVAL_HOURS),
 
485
  )
486
  scheduler.start()
487
 
488
+ print(f"[{APP_START_TIME.isoformat()}] Space Keeper started")
489
+ print(f" Organization: {ORG_NAME}")
490
+ print(f" Ping interval: {PING_INTERVAL_HOURS} hours")
491
+ print(f" Next run: {get_next_run_time()}")
492
 
493
  if __name__ == "__main__":
494
  demo.launch()