- Add shutil.which guard to _run() in settings, asi_bridge routers - Catch RuntimeError on WebSocket disconnect in services, asi_bridge - Make file listing resilient to individual entry errors - Fix keyboard double-fire on touch devices (touchstart + click) - Update install.sh with correct Gitea repo URL - Add six to requirements.txt (python-pam dependency) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
131 lines
4.1 KiB
Python
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, RuntimeError):
|
|
pass
|
|
except Exception:
|
|
log.exception("log stream error for %s", unit)
|
|
finally:
|
|
proc.kill()
|
|
await proc.wait()
|