dashboard/app/routers/services.py

175 lines
4.7 KiB
Python

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))