Matthias Nott
2026-02-21 dddc7c245462846e1e09d6cfb2102934aa1f4e8e
refactor: remove duplicate app/app/ phantom directory
10 files deleted
changed files
app/app/__init__.py patch | view | blame | history
app/app/auth.py patch | view | blame | history
app/app/main.py patch | view | blame | history
app/app/ops_runner.py patch | view | blame | history
app/app/routers/__init__.py patch | view | blame | history
app/app/routers/backups.py patch | view | blame | history
app/app/routers/restore.py patch | view | blame | history
app/app/routers/services.py patch | view | blame | history
app/app/routers/status.py patch | view | blame | history
app/app/routers/system.py patch | view | blame | history
app/app/__init__.py
deleted file mode 100644
app/app/auth.py
deleted file mode 100644
....@@ -1,33 +0,0 @@
1
-import os
2
-from fastapi import HTTPException, Security, Query
3
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
4
-from typing import Optional
5
-
6
-_AUTH_TOKEN = os.environ.get("AUTH_TOKEN", "changeme")
7
-
8
-_bearer_scheme = HTTPBearer(auto_error=False)
9
-
10
-
11
-async def verify_token(
12
- credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer_scheme),
13
- token: Optional[str] = Query(default=None),
14
-) -> str:
15
- """
16
- Verify the bearer token from Authorization header or ?token= query param.
17
- Raises 401 if missing or invalid.
18
- """
19
- provided: Optional[str] = None
20
-
21
- if credentials is not None:
22
- provided = credentials.credentials
23
- elif token is not None:
24
- provided = token
25
-
26
- if provided is None or provided != _AUTH_TOKEN:
27
- raise HTTPException(
28
- status_code=401,
29
- detail="Invalid or missing authentication token",
30
- headers={"WWW-Authenticate": "Bearer"},
31
- )
32
-
33
- return provided
app/app/main.py
deleted file mode 100644
....@@ -1,60 +0,0 @@
1
-import logging
2
-from contextlib import asynccontextmanager
3
-from pathlib import Path
4
-
5
-from fastapi import FastAPI
6
-from fastapi.middleware.cors import CORSMiddleware
7
-from fastapi.staticfiles import StaticFiles
8
-
9
-from app.routers import backups, restore, services, status, system
10
-
11
-logging.basicConfig(
12
- level=logging.INFO,
13
- format="%(asctime)s %(levelname)s %(name)s: %(message)s",
14
-)
15
-logger = logging.getLogger(__name__)
16
-
17
-_STATIC_DIR = Path(__file__).parent.parent / "static"
18
-
19
-
20
-@asynccontextmanager
21
-async def lifespan(app: FastAPI):
22
- logger.info("Ops WebUI server is running")
23
- yield
24
-
25
-
26
-app = FastAPI(
27
- title="Ops WebUI API",
28
- description="Backend API for the ops web dashboard",
29
- version="1.0.0",
30
- lifespan=lifespan,
31
-)
32
-
33
-# ---------------------------------------------------------------------------
34
-# CORS – open for development; restrict in production via env/reverse proxy
35
-# ---------------------------------------------------------------------------
36
-app.add_middleware(
37
- CORSMiddleware,
38
- allow_origins=["*"],
39
- allow_credentials=True,
40
- allow_methods=["*"],
41
- allow_headers=["*"],
42
-)
43
-
44
-# ---------------------------------------------------------------------------
45
-# API routers
46
-# ---------------------------------------------------------------------------
47
-app.include_router(status.router, prefix="/api/status", tags=["status"])
48
-app.include_router(backups.router, prefix="/api/backups", tags=["backups"])
49
-app.include_router(restore.router, prefix="/api/restore", tags=["restore"])
50
-app.include_router(services.router, prefix="/api/services", tags=["services"])
51
-app.include_router(system.router, prefix="/api/system", tags=["system"])
52
-
53
-# ---------------------------------------------------------------------------
54
-# Static files – serve the frontend SPA at /
55
-# Mount last so API routes take precedence
56
-# ---------------------------------------------------------------------------
57
-if _STATIC_DIR.exists():
58
- app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
59
-else:
60
- logger.warning("Static directory not found at %s – frontend will not be served", _STATIC_DIR)
app/app/ops_runner.py
deleted file mode 100644
....@@ -1,175 +0,0 @@
1
-import asyncio
2
-import json
3
-import os
4
-from typing import AsyncGenerator
5
-
6
-OPS_CLI = os.environ.get("OPS_CLI", "/opt/infrastructure/ops")
7
-OFFSITE_PYTHON = os.environ.get("OFFSITE_PYTHON", "/opt/data/π/bin/python3")
8
-OFFSITE_SCRIPT = os.environ.get("OFFSITE_SCRIPT", "/opt/data/scripts/offsite.py")
9
-
10
-_DEFAULT_TIMEOUT = 300
11
-_BACKUP_TIMEOUT = 3600
12
-
13
-# nsenter via Docker: run commands on the host from inside the container.
14
-# Required because ops backup/restore delegate to host Python venvs (3.12)
15
-# that are incompatible with the container's Python (3.11).
16
-_NSENTER_PREFIX = [
17
- "docker", "run", "--rm", "-i",
18
- "--privileged", "--pid=host", "--network=host",
19
- "alpine",
20
- "nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--",
21
-]
22
-
23
-
24
-# ---------------------------------------------------------------------------
25
-# In-container execution (status, disk, health, docker commands)
26
-# ---------------------------------------------------------------------------
27
-
28
-async def run_ops(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
29
- """Run the ops CLI inside the container."""
30
- return await _run_exec([OPS_CLI] + args, timeout=timeout)
31
-
32
-
33
-async def run_ops_json(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
34
- """Run the ops CLI with --json and return parsed JSON."""
35
- result = await run_ops(args + ["--json"], timeout=timeout)
36
- if not result["success"]:
37
- return {"success": False, "data": None, "error": result["error"] or result["output"]}
38
- try:
39
- data = json.loads(result["output"])
40
- return {"success": True, "data": data, "error": ""}
41
- except json.JSONDecodeError as exc:
42
- return {
43
- "success": False,
44
- "data": None,
45
- "error": f"Failed to parse JSON: {exc}\nRaw: {result['output'][:500]}",
46
- }
47
-
48
-
49
-async def stream_ops(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
50
- """Stream ops CLI output (in-container)."""
51
- async for line in _stream_exec([OPS_CLI] + args, timeout=timeout):
52
- yield line
53
-
54
-
55
-async def run_command(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
56
- """Generic command runner (in-container)."""
57
- return await _run_exec(args, timeout=timeout)
58
-
59
-
60
-async def stream_command(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
61
- """Stream generic command output (in-container)."""
62
- async for line in _stream_exec(args, timeout=timeout):
63
- yield line
64
-
65
-
66
-# ---------------------------------------------------------------------------
67
-# Host execution (backup, restore — needs host Python venvs)
68
-# ---------------------------------------------------------------------------
69
-
70
-async def run_ops_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
71
- """Run the ops CLI on the host via nsenter."""
72
- return await _run_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout)
73
-
74
-
75
-async def stream_ops_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
76
- """Stream ops CLI output from the host via nsenter."""
77
- async for line in _stream_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout):
78
- yield line
79
-
80
-
81
-# ---------------------------------------------------------------------------
82
-# Internal helpers
83
-# ---------------------------------------------------------------------------
84
-
85
-async def _run_exec(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
86
- """Execute a command and capture output."""
87
- try:
88
- proc = await asyncio.create_subprocess_exec(
89
- *args,
90
- stdout=asyncio.subprocess.PIPE,
91
- stderr=asyncio.subprocess.PIPE,
92
- )
93
- try:
94
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
95
- except asyncio.TimeoutError:
96
- proc.kill()
97
- await proc.communicate()
98
- return {"success": False, "output": "", "error": f"Command timed out after {timeout}s"}
99
-
100
- return {
101
- "success": proc.returncode == 0,
102
- "output": stdout.decode("utf-8", errors="replace"),
103
- "error": stderr.decode("utf-8", errors="replace"),
104
- }
105
- except FileNotFoundError as exc:
106
- return {"success": False, "output": "", "error": f"Executable not found: {exc}"}
107
- except Exception as exc:
108
- return {"success": False, "output": "", "error": str(exc)}
109
-
110
-
111
-async def _stream_exec(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
112
- """Execute a command and yield interleaved stdout/stderr lines."""
113
- try:
114
- proc = await asyncio.create_subprocess_exec(
115
- *args,
116
- stdout=asyncio.subprocess.PIPE,
117
- stderr=asyncio.subprocess.PIPE,
118
- )
119
- except FileNotFoundError as exc:
120
- yield f"[error] Executable not found: {exc}"
121
- return
122
- except Exception as exc:
123
- yield f"[error] Failed to start process: {exc}"
124
- return
125
-
126
- async def _readline(stream, prefix=""):
127
- while True:
128
- try:
129
- line = await asyncio.wait_for(stream.readline(), timeout=timeout)
130
- except asyncio.TimeoutError:
131
- yield f"{prefix}[timeout] Command exceeded {timeout}s"
132
- break
133
- if not line:
134
- break
135
- yield prefix + line.decode("utf-8", errors="replace").rstrip("\n")
136
-
137
- stdout_gen = _readline(proc.stdout).__aiter__()
138
- stderr_gen = _readline(proc.stderr, "[stderr] ").__aiter__()
139
-
140
- stdout_done = stderr_done = False
141
- pending_out = pending_err = None
142
-
143
- async def _next(it):
144
- try:
145
- return await it.__anext__()
146
- except StopAsyncIteration:
147
- return None
148
-
149
- pending_out = asyncio.create_task(_next(stdout_gen))
150
- pending_err = asyncio.create_task(_next(stderr_gen))
151
-
152
- while not (stdout_done and stderr_done):
153
- tasks = [t for t in (pending_out, pending_err) if t is not None]
154
- if not tasks:
155
- break
156
- done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
157
-
158
- for task in done:
159
- val = task.result()
160
- if task is pending_out:
161
- if val is None:
162
- stdout_done = True
163
- pending_out = None
164
- else:
165
- yield val
166
- pending_out = asyncio.create_task(_next(stdout_gen))
167
- elif task is pending_err:
168
- if val is None:
169
- stderr_done = True
170
- pending_err = None
171
- else:
172
- yield val
173
- pending_err = asyncio.create_task(_next(stderr_gen))
174
-
175
- await proc.wait()
app/app/routers/__init__.py
deleted file mode 100644
app/app/routers/backups.py
deleted file mode 100644
....@@ -1,101 +0,0 @@
1
-from typing import Any
2
-
3
-from fastapi import APIRouter, Depends, HTTPException
4
-
5
-from app.auth import verify_token
6
-from app.ops_runner import run_ops, run_ops_json, run_ops_host, _BACKUP_TIMEOUT
7
-
8
-router = APIRouter()
9
-
10
-
11
-@router.get("/", summary="List local backups")
12
-async def list_backups(
13
- _: str = Depends(verify_token),
14
-) -> list[dict[str, Any]]:
15
- """Returns a list of local backup records from `ops backups --json`."""
16
- result = await run_ops_json(["backups"])
17
- if not result["success"]:
18
- raise HTTPException(status_code=500, detail=f"Failed to list backups: {result['error']}")
19
-
20
- data = result["data"]
21
- if isinstance(data, list):
22
- return data
23
- if isinstance(data, dict):
24
- for key in ("backups", "data", "items"):
25
- if key in data and isinstance(data[key], list):
26
- return data[key]
27
- return [data]
28
- return []
29
-
30
-
31
-@router.get("/offsite", summary="List offsite backups")
32
-async def list_offsite_backups(
33
- _: str = Depends(verify_token),
34
-) -> list[dict[str, Any]]:
35
- """Returns a list of offsite backup records."""
36
- all_backups = []
37
- for project in ["mdf", "seriousletter"]:
38
- result = await run_ops_json(["offsite", "list", project])
39
- if result["success"] and isinstance(result["data"], list):
40
- for b in result["data"]:
41
- b["project"] = project
42
- all_backups.extend(result["data"])
43
- return all_backups
44
-
45
-
46
-@router.post("/{project}/{env}", summary="Create a local backup")
47
-async def create_backup(
48
- project: str,
49
- env: str,
50
- _: str = Depends(verify_token),
51
-) -> dict[str, Any]:
52
- """
53
- Runs `ops backup {project} {env}` on the host.
54
-
55
- Runs via nsenter because ops backup delegates to project CLIs
56
- that use host Python venvs.
57
- """
58
- result = await run_ops_host(["backup", project, env], timeout=_BACKUP_TIMEOUT)
59
- if not result["success"]:
60
- raise HTTPException(
61
- status_code=500,
62
- detail=f"Backup failed: {result['error'] or result['output']}",
63
- )
64
- return {
65
- "success": True,
66
- "output": result["output"],
67
- "project": project,
68
- "env": env,
69
- }
70
-
71
-
72
-@router.post("/offsite/upload/{project}/{env}", summary="Upload backup to offsite")
73
-async def upload_offsite(
74
- project: str,
75
- env: str,
76
- _: str = Depends(verify_token),
77
-) -> dict[str, Any]:
78
- """Runs `ops offsite upload {project} {env}` on the host."""
79
- result = await run_ops_host(
80
- ["offsite", "upload", project, env], timeout=_BACKUP_TIMEOUT
81
- )
82
- if not result["success"]:
83
- raise HTTPException(
84
- status_code=500,
85
- detail=f"Offsite upload failed: {result['error'] or result['output']}",
86
- )
87
- return {"success": True, "output": result["output"], "project": project, "env": env}
88
-
89
-
90
-@router.post("/offsite/retention", summary="Apply offsite retention policy")
91
-async def apply_retention(
92
- _: str = Depends(verify_token),
93
-) -> dict[str, Any]:
94
- """Runs `ops offsite retention` on the host."""
95
- result = await run_ops_host(["offsite", "retention"], timeout=_BACKUP_TIMEOUT)
96
- if not result["success"]:
97
- raise HTTPException(
98
- status_code=500,
99
- detail=f"Retention policy failed: {result['error'] or result['output']}",
100
- )
101
- return {"success": True, "output": result["output"]}
app/app/routers/restore.py
deleted file mode 100644
....@@ -1,85 +0,0 @@
1
-import json
2
-from datetime import datetime, timezone
3
-from typing import AsyncGenerator, Literal
4
-
5
-from fastapi import APIRouter, Depends, Query
6
-from fastapi.responses import StreamingResponse
7
-
8
-from app.auth import verify_token
9
-from app.ops_runner import _BACKUP_TIMEOUT, stream_ops_host
10
-
11
-router = APIRouter()
12
-
13
-
14
-def _sse_line(payload: dict) -> str:
15
- """Format a dict as a single SSE data line."""
16
- return f"data: {json.dumps(payload)}\n\n"
17
-
18
-
19
-async def _restore_generator(
20
- project: str,
21
- env: str,
22
- source: str,
23
- dry_run: bool,
24
-) -> AsyncGenerator[str, None]:
25
- """Async generator that drives the restore workflow and yields SSE events.
26
-
27
- Runs on the host via nsenter because ops restore delegates to project CLIs
28
- that use host Python venvs incompatible with the container's Python.
29
- """
30
- base_args = ["restore", project, env]
31
- if dry_run:
32
- base_args.append("--dry-run")
33
-
34
- if source == "offsite":
35
- # ops offsite restore <project> <env>
36
- download_args = ["offsite", "restore", project, env]
37
- yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()})
38
-
39
- download_ok = True
40
- async for line in stream_ops_host(download_args, timeout=_BACKUP_TIMEOUT):
41
- yield _sse_line({"line": line, "timestamp": _now()})
42
- if line.startswith("[error]"):
43
- download_ok = False
44
-
45
- if not download_ok:
46
- yield _sse_line({"done": True, "success": False})
47
- return
48
-
49
- yield _sse_line({"line": "Download complete. Starting restore...", "timestamp": _now()})
50
-
51
- success = True
52
- async for line in stream_ops_host(base_args, timeout=_BACKUP_TIMEOUT):
53
- yield _sse_line({"line": line, "timestamp": _now()})
54
- if line.startswith("[error]"):
55
- success = False
56
-
57
- yield _sse_line({"done": True, "success": success})
58
-
59
-
60
-def _now() -> str:
61
- return datetime.now(timezone.utc).isoformat()
62
-
63
-
64
-@router.get("/{project}/{env}", summary="Restore a backup with real-time output")
65
-async def restore_backup(
66
- project: str,
67
- env: str,
68
- source: Literal["local", "offsite"] = Query(default="local"),
69
- dry_run: bool = Query(default=False, alias="dry_run"),
70
- _: str = Depends(verify_token),
71
-) -> StreamingResponse:
72
- """
73
- Restore a backup for the given project/env.
74
-
75
- Uses Server-Sent Events (SSE) to stream real-time progress.
76
- Runs on the host via nsenter for Python venv compatibility.
77
- """
78
- return StreamingResponse(
79
- _restore_generator(project, env, source, dry_run),
80
- media_type="text/event-stream",
81
- headers={
82
- "Cache-Control": "no-cache",
83
- "X-Accel-Buffering": "no",
84
- },
85
- )
app/app/routers/services.py
deleted file mode 100644
....@@ -1,177 +0,0 @@
1
-import os
2
-from typing import Any
3
-
4
-import yaml
5
-from fastapi import APIRouter, Depends, HTTPException, Query
6
-
7
-from app.auth import verify_token
8
-from app.ops_runner import run_command
9
-
10
-router = APIRouter()
11
-
12
-_DOCKER = "docker"
13
-_REGISTRY_PATH = os.environ.get(
14
- "REGISTRY_PATH",
15
- "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
16
-)
17
-
18
-# ---------------------------------------------------------------------------
19
-# Registry-based name prefix lookup (cached)
20
-# ---------------------------------------------------------------------------
21
-_prefix_cache: dict[str, str] | None = None
22
-
23
-
24
-def _load_prefixes() -> dict[str, str]:
25
- """Load project -> name_prefix mapping from the ops registry."""
26
- global _prefix_cache
27
- if _prefix_cache is not None:
28
- return _prefix_cache
29
-
30
- try:
31
- with open(_REGISTRY_PATH) as f:
32
- data = yaml.safe_load(f)
33
- _prefix_cache = {}
34
- for proj_name, cfg in data.get("projects", {}).items():
35
- _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
36
- return _prefix_cache
37
- except Exception:
38
- return {}
39
-
40
-
41
-# ---------------------------------------------------------------------------
42
-# Container name resolution
43
-# ---------------------------------------------------------------------------
44
-
45
-
46
-async def _find_by_prefix(pattern: str) -> str | None:
47
- """Find first running container whose name starts with `pattern`."""
48
- result = await run_command(
49
- [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
50
- timeout=10,
51
- )
52
- if not result["success"]:
53
- return None
54
- for name in result["output"].strip().splitlines():
55
- name = name.strip()
56
- if name and name.startswith(pattern):
57
- return name
58
- return None
59
-
60
-
61
-async def _find_exact(name: str) -> str | None:
62
- """Find a running container with exactly this name."""
63
- result = await run_command(
64
- [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
65
- timeout=10,
66
- )
67
- if not result["success"]:
68
- return None
69
- for n in result["output"].strip().splitlines():
70
- if n.strip() == name:
71
- return name
72
- return None
73
-
74
-
75
-async def _resolve_container(project: str, env: str, service: str) -> str:
76
- """
77
- Resolve the actual Docker container name from project/env/service.
78
-
79
- Uses the ops registry name_prefix mapping and tries patterns in order:
80
- 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)
81
- 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
82
- 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)
83
- 4. exact {prefix} (coolify infra: coolify)
84
- """
85
- prefixes = _load_prefixes()
86
- prefix = prefixes.get(project, project)
87
-
88
- # Pattern 1: {env}-{prefix}-{service}
89
- hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
90
- if hit:
91
- return hit
92
-
93
- # Pattern 2: {prefix}-{service}
94
- hit = await _find_by_prefix(f"{prefix}-{service}")
95
- if hit:
96
- return hit
97
-
98
- # Pattern 3: {prefix}-{env}
99
- hit = await _find_by_prefix(f"{prefix}-{env}")
100
- if hit:
101
- return hit
102
-
103
- # Pattern 4: exact match when service == prefix (e.g., coolify)
104
- if service == prefix:
105
- hit = await _find_exact(prefix)
106
- if hit:
107
- return hit
108
-
109
- raise HTTPException(
110
- status_code=404,
111
- detail=f"Container not found for {project}/{env}/{service}",
112
- )
113
-
114
-
115
-# ---------------------------------------------------------------------------
116
-# Endpoints
117
-# ---------------------------------------------------------------------------
118
-
119
-
120
-@router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
121
-async def get_logs(
122
- project: str,
123
- env: str,
124
- service: str,
125
- lines: int = Query(
126
- default=100, ge=1, le=10000, description="Number of log lines to return"
127
- ),
128
- _: str = Depends(verify_token),
129
-) -> dict[str, Any]:
130
- """Fetch the last N lines of logs from a container."""
131
- container = await _resolve_container(project, env, service)
132
- result = await run_command(
133
- [_DOCKER, "logs", "--tail", str(lines), container],
134
- timeout=30,
135
- )
136
-
137
- # docker logs writes to stderr by default; combine both streams
138
- combined = result["output"] + result["error"]
139
-
140
- if not result["success"] and not combined.strip():
141
- raise HTTPException(
142
- status_code=500,
143
- detail=f"Failed to retrieve logs for container '{container}'",
144
- )
145
-
146
- return {
147
- "container": container,
148
- "lines": lines,
149
- "logs": combined,
150
- }
151
-
152
-
153
-@router.post("/restart/{project}/{env}/{service}", summary="Restart a container")
154
-async def restart_service(
155
- project: str,
156
- env: str,
157
- service: str,
158
- _: str = Depends(verify_token),
159
-) -> dict[str, Any]:
160
- """Restart a Docker container."""
161
- container = await _resolve_container(project, env, service)
162
- result = await run_command(
163
- [_DOCKER, "restart", container],
164
- timeout=60,
165
- )
166
-
167
- if not result["success"]:
168
- raise HTTPException(
169
- status_code=500,
170
- detail=f"Failed to restart container '{container}': {result['error'] or result['output']}",
171
- )
172
-
173
- return {
174
- "success": True,
175
- "container": container,
176
- "message": f"Container '{container}' restarted successfully",
177
- }
app/app/routers/status.py
deleted file mode 100644
....@@ -1,37 +0,0 @@
1
-from typing import Any
2
-
3
-from fastapi import APIRouter, Depends, HTTPException
4
-
5
-from app.auth import verify_token
6
-from app.ops_runner import run_ops_json
7
-
8
-router = APIRouter()
9
-
10
-
11
-@router.get("/", summary="Get all container statuses")
12
-async def get_status(
13
- _: str = Depends(verify_token),
14
-) -> list[dict[str, Any]]:
15
- """
16
- Returns a list of container status objects from `ops status --json`.
17
-
18
- Each item contains: project, service, status, health, uptime.
19
- """
20
- result = await run_ops_json(["status"])
21
- if not result["success"]:
22
- raise HTTPException(
23
- status_code=500,
24
- detail=f"Failed to retrieve status: {result['error']}",
25
- )
26
-
27
- data = result["data"]
28
- # Normalise to list regardless of what ops returns
29
- if isinstance(data, list):
30
- return data
31
- if isinstance(data, dict):
32
- # Some ops implementations wrap the list in a key
33
- for key in ("services", "containers", "status", "data"):
34
- if key in data and isinstance(data[key], list):
35
- return data[key]
36
- return [data]
37
- return []
app/app/routers/system.py
deleted file mode 100644
....@@ -1,253 +0,0 @@
1
-import asyncio
2
-import os
3
-import re
4
-from typing import Any
5
-
6
-from fastapi import APIRouter, Depends, HTTPException
7
-
8
-from app.auth import verify_token
9
-from app.ops_runner import run_command, run_ops
10
-
11
-router = APIRouter()
12
-
13
-
14
-# ---------------------------------------------------------------------------
15
-# Helpers
16
-# ---------------------------------------------------------------------------
17
-
18
-def _parse_disk_output(raw: str) -> list[dict[str, str]]:
19
- """Parse df-style output into a list of filesystem dicts."""
20
- filesystems: list[dict[str, str]] = []
21
- lines = raw.strip().splitlines()
22
- if not lines:
23
- return filesystems
24
-
25
- data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
26
-
27
- for line in data_lines:
28
- parts = line.split()
29
- if len(parts) >= 5:
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
- })
38
- return filesystems
39
-
40
-
41
-def _parse_health_output(raw: str) -> list[dict[str, str]]:
42
- """Parse health check output into check result dicts."""
43
- checks: list[dict[str, str]] = []
44
- for line in raw.strip().splitlines():
45
- line = line.strip()
46
- if not line:
47
- continue
48
- match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
49
- if match:
50
- checks.append({"status": match.group(1), "check": match.group(2)})
51
- else:
52
- checks.append({"status": "INFO", "check": line})
53
- return checks
54
-
55
-
56
-def _parse_timers_output(raw: str) -> list[dict[str, str]]:
57
- """Parse `systemctl list-timers` output into timer dicts."""
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": ""})
82
- 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)
129
-
130
-
131
-# ---------------------------------------------------------------------------
132
-# Endpoints
133
-# ---------------------------------------------------------------------------
134
-
135
-@router.get("/disk", summary="Disk usage")
136
-async def disk_usage(
137
- _: str = Depends(verify_token),
138
-) -> dict[str, Any]:
139
- """Returns disk usage via `ops disk` (fallback: `df -h`)."""
140
- result = await run_ops(["disk"])
141
- raw = result["output"]
142
-
143
- if not result["success"] or not raw.strip():
144
- fallback = await run_command(["df", "-h"])
145
- raw = fallback["output"]
146
- if not fallback["success"]:
147
- raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
148
-
149
- return {
150
- "filesystems": _parse_disk_output(raw),
151
- "raw": raw,
152
- }
153
-
154
-
155
-@router.get("/health", summary="System health checks")
156
-async def health_check(
157
- _: str = Depends(verify_token),
158
-) -> dict[str, Any]:
159
- """Returns health check results via `ops health`."""
160
- result = await run_ops(["health"])
161
- if not result["success"] and not result["output"].strip():
162
- raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
163
- return {
164
- "checks": _parse_health_output(result["output"]),
165
- "raw": result["output"],
166
- }
167
-
168
-
169
-@router.get("/timers", summary="Systemd timers")
170
-async def list_timers(
171
- _: str = Depends(verify_token),
172
-) -> dict[str, Any]:
173
- """Lists systemd timers."""
174
- result = await run_command(["systemctl", "list-timers", "--no-pager"])
175
- if not result["success"] and not result["output"].strip():
176
- raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
177
- return {
178
- "timers": _parse_timers_output(result["output"]),
179
- "raw": result["output"],
180
- }
181
-
182
-
183
-@router.get("/info", summary="System information with CPU/memory")
184
-async def system_info(
185
- _: str = Depends(verify_token),
186
-) -> dict[str, Any]:
187
- """
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.
192
- """
193
- uptime_str = ""
194
- load_str = ""
195
-
196
- # Uptime
197
- try:
198
- with open("/proc/uptime") as f:
199
- seconds_up = float(f.read().split()[0])
200
- days = int(seconds_up // 86400)
201
- hours = int((seconds_up % 86400) // 3600)
202
- minutes = int((seconds_up % 3600) // 60)
203
- 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
- except Exception:
213
- pass
214
-
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
237
- if not uptime_str or not load_str:
238
- result = await run_command(["uptime"])
239
- if result["success"]:
240
- raw = result["output"].strip()
241
- up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
242
- 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()
247
-
248
- return {
249
- "uptime": uptime_str or "unavailable",
250
- "load": load_str or "unavailable",
251
- "cpu": cpu_info or None,
252
- **mem_info,
253
- }