| .. | .. |
|---|
| 6 | 6 | from fastapi import APIRouter, Depends, HTTPException |
|---|
| 7 | 7 | |
|---|
| 8 | 8 | 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 |
|---|
| 10 | 10 | |
|---|
| 11 | 11 | router = APIRouter() |
|---|
| 12 | 12 | |
|---|
| .. | .. |
|---|
| 54 | 54 | |
|---|
| 55 | 55 | |
|---|
| 56 | 56 | 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.""" |
|---|
| 58 | 58 | 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()}) |
|---|
| 82 | 70 | return timers |
|---|
| 83 | 71 | |
|---|
| 84 | 72 | |
|---|
| .. | .. |
|---|
| 156 | 144 | async def health_check( |
|---|
| 157 | 145 | _: str = Depends(verify_token), |
|---|
| 158 | 146 | ) -> 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"]) |
|---|
| 161 | 149 | if not result["success"] and not result["output"].strip(): |
|---|
| 162 | 150 | raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}") |
|---|
| 163 | 151 | return { |
|---|
| .. | .. |
|---|
| 170 | 158 | async def list_timers( |
|---|
| 171 | 159 | _: str = Depends(verify_token), |
|---|
| 172 | 160 | ) -> 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"]) |
|---|
| 175 | 163 | if not result["success"] and not result["output"].strip(): |
|---|
| 176 | 164 | raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}") |
|---|
| 177 | 165 | return { |
|---|
| .. | .. |
|---|
| 185 | 173 | _: str = Depends(verify_token), |
|---|
| 186 | 174 | ) -> dict[str, Any]: |
|---|
| 187 | 175 | """ |
|---|
| 188 | | - Returns system uptime, load average, CPU usage, memory, and swap. |
|---|
| 176 | + Returns system uptime, CPU usage, memory, and swap. |
|---|
| 189 | 177 | |
|---|
| 190 | 178 | CPU usage is measured over a 0.5s window from /proc/stat. |
|---|
| 191 | 179 | Memory/swap are read from /proc/meminfo. |
|---|
| 192 | 180 | """ |
|---|
| 193 | 181 | uptime_str = "" |
|---|
| 194 | | - load_str = "" |
|---|
| 195 | 182 | |
|---|
| 196 | 183 | # Uptime |
|---|
| 197 | 184 | try: |
|---|
| .. | .. |
|---|
| 201 | 188 | hours = int((seconds_up % 86400) // 3600) |
|---|
| 202 | 189 | minutes = int((seconds_up % 3600) // 60) |
|---|
| 203 | 190 | 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]}" |
|---|
| 212 | 191 | except Exception: |
|---|
| 213 | 192 | pass |
|---|
| 214 | 193 | |
|---|
| .. | .. |
|---|
| 233 | 212 | # Memory + Swap |
|---|
| 234 | 213 | mem_info = _read_memory() |
|---|
| 235 | 214 | |
|---|
| 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: |
|---|
| 238 | 239 | result = await run_command(["uptime"]) |
|---|
| 239 | 240 | if result["success"]: |
|---|
| 240 | 241 | raw = result["output"].strip() |
|---|
| 241 | 242 | up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw) |
|---|
| 242 | 243 | 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() |
|---|
| 247 | 245 | |
|---|
| 248 | 246 | return { |
|---|
| 249 | 247 | "uptime": uptime_str or "unavailable", |
|---|
| 250 | | - "load": load_str or "unavailable", |
|---|
| 251 | 248 | "cpu": cpu_info or None, |
|---|
| 249 | + "containers": containers_str or "n/a", |
|---|
| 250 | + "processes": processes or 0, |
|---|
| 252 | 251 | **mem_info, |
|---|
| 253 | 252 | } |
|---|