Matthias Nott
2026-02-21 ed26def7d76ac011075c11e8c1679ed1f7a08abc
app/app/routers/system.py
....@@ -1,3 +1,5 @@
1
+import asyncio
2
+import os
13 import re
24 from typing import Any
35
....@@ -14,98 +16,116 @@
1416 # ---------------------------------------------------------------------------
1517
1618 def _parse_disk_output(raw: str) -> list[dict[str, str]]:
17
- """
18
- Parse df-style output into a list of filesystem dicts.
19
- Handles both the standard df header and custom ops output.
20
- """
19
+ """Parse df-style output into a list of filesystem dicts."""
2120 filesystems: list[dict[str, str]] = []
2221 lines = raw.strip().splitlines()
23
-
2422 if not lines:
2523 return filesystems
2624
27
- # Skip header line if present
2825 data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
2926
3027 for line in data_lines:
3128 parts = line.split()
3229 if len(parts) >= 5:
33
- filesystems.append(
34
- {
35
- "filesystem": parts[0],
36
- "size": parts[1],
37
- "used": parts[2],
38
- "available": parts[3],
39
- "use_percent": parts[4],
40
- "mount": parts[5] if len(parts) > 5 else "",
41
- }
42
- )
43
-
30
+ filesystems.append({
31
+ "filesystem": parts[0],
32
+ "size": parts[1],
33
+ "used": parts[2],
34
+ "available": parts[3],
35
+ "use_percent": parts[4],
36
+ "mount": parts[5] if len(parts) > 5 else "",
37
+ })
4438 return filesystems
4539
4640
4741 def _parse_health_output(raw: str) -> list[dict[str, str]]:
48
- """
49
- Parse health check output into a list of check result dicts.
50
- Expects lines like: "[OK] Database connection" or "[FAIL] Disk space"
51
- Also handles plain text lines.
52
- """
42
+ """Parse health check output into check result dicts."""
5343 checks: list[dict[str, str]] = []
5444 for line in raw.strip().splitlines():
5545 line = line.strip()
5646 if not line:
5747 continue
58
-
5948 match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
6049 if match:
6150 checks.append({"status": match.group(1), "check": match.group(2)})
6251 else:
63
- # Treat unstructured lines as informational
6452 checks.append({"status": "INFO", "check": line})
65
-
6653 return checks
6754
6855
6956 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
70
- """
71
- Parse `systemctl list-timers` output into structured timer dicts.
72
- Header: NEXT LEFT LAST PASSED UNIT ACTIVATES
73
- """
57
+ """Parse `systemctl list-timers` output into timer dicts."""
7458 timers: list[dict[str, str]] = []
7559 lines = raw.strip().splitlines()
76
-
7760 if not lines:
7861 return timers
7962
80
- # Skip header
8163 header_idx = 0
8264 for i, line in enumerate(lines):
8365 if re.match(r"(?i)next\s+left", line):
8466 header_idx = i
8567 break
8668
87
- for line in lines[header_idx + 1 :]:
69
+ for line in lines[header_idx + 1:]:
8870 line = line.strip()
8971 if not line or line.startswith("timers listed") or line.startswith("To show"):
9072 continue
91
-
92
- # systemctl list-timers columns are variable-width; split carefully
9373 parts = re.split(r"\s{2,}", line)
9474 if len(parts) >= 5:
95
- timers.append(
96
- {
97
- "next": parts[0],
98
- "left": parts[1],
99
- "last": parts[2],
100
- "passed": parts[3],
101
- "unit": parts[4],
102
- "activates": parts[5] if len(parts) > 5 else "",
103
- }
104
- )
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
+ })
10580 elif parts:
10681 timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
107
-
10882 return timers
83
+
84
+
85
+def _read_memory() -> dict[str, Any]:
86
+ """Read memory and swap from /proc/meminfo."""
87
+ info: dict[str, int] = {}
88
+ try:
89
+ with open("/proc/meminfo") as f:
90
+ for line in f:
91
+ parts = line.split()
92
+ if len(parts) >= 2:
93
+ key = parts[0].rstrip(":")
94
+ info[key] = int(parts[1]) * 1024 # kB → bytes
95
+ except Exception:
96
+ return {}
97
+
98
+ mem_total = info.get("MemTotal", 0)
99
+ mem_available = info.get("MemAvailable", 0)
100
+ mem_used = mem_total - mem_available
101
+ swap_total = info.get("SwapTotal", 0)
102
+ swap_free = info.get("SwapFree", 0)
103
+ swap_used = swap_total - swap_free
104
+
105
+ return {
106
+ "memory": {
107
+ "total": mem_total,
108
+ "used": mem_used,
109
+ "available": mem_available,
110
+ "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0,
111
+ },
112
+ "swap": {
113
+ "total": swap_total,
114
+ "used": swap_used,
115
+ "free": swap_free,
116
+ "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0,
117
+ },
118
+ }
119
+
120
+
121
+def _read_cpu_stat() -> tuple[int, int]:
122
+ """Read idle and total jiffies from /proc/stat."""
123
+ with open("/proc/stat") as f:
124
+ line = f.readline()
125
+ parts = line.split()
126
+ values = [int(x) for x in parts[1:]]
127
+ idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait
128
+ return idle, sum(values)
109129
110130
111131 # ---------------------------------------------------------------------------
....@@ -116,21 +136,15 @@
116136 async def disk_usage(
117137 _: str = Depends(verify_token),
118138 ) -> dict[str, Any]:
119
- """
120
- Returns disk usage via `ops disk` (or falls back to `df -h`).
121
- """
139
+ """Returns disk usage via `ops disk` (fallback: `df -h`)."""
122140 result = await run_ops(["disk"])
123141 raw = result["output"]
124142
125143 if not result["success"] or not raw.strip():
126
- # Fallback to df
127144 fallback = await run_command(["df", "-h"])
128145 raw = fallback["output"]
129146 if not fallback["success"]:
130
- raise HTTPException(
131
- status_code=500,
132
- detail=f"Failed to get disk usage: {result['error']}",
133
- )
147
+ raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
134148
135149 return {
136150 "filesystems": _parse_disk_output(raw),
....@@ -142,20 +156,13 @@
142156 async def health_check(
143157 _: str = Depends(verify_token),
144158 ) -> dict[str, Any]:
145
- """
146
- Returns health check results via `ops health`.
147
- """
159
+ """Returns health check results via `ops health`."""
148160 result = await run_ops(["health"])
149161 if not result["success"] and not result["output"].strip():
150
- raise HTTPException(
151
- status_code=500,
152
- detail=f"Failed to run health checks: {result['error']}",
153
- )
154
-
155
- raw = result["output"]
162
+ raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
156163 return {
157
- "checks": _parse_health_output(raw),
158
- "raw": raw,
164
+ "checks": _parse_health_output(result["output"]),
165
+ "raw": result["output"],
159166 }
160167
161168
....@@ -163,35 +170,30 @@
163170 async def list_timers(
164171 _: str = Depends(verify_token),
165172 ) -> dict[str, Any]:
166
- """
167
- Lists systemd timers via `systemctl list-timers --no-pager`.
168
- """
173
+ """Lists systemd timers."""
169174 result = await run_command(["systemctl", "list-timers", "--no-pager"])
170175 if not result["success"] and not result["output"].strip():
171
- raise HTTPException(
172
- status_code=500,
173
- detail=f"Failed to list timers: {result['error']}",
174
- )
175
-
176
- raw = result["output"]
176
+ raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
177177 return {
178
- "timers": _parse_timers_output(raw),
179
- "raw": raw,
178
+ "timers": _parse_timers_output(result["output"]),
179
+ "raw": result["output"],
180180 }
181181
182182
183
-@router.get("/info", summary="Basic system information")
183
+@router.get("/info", summary="System information with CPU/memory")
184184 async def system_info(
185185 _: str = Depends(verify_token),
186186 ) -> dict[str, Any]:
187187 """
188
- Returns system uptime and load average.
189
- Reads from /proc/uptime and /proc/loadavg; falls back to `uptime` command.
188
+ Returns system uptime, load average, CPU usage, memory, and swap.
189
+
190
+ CPU usage is measured over a 0.5s window from /proc/stat.
191
+ Memory/swap are read from /proc/meminfo.
190192 """
191193 uptime_str = ""
192194 load_str = ""
193195
194
- # Try /proc first (Linux)
196
+ # Uptime
195197 try:
196198 with open("/proc/uptime") as f:
197199 seconds_up = float(f.read().split()[0])
....@@ -199,22 +201,43 @@
199201 hours = int((seconds_up % 86400) // 3600)
200202 minutes = int((seconds_up % 3600) // 60)
201203 uptime_str = f"{days}d {hours}h {minutes}m"
202
- except (FileNotFoundError, ValueError):
204
+ except Exception:
203205 pass
204206
207
+ # Load average
205208 try:
206209 with open("/proc/loadavg") as f:
207210 parts = f.read().split()
208211 load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
209
- except (FileNotFoundError, ValueError):
212
+ except Exception:
210213 pass
211214
212
- # Fallback: use `uptime` command (works on macOS too)
215
+ # CPU usage (two samples, 0.5s apart)
216
+ cpu_info: dict[str, Any] = {}
217
+ try:
218
+ idle1, total1 = _read_cpu_stat()
219
+ await asyncio.sleep(0.5)
220
+ idle2, total2 = _read_cpu_stat()
221
+ total_delta = total2 - total1
222
+ if total_delta > 0:
223
+ usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1)
224
+ else:
225
+ usage = 0.0
226
+ cpu_info = {
227
+ "usage_percent": usage,
228
+ "cores": os.cpu_count() or 1,
229
+ }
230
+ except Exception:
231
+ pass
232
+
233
+ # Memory + Swap
234
+ mem_info = _read_memory()
235
+
236
+ # Fallback for uptime/load if /proc wasn't available
213237 if not uptime_str or not load_str:
214238 result = await run_command(["uptime"])
215239 if result["success"]:
216240 raw = result["output"].strip()
217
- # Parse "up X days, Y:Z, N users, load average: a, b, c"
218241 up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
219242 if up_match:
220243 uptime_str = uptime_str or up_match.group(1).strip()
....@@ -225,4 +248,6 @@
225248 return {
226249 "uptime": uptime_str or "unavailable",
227250 "load": load_str or "unavailable",
251
+ "cpu": cpu_info or None,
252
+ **mem_info,
228253 }