atlus/backend/routers/stats.py
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

121 lines
4.1 KiB
Python

"""System stats endpoint and background WebSocket broadcaster."""
import asyncio
import logging
import time
import psutil
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from backend.auth import get_current_user, ws_authenticate
from backend.config import STATS_INTERVAL
from backend.ws.manager import manager
router = APIRouter(prefix="/api/stats", tags=["stats"])
log = logging.getLogger("atlus.stats")
def _gather_stats() -> dict:
"""Collect current system stats via psutil."""
cpu_percent = psutil.cpu_percent(interval=0)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
# CPU temperature — varies by platform
temps = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
cpu_temp = None
for name in ("cpu_thermal", "coretemp", "cpu-thermal", "soc_thermal"):
if name in temps and temps[name]:
cpu_temp = temps[name][0].current
break
if cpu_temp is None and temps:
first = next(iter(temps.values()))
if first:
cpu_temp = first[0].current
# Network interfaces — filter out virtual/tunnel/loopback
_SKIP_PREFIXES = ("lo", "utun", "anpi", "bridge", "awdl", "llw", "gif", "stf", "ap", "veth", "docker", "br-")
addrs = psutil.net_if_addrs()
net_stats = psutil.net_if_stats()
interfaces = {}
for iface, addr_list in addrs.items():
if any(iface.startswith(p) for p in _SKIP_PREFIXES):
continue
info: dict = {"up": net_stats.get(iface, None) is not None and net_stats[iface].isup}
for a in addr_list:
if a.family.name == "AF_INET":
info["ipv4"] = a.address
elif a.family.name == "AF_INET6" and not a.address.startswith("fe80"):
info["ipv6"] = a.address
interfaces[iface] = info
# Network I/O
net_io = psutil.net_io_counters()
return {
"ts": time.time(),
"cpu_percent": cpu_percent,
"memory": {
"total": mem.total,
"used": mem.used,
"percent": mem.percent,
},
"disk": {
"total": disk.total,
"used": disk.used,
"percent": disk.percent,
},
"cpu_temp": cpu_temp,
"network": {
"interfaces": interfaces,
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
},
"uptime": time.time() - psutil.boot_time(),
"load_avg": list(psutil.getloadavg()) if hasattr(psutil, "getloadavg") else None,
}
# ---------------------------------------------------------------------------
# REST endpoint — one-shot stats
# ---------------------------------------------------------------------------
@router.get("")
async def get_stats(_user: str = Depends(get_current_user)):
return _gather_stats()
# ---------------------------------------------------------------------------
# WebSocket — live push
# ---------------------------------------------------------------------------
@router.websocket("/ws")
async def stats_ws(websocket: WebSocket):
username = await ws_authenticate(websocket)
await manager.connect(websocket, channel="stats")
try:
while True:
data = _gather_stats()
await manager.send_personal(websocket, data)
await asyncio.sleep(STATS_INTERVAL)
except (WebSocketDisconnect, RuntimeError):
pass
except Exception:
log.exception("stats ws error")
finally:
manager.disconnect(websocket, channel="stats")
# ---------------------------------------------------------------------------
# Background broadcaster (called from main.py lifespan)
# ---------------------------------------------------------------------------
async def stats_broadcaster():
"""Push stats to all connected clients on the 'stats' channel."""
while True:
try:
data = _gather_stats()
await manager.broadcast(data, channel="stats")
except Exception:
log.exception("broadcast error")
await asyncio.sleep(STATS_INTERVAL)