from typing import Annotated, Any from fastapi import APIRouter, Form from fastapi.responses import HTMLResponse from app import journal from app.render import render from app.systemd import units router = APIRouter(prefix="/api/services", tags=["services"]) SORT_KEYS = ("name", "state", "enabled") _STATE_RANK = { "active": 0, "activating": 1, "deactivating": 1, "reloading": 1, "reactivating": 1, "failed": 2, "inactive": 3, "dead": 3, "maintenance": 3, } _ENABLED_RANK = { "enabled": 0, "indirect": 1, "static": 2, "disabled": 3, "alias": 4, "linked": 4, "linked-runtime": 4, "masked": 5, "": 6, } def _rank(u: dict[str, Any], key: str) -> int: """Sort rank of a unit row for the state/enabled columns. Unknown states rank last (9); "name" sorting uses the raw string and returns 0 here. Args: u: unit row from units.unit_list(). key: "state" or "enabled". Returns: An integer rank, lower first. """ if key == "state": return _STATE_RANK.get(u["active"], 9) if key == "enabled": return _ENABLED_RANK.get(u["enabled"], 9) return 0 async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error: str | None = None) -> str: """Render the services list fragment (shared by GET and POST endpoints). Filters by substring match on unit name or description, sorts by name or by state/enabled rank (with the unit name as tiebreaker), and renders services.html including the overall system state. Args: q: search filter, empty for all. sort: one of SORT_KEYS. order: "asc" or "desc". error: error message to show in the fragment, if any. Returns: The rendered services.html fragment. """ if sort not in SORT_KEYS: sort = "name" if order not in ("asc", "desc"): order = "asc" unit_list = await units.unit_list() if q: ql = q.lower() unit_list = [ u for u in unit_list if ql in u["name"].lower() or ql in u["desc"].lower() ] reverse = order == "desc" if sort == "name": unit_list.sort(key=lambda u: u["name"], reverse=reverse) else: unit_list.sort(key=lambda u: (_rank(u, sort), u["name"]), reverse=reverse) state = await units.system_state() return render( "services.html", units=unit_list, state=state, q=q, sort=sort, order=order, error=error, ) @router.get("") async def services(q: str = "", sort: str = "name", order: str = "asc"): """Render the Services tab fragment. Args: q: search filter, empty for all. sort: one of SORT_KEYS. order: "asc" or "desc". Returns: The services list as an HTMLResponse. """ return HTMLResponse(await _list_fragment(q, sort, order)) @router.get("/{unit}/detail") async def service_detail(unit: str): """Render the detail fragment for one service. Shows the unit's properties (via units.unit_detail) plus its 15 most recent journal lines. A detail error suppresses the journal fetch and is rendered as a banner. Args: unit: unit name, e.g. "sshd.service". Returns: The rendered service_detail.html as an HTMLResponse. """ error = None props: dict[str, str] = {} log: list[dict[str, str]] = [] try: props = await units.unit_detail(unit) except (ValueError, RuntimeError) as e: error = str(e)[:300] if not error: try: log, _ = await journal.tail(None, "all", unit, None, 15) except (RuntimeError, OSError): pass return HTMLResponse(render("service_detail.html", unit=unit, props=props, log=log, error=error)) @router.post("/{unit}/action") async def service_action( unit: str, action: Annotated[str, Form()], q: Annotated[str, Form()] = "", sort: Annotated[str, Form()] = "name", order: Annotated[str, Form()] = "asc", ): """Perform a start/stop/restart/enable/disable on a unit and re-render the list. The form carries the current q/sort/order so the htmx swap shows the updated list with the same view. Errors from unit_action are rendered in the fragment instead of raising. Args: unit: unit name. action: one of units.ACTIONS. q: search filter to keep. sort: column to sort by. order: "asc" or "desc". Returns: The re-rendered services list as an HTMLResponse. """ error = None try: _ = await units.unit_action(unit, action) except ValueError as e: error = str(e) except RuntimeError as e: error = str(e)[:300] return HTMLResponse(await _list_fragment(q, sort, order, error=error))