www/app/main.py
Sundog Garage Studio 411038582a Initial commit
2026-03-17 16:17:53 +02:00

299 lines
11 KiB
Python

import asyncio
import json
import logging
import time
from pathlib import Path
from typing import List
import psutil
import yaml
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .docker_monitor import DockerMonitor
logger = logging.getLogger(__name__)
app = FastAPI(title="RedUnits Control Panel")
# Setup paths
BASE_DIR = Path(__file__).resolve().parent.parent
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Initialize Docker monitor
docker_monitor = DockerMonitor()
# ──────────────────────────────────────────────
# Config loader
# ──────────────────────────────────────────────
def load_services() -> List[dict]:
"""Load services from config.yaml. Falls back to empty list on error."""
config_path = BASE_DIR / "config.yaml"
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("services", [])
except FileNotFoundError:
logger.warning(f"config.yaml not found at {config_path}. No services loaded.")
return []
except Exception as e:
logger.error(f"Error loading config.yaml: {e}")
return []
def load_documents() -> List[dict]:
"""Load documents from config.yaml."""
config_path = BASE_DIR / "config.yaml"
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("documents", [])
except Exception:
return []
SERVICES = load_services()
# ──────────────────────────────────────────────
# WebSocket connection manager
# ──────────────────────────────────────────────
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
logger.info(f"WS client connected. Total: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"WS client disconnected. Total: {len(self.active_connections)}")
async def broadcast(self, data: dict):
if not self.active_connections:
return
message = json.dumps(data)
dead = []
for ws in self.active_connections:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws)
manager = ConnectionManager()
# ──────────────────────────────────────────────
# Background metrics collector
# ──────────────────────────────────────────────
async def collect_and_broadcast():
"""Collect system + service metrics and broadcast to all WS clients."""
try:
services_data = []
for service in SERVICES:
status = await asyncio.to_thread(
docker_monitor.get_container_status, service["container_name"]
)
services_data.append({**service, "status": status})
cpu_percent = await asyncio.to_thread(psutil.cpu_percent, 1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage("/")
containers_total = await asyncio.to_thread(docker_monitor.get_containers_count)
containers_running = await asyncio.to_thread(docker_monitor.get_running_containers_count)
uptime_seconds = time.time() - psutil.boot_time()
payload = {
"type": "update",
"services": services_data,
"system": {
"cpu": {"percent": round(cpu_percent, 1)},
"memory": {
"percent": round(memory.percent, 1),
"used_gb": round(memory.used / (1024 ** 3), 2),
"total_gb": round(memory.total / (1024 ** 3), 2),
},
"disk": {
"percent": round(disk.percent, 1),
"used_gb": round(disk.used / (1024 ** 3), 2),
"total_gb": round(disk.total / (1024 ** 3), 2),
},
"containers": {
"running": containers_running,
"total": containers_total,
},
"uptime": {
"days": int(uptime_seconds / 86400),
"seconds": int(uptime_seconds),
},
},
}
await manager.broadcast(payload)
except Exception as e:
logger.error(f"Error in collect_and_broadcast: {e}")
async def metrics_loop():
"""Background loop: collect and broadcast metrics every 5 seconds."""
while True:
await collect_and_broadcast()
await asyncio.sleep(5)
# ──────────────────────────────────────────────
# App lifecycle
# ──────────────────────────────────────────────
@app.on_event("startup")
async def startup_event():
asyncio.create_task(metrics_loop())
logger.info("Metrics background task started")
# ──────────────────────────────────────────────
# HTTP routes
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Render main dashboard page."""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/services")
async def get_services():
"""Get all services with their current status (one-shot REST fallback)."""
services_with_status = []
for service in SERVICES:
status = await asyncio.to_thread(
docker_monitor.get_container_status, service["container_name"]
)
services_with_status.append({**service, "status": status})
return {"services": services_with_status}
@app.get("/api/system")
async def get_system_stats():
"""Get system statistics (one-shot REST fallback)."""
cpu_percent = await asyncio.to_thread(psutil.cpu_percent, 1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage("/")
containers_total = await asyncio.to_thread(docker_monitor.get_containers_count)
containers_running = await asyncio.to_thread(docker_monitor.get_running_containers_count)
uptime_seconds = time.time() - psutil.boot_time()
return {
"cpu": {"percent": round(cpu_percent, 1)},
"memory": {
"percent": round(memory.percent, 1),
"used_gb": round(memory.used / (1024 ** 3), 2),
"total_gb": round(memory.total / (1024 ** 3), 2),
},
"disk": {
"percent": round(disk.percent, 1),
"used_gb": round(disk.used / (1024 ** 3), 2),
"total_gb": round(disk.total / (1024 ** 3), 2),
},
"containers": {
"running": containers_running,
"total": containers_total,
},
"uptime": {
"days": int(uptime_seconds / 86400),
"seconds": int(uptime_seconds),
},
}
@app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok", "message": "RedUnits Control Panel is running"}
@app.post("/api/services/{service_id}/restart")
async def restart_service(service_id: str):
"""Restart a service container."""
service = next((s for s in SERVICES if s["id"] == service_id), None)
if not service:
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found")
result = await asyncio.to_thread(
docker_monitor.restart_container, service["container_name"]
)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
@app.post("/api/services/{service_id}/stop")
async def stop_service(service_id: str):
"""Stop a service container."""
service = next((s for s in SERVICES if s["id"] == service_id), None)
if not service:
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found")
result = await asyncio.to_thread(
docker_monitor.stop_container, service["container_name"]
)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
@app.get("/api/documents")
async def get_documents():
"""Get list of available documents."""
return {"documents": load_documents()}
@app.get("/api/document/{doc_id}")
async def get_document_content(doc_id: str):
"""Fetch raw document content (acts as a proxy)."""
docs = load_documents()
doc = next((d for d in docs if d["id"] == doc_id), None)
if not doc:
raise HTTPException(status_code=404, detail=f"Document '{doc_id}' not found")
import requests
try:
resp = await asyncio.to_thread(requests.get, doc["url"], timeout=10)
resp.raise_for_status()
return {"content": resp.text}
except Exception as e:
logger.error(f"Error fetching document '{doc_id}': {e}")
raise HTTPException(status_code=500, detail=str(e))
# ──────────────────────────────────────────────
# WebSocket endpoint
# ──────────────────────────────────────────────
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
# Send initial data immediately on connect
await collect_and_broadcast()
try:
while True:
# Keep connection alive; client messages are ignored
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)