From 7d94ec0d18b46893e23680cf8438109a34cc2a10 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 22 Feb 2026 16:55:03 +0100
Subject: [PATCH] feat: promote/sync/rebuild UI, operations page, bidirectional sync, lifecycle ops
---
app/routers/system.py | 91 ++++++++++++++++++++++-----------------------
1 files changed, 45 insertions(+), 46 deletions(-)
diff --git a/app/routers/system.py b/app/routers/system.py
index a9f15ce..27a8e16 100644
--- a/app/routers/system.py
+++ b/app/routers/system.py
@@ -6,7 +6,7 @@
from fastapi import APIRouter, Depends, HTTPException
from app.auth import verify_token
-from app.ops_runner import run_command, run_ops
+from app.ops_runner import run_command, run_command_host, run_ops, run_ops_host
router = APIRouter()
@@ -54,31 +54,19 @@
def _parse_timers_output(raw: str) -> list[dict[str, str]]:
- """Parse `systemctl list-timers` output into timer dicts."""
+ """Parse `systemctl list-timers` by anchoring on timestamp and .timer patterns."""
timers: list[dict[str, str]] = []
- lines = raw.strip().splitlines()
- if not lines:
- return timers
-
- header_idx = 0
- for i, line in enumerate(lines):
- if re.match(r"(?i)next\s+left", line):
- header_idx = i
- break
-
- for line in lines[header_idx + 1:]:
- line = line.strip()
- if not line or line.startswith("timers listed") or line.startswith("To show"):
- continue
- parts = re.split(r"\s{2,}", line)
- if len(parts) >= 5:
- timers.append({
- "next": parts[0], "left": parts[1], "last": parts[2],
- "passed": parts[3], "unit": parts[4],
- "activates": parts[5] if len(parts) > 5 else "",
- })
- elif parts:
- timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
+ # Timestamp pattern: "Day YYYY-MM-DD HH:MM:SS TZ" or "-"
+ ts = r"(?:\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \w+|-)"
+ timer_re = re.compile(
+ rf"^(?P<next>{ts})\s+(?P<left>.+?)\s+"
+ rf"(?P<last>{ts})\s+(?P<passed>.+?)\s+"
+ r"(?P<unit>\S+\.timer)\s+(?P<activates>\S+)"
+ )
+ for line in raw.strip().splitlines():
+ m = timer_re.match(line)
+ if m:
+ timers.append({k: v.strip() for k, v in m.groupdict().items()})
return timers
@@ -156,8 +144,8 @@
async def health_check(
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """Returns health check results via `ops health`."""
- result = await run_ops(["health"])
+ """Returns health check results via `ops health` on the host."""
+ result = await run_ops_host(["health"])
if not result["success"] and not result["output"].strip():
raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
return {
@@ -170,8 +158,8 @@
async def list_timers(
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """Lists systemd timers."""
- result = await run_command(["systemctl", "list-timers", "--no-pager"])
+ """Lists systemd timers via nsenter on the host."""
+ result = await run_command_host(["systemctl", "list-timers", "--no-pager"])
if not result["success"] and not result["output"].strip():
raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
return {
@@ -185,13 +173,12 @@
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""
- Returns system uptime, load average, CPU usage, memory, and swap.
+ Returns system uptime, CPU usage, memory, and swap.
CPU usage is measured over a 0.5s window from /proc/stat.
Memory/swap are read from /proc/meminfo.
"""
uptime_str = ""
- load_str = ""
# Uptime
try:
@@ -201,14 +188,6 @@
hours = int((seconds_up % 86400) // 3600)
minutes = int((seconds_up % 3600) // 60)
uptime_str = f"{days}d {hours}h {minutes}m"
- except Exception:
- pass
-
- # Load average
- try:
- with open("/proc/loadavg") as f:
- parts = f.read().split()
- load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
except Exception:
pass
@@ -233,21 +212,41 @@
# Memory + Swap
mem_info = _read_memory()
- # Fallback for uptime/load if /proc wasn't available
- if not uptime_str or not load_str:
+ # Container count
+ containers_str = ""
+ try:
+ result = await run_command(["docker", "ps", "--format", "{{.State}}"])
+ if result["success"]:
+ states = [s for s in result["output"].strip().splitlines() if s]
+ running = sum(1 for s in states if s == "running")
+ containers_str = f"{running}/{len(states)}"
+ except Exception:
+ pass
+
+ # Process count
+ processes = 0
+ try:
+ with open("/proc/loadavg") as f:
+ parts = f.read().split()
+ if len(parts) >= 4:
+ # /proc/loadavg field 4 is "running/total" processes
+ processes = int(parts[3].split("/")[1])
+ except Exception:
+ pass
+
+ # Fallback for uptime if /proc wasn't available
+ if not uptime_str:
result = await run_command(["uptime"])
if result["success"]:
raw = result["output"].strip()
up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
if up_match:
- uptime_str = uptime_str or up_match.group(1).strip()
- load_match = re.search(r"load average[s]?:\s*(.+)$", raw, re.IGNORECASE)
- if load_match:
- load_str = load_str or load_match.group(1).strip()
+ uptime_str = up_match.group(1).strip()
return {
"uptime": uptime_str or "unavailable",
- "load": load_str or "unavailable",
"cpu": cpu_info or None,
+ "containers": containers_str or "n/a",
+ "processes": processes or 0,
**mem_info,
}
--
Gitblit v1.3.1