Matthias Nott
2026-02-22 7d94ec0d18b46893e23680cf8438109a34cc2a10
app/routers/system.py
....@@ -6,7 +6,7 @@
66 from fastapi import APIRouter, Depends, HTTPException
77
88 from app.auth import verify_token
9
-from app.ops_runner import run_command, run_ops
9
+from app.ops_runner import run_command, run_command_host, run_ops, run_ops_host
1010
1111 router = APIRouter()
1212
....@@ -54,31 +54,19 @@
5454
5555
5656 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
57
- """Parse `systemctl list-timers` output into timer dicts."""
57
+ """Parse `systemctl list-timers` by anchoring on timestamp and .timer patterns."""
5858 timers: list[dict[str, str]] = []
59
- lines = raw.strip().splitlines()
60
- if not lines:
61
- return timers
62
-
63
- header_idx = 0
64
- for i, line in enumerate(lines):
65
- if re.match(r"(?i)next\s+left", line):
66
- header_idx = i
67
- break
68
-
69
- for line in lines[header_idx + 1:]:
70
- line = line.strip()
71
- if not line or line.startswith("timers listed") or line.startswith("To show"):
72
- continue
73
- parts = re.split(r"\s{2,}", line)
74
- if len(parts) >= 5:
75
- timers.append({
76
- "next": parts[0], "left": parts[1], "last": parts[2],
77
- "passed": parts[3], "unit": parts[4],
78
- "activates": parts[5] if len(parts) > 5 else "",
79
- })
80
- elif parts:
81
- timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
59
+ # Timestamp pattern: "Day YYYY-MM-DD HH:MM:SS TZ" or "-"
60
+ ts = r"(?:\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \w+|-)"
61
+ timer_re = re.compile(
62
+ rf"^(?P<next>{ts})\s+(?P<left>.+?)\s+"
63
+ rf"(?P<last>{ts})\s+(?P<passed>.+?)\s+"
64
+ r"(?P<unit>\S+\.timer)\s+(?P<activates>\S+)"
65
+ )
66
+ for line in raw.strip().splitlines():
67
+ m = timer_re.match(line)
68
+ if m:
69
+ timers.append({k: v.strip() for k, v in m.groupdict().items()})
8270 return timers
8371
8472
....@@ -156,8 +144,8 @@
156144 async def health_check(
157145 _: str = Depends(verify_token),
158146 ) -> dict[str, Any]:
159
- """Returns health check results via `ops health`."""
160
- result = await run_ops(["health"])
147
+ """Returns health check results via `ops health` on the host."""
148
+ result = await run_ops_host(["health"])
161149 if not result["success"] and not result["output"].strip():
162150 raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
163151 return {
....@@ -170,8 +158,8 @@
170158 async def list_timers(
171159 _: str = Depends(verify_token),
172160 ) -> dict[str, Any]:
173
- """Lists systemd timers."""
174
- result = await run_command(["systemctl", "list-timers", "--no-pager"])
161
+ """Lists systemd timers via nsenter on the host."""
162
+ result = await run_command_host(["systemctl", "list-timers", "--no-pager"])
175163 if not result["success"] and not result["output"].strip():
176164 raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
177165 return {
....@@ -185,13 +173,12 @@
185173 _: str = Depends(verify_token),
186174 ) -> dict[str, Any]:
187175 """
188
- Returns system uptime, load average, CPU usage, memory, and swap.
176
+ Returns system uptime, CPU usage, memory, and swap.
189177
190178 CPU usage is measured over a 0.5s window from /proc/stat.
191179 Memory/swap are read from /proc/meminfo.
192180 """
193181 uptime_str = ""
194
- load_str = ""
195182
196183 # Uptime
197184 try:
....@@ -201,14 +188,6 @@
201188 hours = int((seconds_up % 86400) // 3600)
202189 minutes = int((seconds_up % 3600) // 60)
203190 uptime_str = f"{days}d {hours}h {minutes}m"
204
- except Exception:
205
- pass
206
-
207
- # Load average
208
- try:
209
- with open("/proc/loadavg") as f:
210
- parts = f.read().split()
211
- load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
212191 except Exception:
213192 pass
214193
....@@ -233,21 +212,41 @@
233212 # Memory + Swap
234213 mem_info = _read_memory()
235214
236
- # Fallback for uptime/load if /proc wasn't available
237
- if not uptime_str or not load_str:
215
+ # Container count
216
+ containers_str = ""
217
+ try:
218
+ result = await run_command(["docker", "ps", "--format", "{{.State}}"])
219
+ if result["success"]:
220
+ states = [s for s in result["output"].strip().splitlines() if s]
221
+ running = sum(1 for s in states if s == "running")
222
+ containers_str = f"{running}/{len(states)}"
223
+ except Exception:
224
+ pass
225
+
226
+ # Process count
227
+ processes = 0
228
+ try:
229
+ with open("/proc/loadavg") as f:
230
+ parts = f.read().split()
231
+ if len(parts) >= 4:
232
+ # /proc/loadavg field 4 is "running/total" processes
233
+ processes = int(parts[3].split("/")[1])
234
+ except Exception:
235
+ pass
236
+
237
+ # Fallback for uptime if /proc wasn't available
238
+ if not uptime_str:
238239 result = await run_command(["uptime"])
239240 if result["success"]:
240241 raw = result["output"].strip()
241242 up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
242243 if up_match:
243
- uptime_str = uptime_str or up_match.group(1).strip()
244
- load_match = re.search(r"load average[s]?:\s*(.+)$", raw, re.IGNORECASE)
245
- if load_match:
246
- load_str = load_str or load_match.group(1).strip()
244
+ uptime_str = up_match.group(1).strip()
247245
248246 return {
249247 "uptime": uptime_str or "unavailable",
250
- "load": load_str or "unavailable",
251248 "cpu": cpu_info or None,
249
+ "containers": containers_str or "n/a",
250
+ "processes": processes or 0,
252251 **mem_info,
253252 }