Files
sysmonstm/ctrl/edge/edge.py
buenosairesam 91f95d55a5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Move edge HTML to templates, add jinja2
2026-01-26 21:00:50 -03:00

136 lines
4.9 KiB
Python

"""Minimal sysmonstm gateway - standalone mode without dependencies."""
import asyncio
import json
import logging
import os
from pathlib import Path
from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
from fastapi.requests import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
# Configuration
API_KEY = os.environ.get("API_KEY", "")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
# Logging setup
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("gateway")
app = FastAPI(title="sysmonstm")
# Templates
templates_path = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_path))
# Store connected websockets
connections: list[WebSocket] = []
# Store latest metrics from collectors
machines: dict = {}
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/health")
async def health():
return {"status": "ok", "machines": len(machines)}
@app.get("/api/machines")
async def get_machines():
return machines
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, key: str = Query(default="")):
# API key validation for collectors (browsers don't need key)
# We validate key only when metrics are received, allowing browsers to connect freely
await websocket.accept()
connections.append(websocket)
client = websocket.client.host if websocket.client else "unknown"
log.info(f"WebSocket connected: {client}")
try:
# Send current state to new connection
for machine_id, data in machines.items():
await websocket.send_json(
{"type": "metrics", "machine_id": machine_id, **data}
)
# Main loop
while True:
try:
msg = await asyncio.wait_for(websocket.receive_text(), timeout=30)
data = json.loads(msg)
if data.get("type") == "metrics":
# Validate API key for metric submissions
if API_KEY and key != API_KEY:
log.warning(f"Invalid API key from {client}")
await websocket.close(code=4001, reason="Invalid API key")
return
# Handle both formats:
# 1. Direct: {"type": "metrics", "machine_id": "...", "cpu": ...}
# 2. Nested (from gateway): {"type": "metrics", "data": {...}, "timestamp": "..."}
if "data" in data and isinstance(data["data"], dict):
# Nested format from gateway forwarding
payload = data["data"]
machine_id = payload.get("machine_id", "unknown")
# Extract metrics from nested structure
metrics = payload.get("metrics", {})
metric_data = {
"type": "metrics",
"machine_id": machine_id,
"hostname": payload.get("hostname", ""),
"timestamp": data.get("timestamp"),
}
# Flatten metrics for dashboard display
for key_name, value in metrics.items():
metric_data[key_name.lower()] = value
machines[machine_id] = metric_data
log.debug(f"Metrics (forwarded) from {machine_id}")
else:
# Direct format from collector
machine_id = data.get("machine_id", "unknown")
machines[machine_id] = data
log.debug(f"Metrics from {machine_id}: cpu={data.get('cpu')}%")
# Broadcast to all connected clients
broadcast_data = machines[machine_id]
for conn in connections:
try:
await conn.send_json(broadcast_data)
except Exception:
pass
except asyncio.TimeoutError:
# Send ping to keep connection alive
await websocket.send_json({"type": "ping"})
except WebSocketDisconnect:
log.info(f"WebSocket disconnected: {client}")
except Exception as e:
log.error(f"WebSocket error: {e}")
finally:
if websocket in connections:
connections.remove(websocket)
if __name__ == "__main__":
import uvicorn
log.info("Starting sysmonstm gateway")
log.info(f" API key: {'configured' if API_KEY else 'not set (open)'}")
uvicorn.run(app, host="0.0.0.0", port=8080)