{value}
+{label}
+Aucun VPS configuré
+Cliquez sur Ajouter un VPS pour commencer.
+ +diff --git a/.gitea/workflows/build-push.yml b/.gitea/workflows/build-push.yml new file mode 100644 index 0000000..c0e1b43 --- /dev/null +++ b/.gitea/workflows/build-push.yml @@ -0,0 +1,41 @@ +name: Build and Push Docker Images + +on: + push: + branches: + - main + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Gitea Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.jeanbonapp.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build Backend Image + run: | + docker build \ + -t git.jeanbonapp.com/${{ secrets.REGISTRY_USER }}/vps-monitor-backend:latest \ + ./vps-monitor/backend + + - name: Push Backend Image + run: | + docker push git.jeanbonapp.com/${{ secrets.REGISTRY_USER }}/vps-monitor-backend:latest + + - name: Build Frontend Image + run: | + docker build \ + --build-arg VITE_APP_VERSION=$(echo ${{ gitea.sha }} | cut -c1-7) \ + --build-arg VITE_APP_ENV=Production \ + -t git.jeanbonapp.com/${{ secrets.REGISTRY_USER }}/vps-monitor-frontend:latest \ + ./vps-monitor/frontend + + - name: Push Frontend Image + run: | + docker push git.jeanbonapp.com/${{ secrets.REGISTRY_USER }}/vps-monitor-frontend:latest + diff --git a/src/manage_vps.py b/src/manage_vps.py index fd9b01e..f6702e1 100644 --- a/src/manage_vps.py +++ b/src/manage_vps.py @@ -1,39 +1,524 @@ #!/usr/bin/env python3 """ Gestionnaire d'applications Docker sur VPS Debian. -Autodétecte les applications sous /home/*/docker-compose.yml +Navigation au clavier (↑↓ + Entrée), sans dépendance externe. """ import os import subprocess import sys +import termios +import tty from pathlib import Path +# ─── Configuration ──────────────────────────────────────────────────────────── + APPS_BASE_DIR = "/home" -COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml"] +COMPOSE_FILES = ["compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"] + +# ─── ANSI ───────────────────────────────────────────────────────────────────── + +R = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +CYN = "\033[36m" +YEL = "\033[33m" +GRN = "\033[32m" +RED = "\033[31m" +SEL = "\033[44m\033[97m" # fond bleu + texte blanc + +W = 56 # largeur de la boîte + +# ─── Gestion du clavier ─────────────────────────────────────────────────────── + +KEY_UP = "\x1b[A" +KEY_DOWN = "\x1b[B" +KEY_ENTER = ("\r", "\n") +KEY_QUIT = ("q", "Q", "\x1b", "\x03") # q, ESC, Ctrl+C -def find_docker_apps() -> list[dict]: - """Détecte les applications Docker sous /home/[nom_app]/.""" +def getch() -> str: + """Lit un seul appui clavier sans attendre Entrée.""" + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + if ch == "\x1b": + nxt = sys.stdin.read(1) + if nxt == "[": + return "\x1b[" + sys.stdin.read(1) + return "\x1b" + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def hide_cursor() -> None: + print("\033[?25l", end="", flush=True) + + +def show_cursor() -> None: + print("\033[?25h", end="", flush=True) + + +# ─── Rendu ──────────────────────────────────────────────────────────────────── + +def draw_header(title: str, subtitle: str = "") -> None: + print(f"{CYN}╔{'═' * (W - 2)}╗{R}") + print(f"{CYN}║{R}{BOLD}{title.center(W - 2)}{R}{CYN}║{R}") + print(f"{CYN}╠{'═' * (W - 2)}╣{R}") + if subtitle: + pad = W - 2 - len(subtitle) + print(f"{CYN}║{R} {YEL}{subtitle}{R}{' ' * max(0, pad - 1)}{CYN}║{R}") + print(f"{CYN}╠{'─' * (W - 2)}╣{R}") + + +def draw_footer() -> None: + print(f"{CYN}╠{'─' * (W - 2)}╣{R}") + hint = f" {YEL}↑↓{R} naviguer {YEL}↵{R} sélectionner {YEL}q{R} retour" + print(f"{CYN}║{R}{hint}") + print(f"{CYN}╚{'═' * (W - 2)}╝{R}") + + +def draw_item(index: int, label: str, selected: bool, badge: str = "") -> None: + num = f"[{index + 1}]" + badge_str = f" {GRN}({badge}){R}" if badge else "" + text = f" {num} {label}" + visible_len = len(text) + (len(badge) + 3 if badge else 0) + padding = " " * max(0, W - 4 - visible_len) + if selected: + print(f"{CYN}║{R}{SEL} › {text}{R}{GRN if badge else ''}{badge_str}{R}{SEL}{padding} {R}{CYN}║{R}") + else: + print(f"{CYN}║{R} {text}{badge_str}{padding} {CYN}║{R}") + + +# ─── Menu générique ─────────────────────────────────────────────────────────── + +def navigate_menu( + title: str, + items: list[str], + subtitle: str = "", + badges: list[str] | None = None, +) -> int | None: + """ + Menu interactif à navigation clavier (↑↓ + Entrée). + Retourne l'index sélectionné, ou None si l'utilisateur quitte. + """ + current = 0 + badges = badges or [""] * len(items) + + while True: + os.system("clear") + hide_cursor() + draw_header(title, subtitle) + for i, item in enumerate(items): + draw_item(i, item, i == current, badges[i]) + draw_footer() + + key = getch() + + if key == KEY_UP: + current = (current - 1) % len(items) + elif key == KEY_DOWN: + current = (current + 1) % len(items) + elif key in KEY_ENTER: + show_cursor() + return current + elif key in KEY_QUIT: + show_cursor() + return None + elif key.isdigit(): + idx = int(key) - 1 + if 0 <= idx < len(items): + show_cursor() + return idx + + +def pause(message: str = "Appuyez sur une touche pour continuer…") -> None: + show_cursor() + print(f"\n {DIM}{message}{R}", end="", flush=True) + getch() + + +# ─── Détection Docker ───────────────────────────────────────────────────────── + +def _apps_from_docker_ps() -> list[dict]: + """Détecte les projets Compose via les labels des conteneurs docker ps -a.""" + apps = [] + seen_dirs: set[str] = set() + try: + result = subprocess.run( + [ + "docker", "ps", "-a", + "--format", + "{{.Label \"com.docker.compose.project\"}}\t" + "{{.Label \"com.docker.compose.project.working_dir\"}}\t" + "{{.Label \"com.docker.compose.project.config_files\"}}", + ], + capture_output=True, + text=True, + ) + except FileNotFoundError: + return [] + + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + project, working_dir, config_files = (p.strip() for p in parts) + if not project or not working_dir or working_dir in seen_dirs: + continue + compose_file = config_files.split(",")[0].strip() if config_files else "" + if not compose_file or not Path(compose_file).exists(): + compose_file = "" + for name in COMPOSE_FILES: + candidate = Path(working_dir) / name + if candidate.exists(): + compose_file = str(candidate) + break + if not compose_file: + continue + seen_dirs.add(working_dir) + apps.append({"name": project, "path": working_dir, "compose_file": compose_file}) + + return apps + + +def _apps_from_filesystem(exclude_dirs: set) -> list[dict]: + """Fallback : scan récursif /home/*/ (profondeur 3) pour trouver des fichiers compose.""" apps = [] base = Path(APPS_BASE_DIR) - for entry in sorted(base.iterdir()): - if not entry.is_dir(): - continue - for compose_file in COMPOSE_FILES: - compose_path = entry / compose_file - if compose_path.exists(): - apps.append({ - "name": entry.name, - "path": str(entry), - "compose_file": str(compose_path), - }) - break + def search(directory: Path, app_name: str, depth: int) -> None: + if depth == 0: + return + try: + for compose_name in COMPOSE_FILES: + compose_path = directory / compose_name + if compose_path.exists() and str(directory) not in exclude_dirs: + rel = directory.relative_to(base / app_name) + label = app_name if rel == Path(".") else f"{app_name}/{rel}" + apps.append({"name": label, "path": str(directory), "compose_file": str(compose_path)}) + exclude_dirs.add(str(directory)) + return + for sub in sorted(directory.iterdir()): + if sub.is_dir(): + search(sub, app_name, depth - 1) + except PermissionError: + pass + + try: + for entry in sorted(base.iterdir()): + if entry.is_dir(): + search(entry, entry.name, depth=3) + except PermissionError: + show_cursor() + print(f"Erreur : permission refusée pour lire {APPS_BASE_DIR}") + sys.exit(1) return apps +def find_docker_apps() -> list[dict]: + """Détecte les apps Compose via docker ps (labels) puis complète avec le filesystem.""" + apps = _apps_from_docker_ps() + seen = {a["path"] for a in apps} + apps += _apps_from_filesystem(seen) + return sorted(apps, key=lambda a: a["name"].lower()) + + +# ─── Actions Docker ─────────────────────────────────────────────────────────── + +def get_running_containers(app: dict) -> list[str]: + try: + result = subprocess.run( + ["docker", "compose", "-f", app["compose_file"], "ps", "--services", "--filter", "status=running"], + capture_output=True, text=True, cwd=app["path"], + ) + return [s.strip() for s in result.stdout.splitlines() if s.strip()] + except FileNotFoundError: + return [] + + +def get_all_services(app: dict) -> list[str]: + try: + result = subprocess.run( + ["docker", "compose", "-f", app["compose_file"], "config", "--services"], + capture_output=True, text=True, cwd=app["path"], + ) + return [s.strip() for s in result.stdout.splitlines() if s.strip()] + except FileNotFoundError: + return [] + + +def get_container_id(app: dict, service: str) -> str | None: + try: + result = subprocess.run( + ["docker", "compose", "-f", app["compose_file"], "ps", "-q", service], + capture_output=True, text=True, cwd=app["path"], + ) + cid = result.stdout.strip() + return cid if cid else None + except FileNotFoundError: + return None + + +def action_bash(app: dict, service: str) -> None: + container_id = get_container_id(app, service) + if not container_id: + print(f"\n {RED}Conteneur '{service}' introuvable ou arrêté.{R}") + pause() + return + show_cursor() + os.system("clear") + print(f"{CYN}{'─' * W}{R}") + print(f" Shell bash → {BOLD}{app['name']}{R} / {service}") + print(f" {DIM}(tapez 'exit' pour revenir au menu){R}") + print(f"{CYN}{'─' * W}{R}\n") + subprocess.run(["docker", "exec", "-it", container_id, "bash"]) + + +def action_logs(app: dict, service: str) -> None: + container_id = get_container_id(app, service) + if not container_id: + print(f"\n {RED}Conteneur '{service}' introuvable ou arrêté.{R}") + pause() + return + show_cursor() + os.system("clear") + print(f"{CYN}{'─' * W}{R}") + print(f" Logs → {BOLD}{app['name']}{R} / {service}") + print(f" {DIM}(Ctrl+C pour arrêter){R}") + print(f"{CYN}{'─' * W}{R}\n") + try: + subprocess.run(["docker", "logs", "--tail", "100", "-f", container_id]) + except KeyboardInterrupt: + print(f"\n {YEL}Suivi interrompu.{R}") + pause() + + +def action_update(app: dict) -> None: + show_cursor() + os.system("clear") + print(f"{CYN}{'─' * W}{R}") + print(f" Mise à jour → {BOLD}{app['name']}{R}") + print(f"{CYN}{'─' * W}{R}\n") + + print(f" {YEL}→ docker compose pull{R}\n") + result_pull = subprocess.run( + ["docker", "compose", "-f", app["compose_file"], "pull"], + cwd=app["path"], + ) + if result_pull.returncode != 0: + print(f"\n {RED}Erreur lors du pull.{R}") + pause() + return + + print(f"\n {YEL}→ docker compose up -d{R}\n") + result_up = subprocess.run( + ["docker", "compose", "-f", app["compose_file"], "up", "-d"], + cwd=app["path"], + ) + if result_up.returncode == 0: + print(f"\n {GRN}Mise à jour terminée avec succès.{R}") + else: + print(f"\n {RED}Erreur lors du redémarrage.{R}") + pause() + + +# ─── Menus ──────────────────────────────────────────────────────────────────── + +def service_menu(app: dict, action: str) -> None: + """Sélection d'un service puis exécution de l'action (bash ou logs).""" + services = get_all_services(app) + if not services: + print(f"\n {RED}Aucun service trouvé dans le compose.{R}") + pause() + return + + running = set(get_running_containers(app)) + badges = ["actif" if s in running else "" for s in services] + label = "Shell bash" if action == "bash" else "Logs" + + idx = navigate_menu( + title=" Gestionnaire Docker VPS ", + subtitle=f"{app['name']} › {label}", + items=services, + badges=badges, + ) + if idx is None: + return + + if action == "bash": + action_bash(app, services[idx]) + else: + action_logs(app, services[idx]) + + +def app_menu(app: dict) -> None: + """Menu des actions pour une application.""" + actions = [ + "Shell bash dans un conteneur", + "Voir les logs d'un conteneur", + "Mettre à jour (pull + up -d)", + ] + + while True: + running = get_running_containers(app) + if running: + status = f"{len(running)} service(s) actif(s)" + else: + status = "aucun service actif" + + idx = navigate_menu( + title=" Gestionnaire Docker VPS ", + subtitle=f"{app['name']} · {status}", + items=actions, + ) + + if idx is None: + return + elif idx == 0: + service_menu(app, "bash") + elif idx == 1: + service_menu(app, "logs") + elif idx == 2: + action_update(app) + + +def main() -> None: + if os.geteuid() != 0: + print(f"{YEL}Attention : droits root recommandés (sudo python3 manage_vps.py){R}\n") + + try: + while True: + apps = find_docker_apps() + + if not apps: + os.system("clear") + print(f"\n {RED}Aucune application Docker trouvée sous {APPS_BASE_DIR}/{R}") + print(f" {DIM}(vérifiez que des fichiers compose.yml existent){R}\n") + sys.exit(0) + + idx = navigate_menu( + title=" Gestionnaire Docker VPS ", + subtitle=f"{len(apps)} application(s) détectée(s)", + items=[a["name"] for a in apps], + ) + + if idx is None: + os.system("clear") + print(f"\n Au revoir !\n") + sys.exit(0) + + app_menu(apps[idx]) + + except KeyboardInterrupt: + show_cursor() + os.system("clear") + print("\n Au revoir !\n") + sys.exit(0) + + +if __name__ == "__main__": + main() + + + +def _apps_from_docker_ps() -> list[dict]: + """Détecte les projets Compose via les labels des conteneurs docker ps -a.""" + apps = [] + seen_dirs = set() + try: + result = subprocess.run( + [ + "docker", "ps", "-a", + "--format", + "{{.Label \"com.docker.compose.project\"}}\t" + "{{.Label \"com.docker.compose.project.working_dir\"}}\t" + "{{.Label \"com.docker.compose.project.config_files\"}}", + ], + capture_output=True, + text=True, + ) + except FileNotFoundError: + return [] # docker non installé + + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + project, working_dir, config_files = (p.strip() for p in parts) + if not project or not working_dir or working_dir in seen_dirs: + continue + # Prend le premier fichier de config listé + compose_file = config_files.split(",")[0].strip() if config_files else "" + if not compose_file or not Path(compose_file).exists(): + # Cherche un fichier compose connu dans le working_dir + compose_file = "" + for name in COMPOSE_FILES: + candidate = Path(working_dir) / name + if candidate.exists(): + compose_file = str(candidate) + break + if not compose_file: + continue + seen_dirs.add(working_dir) + apps.append({ + "name": project, + "path": working_dir, + "compose_file": compose_file, + }) + return apps + + +def _apps_from_filesystem(exclude_dirs: set) -> list[dict]: + """Fallback : scanne /home/*/ récursivement (profondeur 3) pour trouver des fichiers compose.""" + apps = [] + base = Path(APPS_BASE_DIR) + + def search(directory: Path, app_name: str, depth: int): + if depth == 0: + return + try: + for compose_name in COMPOSE_FILES: + compose_path = directory / compose_name + if compose_path.exists() and str(directory) not in exclude_dirs: + rel = directory.relative_to(base / app_name) + label = app_name if rel == Path(".") else f"{app_name}/{rel}" + apps.append({ + "name": label, + "path": str(directory), + "compose_file": str(compose_path), + }) + exclude_dirs.add(str(directory)) + return + for sub in sorted(directory.iterdir()): + if sub.is_dir(): + search(sub, app_name, depth - 1) + except PermissionError: + pass + + try: + for entry in sorted(base.iterdir()): + if entry.is_dir(): + search(entry, entry.name, depth=3) + except PermissionError: + print(f" Erreur : permission refusée pour lire {APPS_BASE_DIR}") + sys.exit(1) + + return apps + + +def find_docker_apps() -> list[dict]: + """Détecte les apps Compose via docker ps (labels) puis complète avec le filesystem.""" + apps = _apps_from_docker_ps() + seen_dirs = {a["path"] for a in apps} + apps += _apps_from_filesystem(seen_dirs) + return sorted(apps, key=lambda a: a["name"].lower()) + + def get_running_containers(app: dict) -> list[str]: """Retourne les noms des conteneurs en cours d'exécution pour une app.""" try: diff --git a/vps-monitor/agent/.env.example b/vps-monitor/agent/.env.example new file mode 100644 index 0000000..fb7914b --- /dev/null +++ b/vps-monitor/agent/.env.example @@ -0,0 +1,2 @@ +AGENT_API_KEY=changeme-please +AGENT_PORT=8001 diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py new file mode 100644 index 0000000..5770782 --- /dev/null +++ b/vps-monitor/agent/agent.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +VPS Monitor Agent — à déployer sur chaque VPS. +Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker. +""" + +import os +from datetime import datetime, timezone + +import docker +from docker.errors import DockerException, NotFound +from fastapi import Depends, FastAPI, HTTPException, Security +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import APIKeyHeader + +# ─── Config ─────────────────────────────────────────────────────────────────── + +API_KEY = os.getenv("AGENT_API_KEY", "changeme-please") +AGENT_PORT = int(os.getenv("AGENT_PORT", "8001")) + +# ─── App ────────────────────────────────────────────────────────────────────── + +app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_url=None) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True) + + +def require_api_key(key: str = Security(api_key_header)) -> None: + if key != API_KEY: + raise HTTPException(status_code=403, detail="Clé API invalide") + + +def get_docker_client(): + try: + return docker.from_env() + except DockerException as e: + raise HTTPException(status_code=503, detail=f"Docker inaccessible : {e}") + + +# ─── Routes ─────────────────────────────────────────────────────────────────── + +@app.get("/health") +def health(): + """Vérification de disponibilité — sans authentification.""" + return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} + + +@app.get("/containers") +def list_containers(_: None = Depends(require_api_key)): + """Retourne tous les conteneurs (actifs et arrêtés).""" + client = get_docker_client() + result = [] + for c in client.containers.list(all=True): + image_tag = c.image.tags[0] if c.image.tags else c.image.short_id + result.append({ + "id": c.short_id, + "name": c.name.lstrip("/"), + "status": c.status, + "image": image_tag, + "created": c.attrs.get("Created", ""), + "compose_project": c.labels.get("com.docker.compose.project", ""), + "compose_service": c.labels.get("com.docker.compose.service", ""), + "ports": { + host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings] + for host, bindings in (c.ports or {}).items() + if bindings + }, + }) + return sorted(result, key=lambda x: x["name"]) + + +@app.get("/containers/{container_id}/logs") +def get_logs(container_id: str, lines: int = 100, _: None = Depends(require_api_key)): + """Retourne les N dernières lignes de logs d'un conteneur.""" + client = get_docker_client() + try: + c = client.containers.get(container_id) + raw = c.logs(tail=lines, timestamps=True) + return {"logs": raw.decode("utf-8", errors="replace")} + except NotFound: + raise HTTPException(status_code=404, detail="Conteneur introuvable") + + +@app.post("/containers/{container_id}/action") +def container_action(container_id: str, action: str, _: None = Depends(require_api_key)): + """Effectue une action sur un conteneur : start, stop, restart.""" + if action not in ("start", "stop", "restart"): + raise HTTPException(status_code=400, detail=f"Action invalide : {action}") + client = get_docker_client() + try: + c = client.containers.get(container_id) + getattr(c, action)() + return {"status": "ok", "action": action, "container": container_id} + except NotFound: + raise HTTPException(status_code=404, detail="Conteneur introuvable") + + +# ─── Entrée ─────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=AGENT_PORT) diff --git a/vps-monitor/agent/agent.service b/vps-monitor/agent/agent.service new file mode 100644 index 0000000..a3ae600 --- /dev/null +++ b/vps-monitor/agent/agent.service @@ -0,0 +1,16 @@ +[Unit] +Description=VPS Monitor Agent +After=docker.service +Requires=docker.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/vps-monitor-agent +EnvironmentFile=/opt/vps-monitor-agent/.env +ExecStart=/opt/vps-monitor-agent/venv/bin/uvicorn agent:app --host 0.0.0.0 --port 8001 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/vps-monitor/agent/requirements.txt b/vps-monitor/agent/requirements.txt new file mode 100644 index 0000000..b68a2a6 --- /dev/null +++ b/vps-monitor/agent/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +docker>=7.1.0 diff --git a/vps-monitor/backend/.env.example b/vps-monitor/backend/.env.example new file mode 100644 index 0000000..b619400 --- /dev/null +++ b/vps-monitor/backend/.env.example @@ -0,0 +1,3 @@ +CONFIG_FILE=data/vps.json +AGENT_TIMEOUT=5 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 diff --git a/vps-monitor/backend/Dockerfile b/vps-monitor/backend/Dockerfile new file mode 100644 index 0000000..063ef6a --- /dev/null +++ b/vps-monitor/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py new file mode 100644 index 0000000..db15db4 --- /dev/null +++ b/vps-monitor/backend/main.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +VPS Monitor Backend — serveur central. +Agrège les données de tous les agents et expose une API REST pour le frontend. +""" + +import asyncio +import json +import os +from pathlib import Path + +import aiohttp +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +# ─── Config ─────────────────────────────────────────────────────────────────── + +CONFIG_FILE = Path(os.getenv("CONFIG_FILE", "data/vps.json")) +AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5")) + +CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) +if not CONFIG_FILE.exists(): + CONFIG_FILE.write_text("[]") + +# ─── Modèles ────────────────────────────────────────────────────────────────── + +class VpsConfig(BaseModel): + id: str + name: str + host: str + port: int = 8001 + api_key: str + description: str = "" + + +class ActionRequest(BaseModel): + action: str # start | stop | restart + + +# ─── Persistance ────────────────────────────────────────────────────────────── + +def load_vps() -> list[dict]: + return json.loads(CONFIG_FILE.read_text()) + + +def save_vps(data: list[dict]) -> None: + CONFIG_FILE.write_text(json.dumps(data, indent=2)) + + +# ─── App ────────────────────────────────────────────────────────────────────── + +app = FastAPI(title="VPS Monitor Backend", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=os.getenv("CORS_ORIGINS", "*").split(","), + allow_methods=["*"], + allow_headers=["*"], +) + +# ─── Helpers HTTP ───────────────────────────────────────────────────────────── + +async def agent_get(vps: dict, path: str): + url = f"http://{vps['host']}:{vps['port']}{path}" + headers = {"X-API-Key": vps["api_key"]} + timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT) + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=timeout) as r: + r.raise_for_status() + return await r.json() + + +async def agent_post(vps: dict, path: str, payload: dict | None = None): + url = f"http://{vps['host']}:{vps['port']}{path}" + headers = {"X-API-Key": vps["api_key"]} + timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT) + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload, timeout=timeout) as r: + r.raise_for_status() + return await r.json() + + +async def fetch_vps_status(vps: dict) -> dict: + """Interroge un agent et retourne son état complet.""" + try: + containers = await agent_get(vps, "/containers") + return { + "id": vps["id"], + "name": vps["name"], + "host": vps["host"], + "description": vps.get("description", ""), + "online": True, + "containers": containers, + } + except Exception as e: + return { + "id": vps["id"], + "name": vps["name"], + "host": vps["host"], + "description": vps.get("description", ""), + "online": False, + "error": str(e), + "containers": [], + } + + +# ─── Routes VPS ─────────────────────────────────────────────────────────────── + +@app.get("/api/vps") +def list_vps(): + """Liste les VPS configurés (sans les clés API).""" + return [ + {"id": v["id"], "name": v["name"], "host": v["host"], "description": v.get("description", "")} + for v in load_vps() + ] + + +@app.post("/api/vps", status_code=201) +def add_vps(vps: VpsConfig): + """Ajoute un nouveau VPS.""" + data = load_vps() + if any(v["id"] == vps.id for v in data): + raise HTTPException(status_code=409, detail="Un VPS avec cet ID existe déjà") + data.append(vps.model_dump()) + save_vps(data) + return {"status": "ok", "id": vps.id} + + +@app.delete("/api/vps/{vps_id}") +def delete_vps(vps_id: str): + """Supprime un VPS de la configuration.""" + data = load_vps() + filtered = [v for v in data if v["id"] != vps_id] + if len(filtered) == len(data): + raise HTTPException(status_code=404, detail="VPS introuvable") + save_vps(filtered) + return {"status": "ok"} + + +@app.get("/api/status") +async def all_status(): + """Retourne l'état de tous les VPS en parallèle.""" + vps_list = load_vps() + results = await asyncio.gather(*[fetch_vps_status(v) for v in vps_list]) + return list(results) + + +@app.get("/api/vps/{vps_id}/status") +async def vps_status(vps_id: str): + """Retourne l'état d'un VPS spécifique.""" + vps = next((v for v in load_vps() if v["id"] == vps_id), None) + if not vps: + raise HTTPException(status_code=404, detail="VPS introuvable") + return await fetch_vps_status(vps) + + +@app.get("/api/vps/{vps_id}/containers/{container_id}/logs") +async def container_logs(vps_id: str, container_id: str, lines: int = 100): + """Récupère les logs d'un conteneur via l'agent.""" + vps = next((v for v in load_vps() if v["id"] == vps_id), None) + if not vps: + raise HTTPException(status_code=404, detail="VPS introuvable") + try: + return await agent_get(vps, f"/containers/{container_id}/logs?lines={lines}") + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@app.post("/api/vps/{vps_id}/containers/{container_id}/action") +async def container_action(vps_id: str, container_id: str, body: ActionRequest): + """Effectue une action sur un conteneur via l'agent.""" + vps = next((v for v in load_vps() if v["id"] == vps_id), None) + if not vps: + raise HTTPException(status_code=404, detail="VPS introuvable") + try: + return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}") + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) diff --git a/vps-monitor/backend/requirements.txt b/vps-monitor/backend/requirements.txt new file mode 100644 index 0000000..d9c3793 --- /dev/null +++ b/vps-monitor/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +aiohttp>=3.9.0 +pydantic>=2.0.0 diff --git a/vps-monitor/docker-compose.yml b/vps-monitor/docker-compose.yml new file mode 100644 index 0000000..7bf1ea1 --- /dev/null +++ b/vps-monitor/docker-compose.yml @@ -0,0 +1,17 @@ +services: + backend: + build: ./backend + ports: + - "8000:8000" + volumes: + - ./backend/data:/app/data + env_file: ./backend/.env + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped diff --git a/vps-monitor/frontend/Dockerfile b/vps-monitor/frontend/Dockerfile new file mode 100644 index 0000000..c62d91c --- /dev/null +++ b/vps-monitor/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/vps-monitor/frontend/index.html b/vps-monitor/frontend/index.html new file mode 100644 index 0000000..90c4e72 --- /dev/null +++ b/vps-monitor/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + +{value}
+{label}
+Aucun VPS configuré
+Cliquez sur Ajouter un VPS pour commencer.
+ +{container.image}
+
+ {logs || '(aucun log disponible)'}
+
+
+ >
+ )}
+ {vps.host}
+{vps.description}
+ )} + + {/* Conteneurs */} + {!collapsed && vps.online && ( +Aucun conteneur détecté.
+ ) : ( + vps.containers.map(c => ( +