feat: real-time WebSocket metrics broadcaster and UI updates

- New metrics_broadcaster service (410 lines) — background broadcaster
  pushing CPU/RAM/disk/network stats to subscribed WebSocket clients
- WebSocket protocol extended: subscribe/unsubscribe channels,
  get_rrd/get_node_rrd requests with request_id
- main.py: metrics broadcaster started in lifespan, WS message dispatcher
- websocket_manager.py: subscription map support
- templates: dashboard, instance_detail, proxmox_server_detail,
  proxmox_vms, workspaces — real-time charts and UI updates
- auth.py, servers.py, settings.py: minor fixes
This commit is contained in:
markmorado
2026-03-15 02:49:38 +01:00
parent c4778d2972
commit ed7f1f779e
14 changed files with 1203 additions and 167 deletions

View File

@@ -166,6 +166,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Backup scheduler startup failed: {e}")
# Start WebSocket metrics broadcaster
try:
from .services.metrics_broadcaster import start_metrics_broadcaster
from .db import SessionLocal
start_metrics_broadcaster(SessionLocal)
except Exception as e:
logger.warning(f"Metrics broadcaster startup failed: {e}")
logger.info("Application startup complete")
yield
@@ -335,6 +343,7 @@ def create_app() -> FastAPI:
from .db import SessionLocal
from .models import TaskQueue, ProxmoxTask, User
from .websocket_manager import ws_manager
import asyncio
import json
# --- Authenticate --------------------------------------------------
@@ -382,9 +391,50 @@ def create_app() -> FastAPI:
# Keep connection alive – wait for client messages / ping-pong
while True:
try:
msg = await websocket.receive_text()
if msg == "ping":
raw = await websocket.receive_text()
# Legacy plain-text ping
if raw == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
continue
# JSON message dispatcher
try:
msg = json.loads(raw)
except Exception:
continue
msg_type = msg.get("type", "")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "subscribe":
channel = msg.get("channel", "")
if channel:
ws_manager.subscribe(websocket, channel)
# Immediately send cached payload so client sees data without waiting
from .services.metrics_broadcaster import get_cached_metrics
_cached = get_cached_metrics(channel)
if _cached:
try:
await websocket.send_text(json.dumps(_cached, default=str))
except Exception:
pass
elif msg_type == "unsubscribe":
channel = msg.get("channel", "")
if channel:
ws_manager.unsubscribe(websocket, channel)
elif msg_type == "request":
action = msg.get("action", "")
request_id = msg.get("request_id", "")
if action in ("get_rrd", "get_node_rrd") and request_id:
from .services.metrics_broadcaster import handle_rrd_request
from .db import SessionLocal as _SL
asyncio.create_task(
handle_rrd_request(
websocket,
request_id,
action,
msg.get("params", {}),
_SL,
)
)
except WebSocketDisconnect:
break
except Exception: