atlus/backend/routers/services.py
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

131 lines
4.1 KiB
Python

"""Systemd service management wrapper."""
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth import get_current_user, ws_authenticate
router = APIRouter(prefix="/api/services", tags=["services"])
log = logging.getLogger("atlus.services")
class ServiceAction(BaseModel):
unit: str
action: str # start, stop, restart, enable, disable
async def _run(cmd: list[str]) -> tuple[int, str, str]:
import shutil
if not shutil.which(cmd[0]):
return 1, "", f"{cmd[0]}: command not found"
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
async def _get_services() -> list[dict]:
"""List all loaded systemd services."""
rc, out, _ = await _run([
"systemctl", "list-units", "--type=service", "--all",
"--no-pager", "--no-legend", "--plain",
])
services = []
for line in out.strip().splitlines():
parts = line.split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
services.append({
"unit": unit,
"name": unit.removesuffix(".service"),
"load": parts[1],
"active": parts[2],
"sub": parts[3],
"description": parts[4] if len(parts) > 4 else "",
})
return services
async def _get_service_status(unit: str) -> dict:
"""Get detailed status for a single service."""
rc, out, _ = await _run(["systemctl", "show", unit, "--no-pager"])
props = {}
for line in out.strip().splitlines():
if "=" in line:
k, _, v = line.partition("=")
props[k] = v
return {
"unit": unit,
"name": unit.removesuffix(".service"),
"active": props.get("ActiveState", "unknown"),
"sub": props.get("SubState", "unknown"),
"enabled": props.get("UnitFileState", "unknown"),
"description": props.get("Description", ""),
"main_pid": int(props.get("MainPID", 0)),
"memory": props.get("MemoryCurrent", "0"),
"started": props.get("ActiveEnterTimestamp", ""),
}
@router.get("")
async def list_services(_user: str = Depends(get_current_user)):
return await _get_services()
@router.get("/{unit}")
async def service_status(unit: str, _user: str = Depends(get_current_user)):
return await _get_service_status(unit)
@router.post("/action")
async def service_action(req: ServiceAction, _user: str = Depends(get_current_user)):
if req.action not in ("start", "stop", "restart", "enable", "disable"):
raise HTTPException(400, f"Invalid action: {req.action}")
rc, out, err = await _run(["systemctl", req.action, req.unit])
if rc != 0:
raise HTTPException(500, f"systemctl {req.action} {req.unit} failed: {err}")
return {
"unit": req.unit,
"action": req.action,
"status": await _get_service_status(req.unit),
}
@router.websocket("/{unit}/logs")
async def service_logs(websocket: WebSocket, unit: str):
"""Stream journalctl -f output for a service."""
username = await ws_authenticate(websocket)
await websocket.accept()
proc = await asyncio.create_subprocess_exec(
"journalctl", "-u", unit, "-f", "-n", "100", "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
while True:
line = await proc.stdout.readline()
if not line:
break
await websocket.send_json({
"type": "log",
"unit": unit,
"line": line.decode(errors="replace").rstrip(),
})
except WebSocketDisconnect:
pass
except Exception:
log.exception("log stream error for %s", unit)
finally:
proc.kill()
await proc.wait()