wrolpi/controller/main.py
Roland ccc6cf78d3
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Setting system timezone using Controller.
2026-03-13 15:56:21 -06:00

231 lines
6.8 KiB
Python

"""
WROLPi Controller - FastAPI Application
The Controller provides:
- System status monitoring (CPU, memory, drives, network)
- Service management (start/stop/restart systemd services)
- Disk mounting and management
- Admin operations (hotspot, throttle, shutdown, reboot)
- Simple emergency UI when main app is down
"""
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from controller import __version__
from fastapi.middleware.cors import CORSMiddleware
from controller.api.admin import router as admin_router
from controller.api.disks import router as disks_router
from controller.api.ready import router as ready_router
from controller.api.schemas import HealthResponse
from controller.api.scripts import router as scripts_router
from controller.api.services import router as services_router
from controller.api.status import router as status_router
from controller.lib.config import (
is_docker_mode,
is_primary_drive_mounted,
reload_config_from_drive,
)
from controller.lib.docker_services import (
can_manage_containers,
get_all_containers_status,
)
from controller.lib.status import (
get_cpu_status,
get_iostat_status,
get_load_status,
get_memory_status,
get_primary_drive_status,
get_uptime_status,
)
from controller.lib.status_worker import status_worker_loop
from controller.lib.systemd import get_all_services_status
# Templates directory
TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan handler.
Runs on startup and shutdown.
"""
# Startup
print(f"WROLPi Controller v{__version__} starting...")
print(f"Docker mode: {is_docker_mode()}")
# Try to load config from drive if mounted
if is_primary_drive_mounted():
if reload_config_from_drive():
print("Loaded configuration from drive")
else:
print("Using default configuration (no controller.yaml found)")
# Apply timezone from wrolpi.yaml to the system.
from controller.lib.admin import apply_timezone_from_config
apply_timezone_from_config()
else:
print("Primary drive not mounted - using default configuration")
# Initialize cached status
app.state.cached_status = {}
# Start status worker background task
status_task = asyncio.create_task(status_worker_loop(app))
yield
# Shutdown
print("WROLPi Controller shutting down...")
status_task.cancel()
try:
await status_task
except asyncio.CancelledError:
pass
# Create FastAPI app
app = FastAPI(
title="WROLPi Controller",
description="System management service for WROLPi",
version=__version__,
lifespan=lifespan,
)
# CORS middleware - matches Caddy's permissive policy for offline/local use.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(admin_router)
app.include_router(disks_router)
app.include_router(ready_router)
app.include_router(scripts_router)
app.include_router(services_router)
app.include_router(status_router)
def get_host(request: Request) -> str:
"""Get the host for links (use request host, fallback to localhost)."""
host = request.headers.get("host", "localhost")
# Strip port if present
if ":" in host:
host = host.split(":")[0]
return host
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Render the main dashboard UI with real status data."""
# Try to use cached status first
cached = getattr(request.app.state, "cached_status", {})
if cached:
cpu = cached.get("cpu_stats") or {}
memory = cached.get("memory_stats") or {}
load = cached.get("load_stats") or {}
iostat = cached.get("iostat_stats") or {}
uptime = cached.get("uptime_stats") or {}
drives = cached.get("drives_stats") or []
primary_drive = None
for drive in drives:
if drive.get("mount") == "/media/wrolpi":
primary_drive = drive
break
else:
# Fallback to direct calls if cache not yet populated
cpu = get_cpu_status()
memory = get_memory_status()
load = get_load_status()
iostat = get_iostat_status()
uptime = get_uptime_status()
primary_drive = get_primary_drive_status()
# Format storage data
storage = {}
if primary_drive:
free_bytes = primary_drive["size"] - primary_drive["used"]
storage = {
"percent": primary_drive["percent"],
"free_gb": round(free_bytes / (1024 ** 3), 1),
}
# Format uptime
uptime_seconds = uptime.get("uptime_seconds", 0)
if uptime_seconds:
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
uptime_formatted = f"{days}d {hours}h" if days > 0 else f"{hours}h {(uptime_seconds % 3600) // 60}m"
else:
uptime_formatted = "--"
# Get service status
if is_docker_mode():
if can_manage_containers():
services = get_all_containers_status()
else:
services = []
else:
services = get_all_services_status()
# Sort services by name for consistent display
services = sorted(services, key=lambda s: s["name"])
context = {
"version": __version__,
"docker_mode": is_docker_mode(),
"drive_mounted": is_primary_drive_mounted(),
"host": get_host(request),
# Real status data
"cpu": {
"percent": cpu["percent"],
"temperature_c": cpu["temperature"], # Field renamed
},
"memory": {
"percent": round((memory["used"] / memory["total"]) * 100, 1) if memory["total"] > 0 else 0,
"used_gb": round(memory["used"] / (1024 ** 3), 1),
},
"load": {
"load_1min": load["minute_1"], # Field renamed
},
"storage": storage,
"iostat": {
"percent_iowait": iostat.get("percent_iowait"),
},
"uptime": {
"formatted": uptime_formatted,
"seconds": uptime_seconds,
},
# Real service status
"services": services,
}
return templates.TemplateResponse(request, "index.html", context)
@app.get("/api/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""
Health check endpoint.
Returns basic status for monitoring and load balancer checks.
"""
return HealthResponse(
status="healthy",
version=__version__,
docker_mode=is_docker_mode(),
drive_mounted=is_primary_drive_mounted(),
)