Initial commit

This commit is contained in:
Sundog Garage Studio
2026-03-17 16:17:53 +02:00
commit 411038582a
15 changed files with 1895 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""RedUnits Control Panel - Service Management Dashboard"""
__version__ = "1.0.0"
+146
View File
@@ -0,0 +1,146 @@
import docker
from typing import Dict, Optional
import logging
logger = logging.getLogger(__name__)
class DockerMonitor:
"""Monitor and manage Docker containers"""
def __init__(self):
try:
self.client = docker.from_env()
self.client.ping()
self.available = True
logger.info("Docker client initialized successfully")
except Exception as e:
logger.warning(f"Docker not available: {e}")
self.client = None
self.available = False
def get_container_status(self, container_name: str) -> Dict:
"""
Get status of a specific container.
Returns:
Dict with: online, state, uptime, cpu_percent, memory_mb
"""
if not self.available:
return {"online": False, "state": "unavailable", "message": "Docker not available"}
try:
container = self.client.containers.get(container_name)
stats = container.stats(stream=False)
# Calculate CPU percentage
cpu_delta = (
stats["cpu_stats"]["cpu_usage"]["total_usage"]
- stats["precpu_stats"]["cpu_usage"]["total_usage"]
)
system_delta = (
stats["cpu_stats"]["system_cpu_usage"]
- stats["precpu_stats"]["system_cpu_usage"]
)
cpu_percent = 0.0
if system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * 100.0
# Calculate memory usage
memory_usage = stats["memory_stats"].get("usage", 0)
memory_mb = memory_usage / (1024 * 1024)
return {
"online": container.status == "running",
"state": container.status,
"uptime": self._format_uptime(container),
"cpu_percent": round(cpu_percent, 2),
"memory_mb": round(memory_mb, 2),
}
except docker.errors.NotFound:
return {
"online": False,
"state": "not_found",
"message": f"Container '{container_name}' not found",
}
except Exception as e:
logger.error(f"Error getting container status: {e}")
return {"online": False, "state": "error", "message": str(e)}
def restart_container(self, container_name: str) -> Dict:
"""Restart a specific container."""
if not self.available:
return {"success": False, "message": "Docker not available"}
try:
container = self.client.containers.get(container_name)
container.restart(timeout=10)
logger.info(f"Container '{container_name}' restarted")
return {"success": True, "message": f"Container '{container_name}' restarted"}
except docker.errors.NotFound:
return {"success": False, "message": f"Container '{container_name}' not found"}
except Exception as e:
logger.error(f"Error restarting container '{container_name}': {e}")
return {"success": False, "message": str(e)}
def stop_container(self, container_name: str) -> Dict:
"""Stop a specific container."""
if not self.available:
return {"success": False, "message": "Docker not available"}
try:
container = self.client.containers.get(container_name)
container.stop(timeout=10)
logger.info(f"Container '{container_name}' stopped")
return {"success": True, "message": f"Container '{container_name}' stopped"}
except docker.errors.NotFound:
return {"success": False, "message": f"Container '{container_name}' not found"}
except Exception as e:
logger.error(f"Error stopping container '{container_name}': {e}")
return {"success": False, "message": str(e)}
def get_containers_count(self) -> int:
"""Get total number of containers."""
if not self.available:
return 0
try:
return len(self.client.containers.list(all=True))
except Exception as e:
logger.error(f"Error counting containers: {e}")
return 0
def get_running_containers_count(self) -> int:
"""Get number of running containers."""
if not self.available:
return 0
try:
return len(self.client.containers.list())
except Exception as e:
logger.error(f"Error counting running containers: {e}")
return 0
def _format_uptime(self, container) -> str:
"""Format container uptime."""
try:
from datetime import datetime, timezone
started_at = container.attrs["State"]["StartedAt"]
started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
uptime = now - started
days = uptime.days
hours = uptime.seconds // 3600
minutes = (uptime.seconds % 3600) // 60
if days > 0:
return f"{days}d {hours}h"
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
except Exception as e:
logger.error(f"Error formatting uptime: {e}")
return "unknown"
+298
View File
@@ -0,0 +1,298 @@
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)