feat: dashboard UI with real-time stats, light/dark mode, session metrics
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
from health import HealthServer, SyncSession
|
||||
|
||||
|
||||
class DashboardServer:
|
||||
def __init__(self, port: int = 8082, health: HealthServer = None, session: SyncSession = None):
|
||||
self.port = port
|
||||
self.health = health
|
||||
self.session = session
|
||||
self.server = None
|
||||
self.thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._syncing = False
|
||||
self._next_sync_in = 0
|
||||
self._event_count = 0
|
||||
self._backoff_min = 0
|
||||
self._config = {}
|
||||
self._base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def start(self):
|
||||
handler = self._make_handler()
|
||||
self.server = HTTPServer(("0.0.0.0", self.port), handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server = None
|
||||
self.thread = None
|
||||
|
||||
def update_config(self, config_dict: dict):
|
||||
with self._lock:
|
||||
self._config = dict(config_dict)
|
||||
|
||||
def set_syncing(self, syncing: bool):
|
||||
with self._lock:
|
||||
self._syncing = syncing
|
||||
|
||||
def set_next_sync_in(self, seconds: int):
|
||||
with self._lock:
|
||||
self._next_sync_in = seconds
|
||||
|
||||
def set_event_count(self, n: int):
|
||||
with self._lock:
|
||||
self._event_count = n
|
||||
|
||||
def set_backoff_min(self, n: int):
|
||||
with self._lock:
|
||||
self._backoff_min = n
|
||||
|
||||
def _get_status(self):
|
||||
with self._lock:
|
||||
syncing = self._syncing
|
||||
next_sync_in = self._next_sync_in
|
||||
event_count = self._event_count
|
||||
backoff_min = self._backoff_min
|
||||
config = dict(self._config)
|
||||
|
||||
last_sync = None
|
||||
duration = 0.0
|
||||
last_success = None
|
||||
if self.health:
|
||||
with self.health.lock:
|
||||
last_sync = self.health.last_sync
|
||||
duration = self.health.last_sync_duration
|
||||
last_success = self.health.last_sync_success
|
||||
|
||||
status = "idle"
|
||||
if syncing:
|
||||
status = "syncing"
|
||||
elif last_success is False:
|
||||
status = "error"
|
||||
elif backoff_min > 0:
|
||||
status = "backoff"
|
||||
|
||||
session_data = {}
|
||||
history = []
|
||||
if self.session:
|
||||
session_data = self.session.get_status()
|
||||
history = session_data.get("history", [])
|
||||
|
||||
ics_latency = 0.0
|
||||
if self.session and self.session.non_skip_count > 0:
|
||||
ics_latency = round(self.session.total_latency_ms / self.session.non_skip_count)
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"last_sync": last_sync.isoformat() if last_sync else None,
|
||||
"duration": duration,
|
||||
"ics_latency_ms": ics_latency,
|
||||
"event_count": event_count,
|
||||
"next_sync_in": next_sync_in,
|
||||
"session": session_data,
|
||||
"history": history,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
def _make_handler(self):
|
||||
server_self = self
|
||||
base_dir = server_self._base_dir
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/api/status":
|
||||
self._handle_api(server_self)
|
||||
elif self.path == "/":
|
||||
self._handle_dashboard(base_dir)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": "not found"}).encode())
|
||||
|
||||
def _handle_api(self, srv):
|
||||
data = srv._get_status()
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _handle_dashboard(self, base):
|
||||
html_path = os.path.join(base, "static", "dashboard.html")
|
||||
try:
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
body = content.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": "dashboard not found"}).encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
return Handler
|
||||
Reference in New Issue
Block a user