zelin-keepalive / app.py
TomatitoToho's picture
Deploy app.py
be83318 verified
Raw
History Blame Contribute Delete
15.5 kB
"""
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
# ---------------------------------------------------------------------------
@app.route("/")
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 &middot; Uptime: {uptime_hours}h {uptime_mins}m
&middot; Total pings: {total_pings} &middot; 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 &middot; <a href="/health" style="color:#38bdf8">/health</a>
&middot; <a href="/ping" style="color:#38bdf8">/ping</a>
&middot; <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
@app.route("/health")
def health():
"""Simple health check endpoint."""
return jsonify({"status": "ok", "service": "keepalive", "timestamp": datetime.now(timezone.utc).isoformat()}), 200
@app.route("/ping")
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
@app.route("/api/status")
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)