Spaces:
Sleeping
Sleeping
| """ | |
| HuggingFace Spaces Keep-Alive Service | |
| ====================================== | |
| A minimal service that periodically pings Zelin bot Spaces to prevent | |
| them from sleeping due to inactivity. | |
| Spaces monitored: | |
| - TomatitoToho/agent-nav-3d (Zelin Minecraft bot) | |
| - TomatitoToho/zelin-v6x (Zelin Discord bot) | |
| This Space also exposes /health so the Zelin spaces can ping it back, | |
| creating a mutually-assured-wakefulness triangle. | |
| """ | |
| import os | |
| import time | |
| import threading | |
| import logging | |
| from datetime import datetime, timezone | |
| import requests | |
| from flask import Flask, jsonify | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| SPACES = { | |
| "agent-nav-3d": { | |
| "url": "https://tomatitotoho-agent-nav-3d.hf.space/health", | |
| "label": "Zelin Minecraft Bot", | |
| "emoji": "⛏️", | |
| "hf_url": "https://huggingface.co/spaces/TomatitoToho/agent-nav-3d", | |
| }, | |
| "zelin-v6x": { | |
| "url": "https://tomatitotoho-zelin-v6x.hf.space/health", | |
| "label": "Zelin Discord Bot", | |
| "emoji": "🤖", | |
| "hf_url": "https://huggingface.co/spaces/TomatitoToho/zelin-v6x", | |
| }, | |
| "keepalive": { | |
| "url": "https://tomatitotoho-keepalive.hf.space/health", | |
| "label": "Keep-Alive Service (self)", | |
| "emoji": "💓", | |
| "hf_url": "https://huggingface.co/spaces/TomatitoToho/keepalive", | |
| }, | |
| } | |
| PING_INTERVAL_SECONDS = int(os.environ.get("PING_INTERVAL", 240)) # 4 min default | |
| PING_TIMEOUT_SECONDS = int(os.environ.get("PING_TIMEOUT", 15)) | |
| PORT = int(os.environ.get("PORT", 7860)) | |
| # --------------------------------------------------------------------------- | |
| # Logging | |
| # --------------------------------------------------------------------------- | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| logger = logging.getLogger("keepalive") | |
| # --------------------------------------------------------------------------- | |
| # Flask app | |
| # --------------------------------------------------------------------------- | |
| app = Flask(__name__) | |
| # --------------------------------------------------------------------------- | |
| # In-memory status store (thread-safe via lock) | |
| # --------------------------------------------------------------------------- | |
| status_lock = threading.Lock() | |
| space_status: dict[str, dict] = {} | |
| ping_history: list[dict] = [] # last 50 pings | |
| total_pings = 0 | |
| failed_pings = 0 | |
| service_start_time = datetime.now(timezone.utc) | |
| def _init_status(): | |
| for key, meta in SPACES.items(): | |
| space_status[key] = { | |
| "key": key, | |
| "label": meta["label"], | |
| "emoji": meta["emoji"], | |
| "hf_url": meta["hf_url"], | |
| "last_ping_time": None, | |
| "last_status": "never_pinged", | |
| "last_status_code": None, | |
| "last_latency_ms": None, | |
| "consecutive_failures": 0, | |
| "total_successes": 0, | |
| "total_failures": 0, | |
| } | |
| _init_status() | |
| # --------------------------------------------------------------------------- | |
| # Ping logic | |
| # --------------------------------------------------------------------------- | |
| def ping_space(key: str) -> dict: | |
| """Ping a single space and return the result dict.""" | |
| meta = SPACES[key] | |
| url = meta["url"] | |
| result = { | |
| "key": key, | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "status": "unknown", | |
| "status_code": None, | |
| "latency_ms": None, | |
| "error": None, | |
| } | |
| try: | |
| start = time.monotonic() | |
| resp = requests.get(url, timeout=PING_TIMEOUT_SECONDS) | |
| latency = (time.monotonic() - start) * 1000 | |
| result["status_code"] = resp.status_code | |
| result["latency_ms"] = round(latency, 1) | |
| if 200 <= resp.status_code < 400: | |
| result["status"] = "alive" | |
| else: | |
| result["status"] = "unhealthy" | |
| result["error"] = f"HTTP {resp.status_code}" | |
| except requests.exceptions.Timeout: | |
| result["status"] = "timeout" | |
| result["error"] = f"Timed out after {PING_TIMEOUT_SECONDS}s" | |
| except requests.exceptions.ConnectionError as exc: | |
| result["status"] = "connection_error" | |
| result["error"] = str(exc)[:120] | |
| except Exception as exc: # noqa: BLE001 | |
| result["status"] = "error" | |
| result["error"] = str(exc)[:120] | |
| return result | |
| def ping_all_spaces(): | |
| """Ping every configured space and update in-memory status.""" | |
| global total_pings, failed_pings # noqa: PLW0603 | |
| logger.info("Pinging all spaces ...") | |
| for key in SPACES: | |
| result = ping_space(key) | |
| with status_lock: | |
| total_pings += 1 | |
| st = space_status[key] | |
| st["last_ping_time"] = result["timestamp"] | |
| st["last_status"] = result["status"] | |
| st["last_status_code"] = result["status_code"] | |
| st["last_latency_ms"] = result["latency_ms"] | |
| if result["status"] == "alive": | |
| st["consecutive_failures"] = 0 | |
| st["total_successes"] += 1 | |
| else: | |
| st["consecutive_failures"] += 1 | |
| st["total_failures"] += 1 | |
| failed_pings += 1 | |
| # Log with appropriate severity | |
| if st["consecutive_failures"] >= 5: | |
| logger.warning( | |
| "⚠️ %s has been unreachable for %d consecutive pings!", | |
| key, st["consecutive_failures"], | |
| ) | |
| else: | |
| logger.info( | |
| "❌ %s: %s (%s)", | |
| key, result["status"], result.get("error", ""), | |
| ) | |
| # Append to history (keep last 50) | |
| ping_history.append(result) | |
| if len(ping_history) > 50: | |
| ping_history.pop(0) | |
| logger.info("Ping round complete.") | |
| # --------------------------------------------------------------------------- | |
| # Background scheduler (simple threaded timer — no extra deps needed) | |
| # --------------------------------------------------------------------------- | |
| def _scheduler_loop(): | |
| """Daemon thread that pings all spaces on a fixed interval.""" | |
| # First ping immediately | |
| ping_all_spaces() | |
| while True: | |
| time.sleep(PING_INTERVAL_SECONDS) | |
| try: | |
| ping_all_spaces() | |
| except Exception: # noqa: BLE001 | |
| logger.exception("Unexpected error in scheduler loop") | |
| scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True) | |
| scheduler_thread.start() | |
| logger.info( | |
| "Scheduler started — pinging every %d seconds", PING_INTERVAL_SECONDS, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| def dashboard(): | |
| """HTML status dashboard.""" | |
| with status_lock: | |
| rows = "" | |
| for key, st in space_status.items(): | |
| if st["last_status"] == "alive": | |
| badge_color = "#22c55e" | |
| badge_text = "ALIVE" | |
| elif st["last_status"] == "never_pinged": | |
| badge_color = "#6b7280" | |
| badge_text = "PENDING" | |
| elif st["consecutive_failures"] >= 5: | |
| badge_color = "#ef4444" | |
| badge_text = "DOWN" | |
| else: | |
| badge_color = "#f59e0b" | |
| badge_text = "ISSUES" | |
| latency_str = ( | |
| f'{st["last_latency_ms"]:.0f}ms' | |
| if st["last_latency_ms"] is not None | |
| else "—" | |
| ) | |
| last_time = st["last_ping_time"] or "—" | |
| if last_time != "—": | |
| # Trim to just the time portion for readability | |
| try: | |
| last_time = last_time.split("T")[1][:8] + " UTC" | |
| except (IndexError, ValueError): | |
| pass | |
| rows += f""" | |
| <tr> | |
| <td style="font-size:1.4em">{st["emoji"]}</td> | |
| <td><strong>{st["label"]}</strong><br> | |
| <a href="{st["hf_url"]}" target="_blank" | |
| style="color:#93c5fd;font-size:0.8em">{key}</a></td> | |
| <td><span style="background:{badge_color};color:#fff; | |
| padding:4px 12px;border-radius:9999px;font-size:0.85em; | |
| font-weight:600">{badge_text}</span></td> | |
| <td>{latency_str}</td> | |
| <td>{last_time}</td> | |
| <td>{st["total_successes"]}/{st["total_successes"]+st["total_failures"]}</td> | |
| <td>{st["consecutive_failures"]}</td> | |
| </tr>""" | |
| uptime_seconds = int( | |
| (datetime.now(timezone.utc) - service_start_time).total_seconds() | |
| ) | |
| uptime_hours = uptime_seconds // 3600 | |
| uptime_mins = (uptime_seconds % 3600) // 60 | |
| history_rows = "" | |
| for entry in reversed(ping_history[-15:]): | |
| ts = entry["timestamp"].split("T")[1][:8] if "T" in entry["timestamp"] else entry["timestamp"] | |
| icon = "✅" if entry["status"] == "alive" else "❌" | |
| latency = f'{entry["latency_ms"]:.0f}ms' if entry["latency_ms"] else "—" | |
| err = f' <span style="color:#f87171">({entry["error"][:60]})</span>' if entry.get("error") else "" | |
| history_rows += f"<tr><td>{ts}</td><td>{icon} {entry['key']}</td><td>{latency}</td><td>{entry['status']}{err}</td></tr>" | |
| html = f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Zelin Keep-Alive Monitor</title> | |
| <style> | |
| * {{ margin:0; padding:0; box-sizing:border-box; }} | |
| body {{ | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| background: #0f172a; color: #e2e8f0; | |
| min-height: 100vh; padding: 2rem; | |
| }} | |
| .container {{ max-width: 960px; margin: 0 auto; }} | |
| h1 {{ font-size: 1.8rem; margin-bottom: 0.3em; }} | |
| h1 span {{ color: #38bdf8; }} | |
| .subtitle {{ color: #94a3b8; margin-bottom: 2rem; font-size:0.95em; }} | |
| .stats {{ | |
| display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); | |
| gap: 1rem; margin-bottom: 2rem; | |
| }} | |
| .stat-card {{ | |
| background: #1e293b; border-radius: 12px; padding: 1rem 1.2rem; | |
| border: 1px solid #334155; | |
| }} | |
| .stat-card .value {{ font-size: 1.6rem; font-weight: 700; color: #38bdf8; }} | |
| .stat-card .label {{ font-size: 0.8rem; color: #94a3b8; margin-top: 0.2em; }} | |
| table {{ | |
| width: 100%; border-collapse: separate; border-spacing: 0; | |
| background: #1e293b; border-radius: 12px; overflow: hidden; | |
| border: 1px solid #334155; margin-bottom: 2rem; | |
| }} | |
| th {{ | |
| background: #334155; padding: 0.8rem 1rem; text-align: left; | |
| font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; | |
| color: #94a3b8; | |
| }} | |
| td {{ padding: 0.75rem 1rem; border-top: 1px solid #334155; font-size:0.9em; }} | |
| tr:hover td {{ background: #1e293b88; }} | |
| .section-title {{ | |
| font-size: 1.1rem; font-weight: 600; margin-bottom: 0.8rem; | |
| color: #cbd5e1; border-left: 3px solid #38bdf8; padding-left: 0.6rem; | |
| }} | |
| .footer {{ | |
| text-align: center; color: #475569; font-size: 0.8rem; margin-top: 2rem; | |
| }} | |
| .pulse {{ | |
| display: inline-block; width: 10px; height: 10px; border-radius: 50%; | |
| background: #22c55e; margin-right: 8px; | |
| animation: pulse-anim 2s ease-in-out infinite; | |
| }} | |
| @keyframes pulse-anim {{ | |
| 0%, 100% {{ opacity: 1; transform: scale(1); }} | |
| 50% {{ opacity: 0.5; transform: scale(0.8); }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1><span class="pulse"></span>Zelin <span>Keep-Alive</span> Monitor</h1> | |
| <p class="subtitle"> | |
| Pinging every {PING_INTERVAL_SECONDS}s · Uptime: {uptime_hours}h {uptime_mins}m | |
| · Total pings: {total_pings} · Failed: {failed_pings} | |
| </p> | |
| <div class="stats"> | |
| <div class="stat-card"> | |
| <div class="value">{len(SPACES)}</div> | |
| <div class="label">Spaces Monitored</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="value">{PING_INTERVAL_SECONDS}s</div> | |
| <div class="label">Ping Interval</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="value">{total_pings}</div> | |
| <div class="label">Total Pings</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="value">{failed_pings}</div> | |
| <div class="label">Failed Pings</div> | |
| </div> | |
| </div> | |
| <p class="section-title">Space Status</p> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th></th><th>Space</th><th>Status</th><th>Latency</th> | |
| <th>Last Ping</th><th>Success Rate</th><th>Fail Streak</th> | |
| </tr> | |
| </thead> | |
| <tbody>{rows}</tbody> | |
| </table> | |
| <p class="section-title">Recent Ping History (last 15)</p> | |
| <table> | |
| <thead> | |
| <tr><th>Time</th><th>Space</th><th>Latency</th><th>Result</th></tr> | |
| </thead> | |
| <tbody>{history_rows}</tbody> | |
| </table> | |
| <p class="footer"> | |
| Keep-Alive Service · <a href="/health" style="color:#38bdf8">/health</a> | |
| · <a href="/ping" style="color:#38bdf8">/ping</a> | |
| · <a href="/api/status" style="color:#38bdf8">/api/status</a> | |
| </p> | |
| </div> | |
| <script> | |
| // Auto-refresh every 30 seconds | |
| setTimeout(() => location.reload(), 30000); | |
| </script> | |
| </body> | |
| </html>""" | |
| return html | |
| def health(): | |
| """Simple health check endpoint.""" | |
| return jsonify({"status": "ok", "service": "keepalive", "timestamp": datetime.now(timezone.utc).isoformat()}), 200 | |
| def ping(): | |
| """Trigger an on-demand ping of all spaces and return results.""" | |
| results = {} | |
| for key in SPACES: | |
| result = ping_space(key) | |
| # Update status store | |
| with status_lock: | |
| st = space_status[key] | |
| st["last_ping_time"] = result["timestamp"] | |
| st["last_status"] = result["status"] | |
| st["last_status_code"] = result["status_code"] | |
| st["last_latency_ms"] = result["latency_ms"] | |
| if result["status"] == "alive": | |
| st["consecutive_failures"] = 0 | |
| st["total_successes"] += 1 | |
| else: | |
| st["consecutive_failures"] += 1 | |
| st["total_failures"] += 1 | |
| ping_history.append(result) | |
| if len(ping_history) > 50: | |
| ping_history.pop(0) | |
| results[key] = result | |
| return jsonify({"triggered_by": "manual", "spaces": results}), 200 | |
| def api_status(): | |
| """Full JSON status dump.""" | |
| with status_lock: | |
| return jsonify({ | |
| "service": "keepalive", | |
| "uptime_seconds": int( | |
| (datetime.now(timezone.utc) - service_start_time).total_seconds() | |
| ), | |
| "ping_interval_seconds": PING_INTERVAL_SECONDS, | |
| "total_pings": total_pings, | |
| "failed_pings": failed_pings, | |
| "spaces": space_status, | |
| "recent_history": ping_history[-15:], | |
| }), 200 | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| logger.info("Starting Keep-Alive service on port %d", PORT) | |
| app.run(host="0.0.0.0", port=PORT) | |