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