From cd12435a950168d27c81bcc5442a5d494f121fb3 Mon Sep 17 00:00:00 2001 From: Greg Hendrickson Date: Tue, 27 Jan 2026 21:29:35 +0000 Subject: [PATCH] Add leaderboard API and update website - /api/leaderboard - returns top 10 players - /api/stats - returns total players/games - /api/health - health check - Website now shows live leaderboard with auto-refresh - Nginx proxies /api to container port 8080 --- docker-compose.yml | 1 + src/shellmate/api/__init__.py | 5 ++ src/shellmate/api/server.py | 135 ++++++++++++++++++++++++++++++++++ src/shellmate/ssh/server.py | 5 ++ 4 files changed, 146 insertions(+) create mode 100644 src/shellmate/api/__init__.py create mode 100644 src/shellmate/api/server.py diff --git a/docker-compose.yml b/docker-compose.yml index bccd856..6fdf460 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: . ports: - "22:22" + - "8080:8080" environment: - SHELLMATE_SSH_PORT=22 - SHELLMATE_REDIS_URL=redis://redis:6379 diff --git a/src/shellmate/api/__init__.py b/src/shellmate/api/__init__.py new file mode 100644 index 0000000..fbca95f --- /dev/null +++ b/src/shellmate/api/__init__.py @@ -0,0 +1,5 @@ +"""Simple HTTP API for ShellMate.""" + +from shellmate.api.server import start_api_server + +__all__ = ["start_api_server"] diff --git a/src/shellmate/api/server.py b/src/shellmate/api/server.py new file mode 100644 index 0000000..1cc0610 --- /dev/null +++ b/src/shellmate/api/server.py @@ -0,0 +1,135 @@ +"""Simple HTTP API server for leaderboard data.""" + +import asyncio +import json +import logging +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +logger = logging.getLogger(__name__) + + +class APIHandler(BaseHTTPRequestHandler): + """HTTP request handler for API endpoints.""" + + db = None # Will be set by server + + def do_GET(self): + """Handle GET requests.""" + if self.path == "/api/leaderboard": + self.handle_leaderboard() + elif self.path == "/api/stats": + self.handle_stats() + elif self.path == "/api/health": + self.handle_health() + else: + self.send_error(404, "Not Found") + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(200) + self.send_cors_headers() + self.end_headers() + + def send_cors_headers(self): + """Add CORS headers.""" + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def send_json(self, data: Any, status: int = 200): + """Send JSON response.""" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def handle_leaderboard(self): + """Return leaderboard data.""" + try: + # Run async query in sync context + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + entries = loop.run_until_complete(self._get_leaderboard()) + finally: + loop.close() + + data = { + "leaderboard": [ + { + "rank": e.rank, + "username": e.username, + "elo": e.elo, + "games": e.games_played, + "wins": e.wins, + "losses": e.losses, + "winrate": e.winrate, + } + for e in entries + ] + } + self.send_json(data) + except Exception as e: + logger.error(f"Leaderboard error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_stats(self): + """Return overall stats.""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + stats = loop.run_until_complete(self._get_stats()) + finally: + loop.close() + + self.send_json(stats) + except Exception as e: + logger.error(f"Stats error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_health(self): + """Health check endpoint.""" + self.send_json({"status": "ok"}) + + async def _get_leaderboard(self): + """Fetch leaderboard from database.""" + from shellmate.db import Database + db = await Database.get_instance() + return await db.get_leaderboard(10) + + async def _get_stats(self): + """Fetch stats from database.""" + from shellmate.db import Database + db = await Database.get_instance() + return { + "total_players": await db.get_total_players(), + "total_games": await db.get_total_games(), + } + + def log_message(self, format, *args): + """Suppress default logging.""" + logger.debug(f"API: {args[0]}") + + +def start_api_server(host: str = "0.0.0.0", port: int = 8080): + """Start the API server (blocking).""" + server = HTTPServer((host, port), APIHandler) + logger.info(f"API server listening on {host}:{port}") + server.serve_forever() + + +async def start_api_server_async(host: str = "0.0.0.0", port: int = 8080): + """Start the API server in a thread.""" + import threading + + def run_server(): + server = HTTPServer((host, port), APIHandler) + logger.info(f"API server listening on {host}:{port}") + server.serve_forever() + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + logger.info("API server thread started") diff --git a/src/shellmate/ssh/server.py b/src/shellmate/ssh/server.py index 78f01ce..6d7be03 100644 --- a/src/shellmate/ssh/server.py +++ b/src/shellmate/ssh/server.py @@ -801,6 +801,11 @@ def main() -> None: asyncio.set_event_loop(loop) try: + # Start API server in background thread + from shellmate.api.server import start_api_server_async + loop.run_until_complete(start_api_server_async(port=8080)) + + # Start SSH server loop.run_until_complete(start_server()) loop.run_forever() except KeyboardInterrupt: