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>
121 lines
4.1 KiB
Python
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)
|