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 @@ + + + + + + VPS Monitor + + +
+ + + diff --git a/vps-monitor/frontend/nginx.conf b/vps-monitor/frontend/nginx.conf new file mode 100644 index 0000000..f21bf9f --- /dev/null +++ b/vps-monitor/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy vers le backend FastAPI + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/vps-monitor/frontend/package.json b/vps-monitor/frontend/package.json new file mode 100644 index 0000000..925eec9 --- /dev/null +++ b/vps-monitor/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "vps-monitor-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.396.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "vite": "^5.3.1" + } +} diff --git a/vps-monitor/frontend/postcss.config.js b/vps-monitor/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/vps-monitor/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx new file mode 100644 index 0000000..f869b7e --- /dev/null +++ b/vps-monitor/frontend/src/App.jsx @@ -0,0 +1,171 @@ +import { useState, useEffect, useCallback } from 'react' +import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs } from './api/client' +import Header from './components/Header' +import VpsCard from './components/VpsCard' +import LogsModal from './components/LogsModal' +import AddVpsModal from './components/AddVpsModal' + +const REFRESH_INTERVAL = 30_000 + +export default function App() { + const [vpsList, setVpsList] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [lastUpdate, setLastUpdate] = useState(null) + const [logsModal, setLogsModal] = useState(null) // { vpsId, containerId, name } + const [logsContent, setLogsContent] = useState('') + const [logsLoading, setLogsLoading] = useState(false) + const [showAddVps, setShowAddVps] = useState(false) + + const refresh = useCallback(async (showSpinner = false) => { + if (showSpinner) setRefreshing(true) + try { + const data = await fetchAllStatus() + setVpsList(data) + setLastUpdate(new Date()) + setError(null) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + refresh() + const id = setInterval(() => refresh(), REFRESH_INTERVAL) + return () => clearInterval(id) + }, [refresh]) + + const openLogs = async (vpsId, containerId, name) => { + setLogsModal({ vpsId, containerId, name }) + setLogsLoading(true) + setLogsContent('') + try { + const data = await fetchLogs(vpsId, containerId) + setLogsContent(data.logs) + } catch (e) { + setLogsContent(`Erreur lors de la récupération des logs :\n${e.message}`) + } finally { + setLogsLoading(false) + } + } + + const handleAction = async (vpsId, containerId, action) => { + await containerAction(vpsId, containerId, action) + await refresh() + } + + const handleAddVps = async (formData) => { + await addVps(formData) + setShowAddVps(false) + await refresh(true) + } + + const handleDeleteVps = async (vpsId) => { + if (!window.confirm('Supprimer ce VPS de la configuration ?')) return + await deleteVps(vpsId) + await refresh(true) + } + + // Statistiques globales + const totalOnline = vpsList.filter(v => v.online).length + const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) + const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0) + + return ( +
+
refresh(true)} + onAddVps={() => setShowAddVps(true)} + refreshing={refreshing} + /> + +
+ + {/* Barre d'erreur backend */} + {error && ( +
+ Impossible de joindre le backend : {error} +
+ )} + + {/* Stats globales */} + {!loading && vpsList.length > 0 && ( +
+ {[ + { label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' }, + { label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' }, + { label: 'Actualisation auto', value: '30s', color: 'text-gray-400' }, + ].map(({ label, value, color }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ )} + + {/* Chargement initial */} + {loading && ( +
+ + + + Chargement… +
+ )} + + {/* Aucun VPS */} + {!loading && vpsList.length === 0 && !error && ( +
+

Aucun VPS configuré

+

Cliquez sur Ajouter un VPS pour commencer.

+ +
+ )} + + {/* Grille de VPS */} + {!loading && vpsList.length > 0 && ( +
+ {vpsList.map(vps => ( + + ))} +
+ )} +
+ + {/* Modal logs */} + {logsModal && ( + setLogsModal(null)} + /> + )} + + {/* Modal ajout VPS */} + {showAddVps && ( + setShowAddVps(false)} + /> + )} +
+ ) +} diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js new file mode 100644 index 0000000..2e36c77 --- /dev/null +++ b/vps-monitor/frontend/src/api/client.js @@ -0,0 +1,42 @@ +const BASE = '/api' + +export async function fetchAllStatus() { + const res = await fetch(`${BASE}/status`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() +} + +export async function fetchLogs(vpsId, containerId, lines = 200) { + const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/logs?lines=${lines}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() +} + +export async function containerAction(vpsId, containerId, action) { + const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() +} + +export async function addVps(data) { + const res = await fetch(`${BASE}/vps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail ?? `HTTP ${res.status}`) + } + return res.json() +} + +export async function deleteVps(vpsId) { + const res = await fetch(`${BASE}/vps/${vpsId}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() +} diff --git a/vps-monitor/frontend/src/components/AddVpsModal.jsx b/vps-monitor/frontend/src/components/AddVpsModal.jsx new file mode 100644 index 0000000..59cecef --- /dev/null +++ b/vps-monitor/frontend/src/components/AddVpsModal.jsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from 'react' +import { X } from 'lucide-react' + +const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '' } + +const FIELDS = [ + { key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' }, + { key: 'id', label: 'Identifiant unique', placeholder: 'vps-1', required: true, type: 'text' }, + { key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' }, + { key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' }, + { key: 'api_key', label: 'Clé API agent', placeholder: '••••••••', required: true, type: 'password' }, + { key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' }, +] + +export default function AddVpsModal({ onSave, onClose }) { + const [form, setForm] = useState(DEFAULTS) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onClose]) + + const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value })) + + const handleSubmit = async (e) => { + e.preventDefault() + setSaving(true) + setError('') + try { + await onSave({ ...form, port: parseInt(form.port, 10) }) + } catch (err) { + setError(err.message) + setSaving(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+

Ajouter un VPS

+ +
+ +
+ {FIELDS.map(({ key, label, placeholder, required, type }) => ( +
+ + +
+ ))} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/ContainerRow.jsx b/vps-monitor/frontend/src/components/ContainerRow.jsx new file mode 100644 index 0000000..5a10ba6 --- /dev/null +++ b/vps-monitor/frontend/src/components/ContainerRow.jsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { Play, Square, RotateCcw, FileText, Loader2 } from 'lucide-react' +import StatusBadge from './StatusBadge' + +export default function ContainerRow({ container, onAction, onLogs }) { + const [pending, setPending] = useState(null) + const isRunning = container.status === 'running' + + const handle = async (action) => { + setPending(action) + try { await onAction(action) } finally { setPending(null) } + } + + return ( +
+
+
+ {container.name} + + {container.compose_project && ( + + {container.compose_project} + + )} +
+

{container.image}

+
+ +
+ {!isRunning && ( + handle('start')} loading={pending === 'start'}> + + + )} + {isRunning && ( + handle('stop')} loading={pending === 'stop'} danger> + + + )} + handle('restart')} loading={pending === 'restart'}> + + + + + +
+
+ ) +} + +function ActionBtn({ children, onClick, title, danger = false, loading = false }) { + return ( + + ) +} diff --git a/vps-monitor/frontend/src/components/Header.jsx b/vps-monitor/frontend/src/components/Header.jsx new file mode 100644 index 0000000..b5be11b --- /dev/null +++ b/vps-monitor/frontend/src/components/Header.jsx @@ -0,0 +1,48 @@ +import { Monitor } from 'lucide-react' + +export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing }) { + return ( +
+
+
+
+ +
+ VPS Monitor + {lastUpdate && ( + + · mis à jour {lastUpdate.toLocaleTimeString('fr-FR')} + + )} +
+ +
+ + +
+
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/LogsModal.jsx b/vps-monitor/frontend/src/components/LogsModal.jsx new file mode 100644 index 0000000..db417c5 --- /dev/null +++ b/vps-monitor/frontend/src/components/LogsModal.jsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react' +import { X, Download } from 'lucide-react' + +export default function LogsModal({ name, logs, loading, onClose }) { + const bottomRef = useRef(null) + + useEffect(() => { + if (!loading && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [logs, loading]) + + const handleDownload = () => { + const blob = new Blob([logs], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${name.replace(/[^a-z0-9]/gi, '_')}.log` + a.click() + URL.revokeObjectURL(url) + } + + // Fermeture sur Échap + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onClose]) + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* Header */} +
+

📄 {name}

+
+ {logs && ( + + )} + +
+
+ + {/* Logs */} +
+ {loading ? ( +
+ + + + Chargement des logs… +
+ ) : ( + <> +
+                {logs || '(aucun log disponible)'}
+              
+
+ + )} +
+
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/StatusBadge.jsx b/vps-monitor/frontend/src/components/StatusBadge.jsx new file mode 100644 index 0000000..ffd865a --- /dev/null +++ b/vps-monitor/frontend/src/components/StatusBadge.jsx @@ -0,0 +1,17 @@ +const STATUSES = { + running: { dot: 'bg-emerald-400 animate-pulse', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20' }, + exited: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20' }, + paused: { dot: 'bg-yellow-400', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20' }, + restarting: { dot: 'bg-blue-400 animate-pulse', text: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' }, + dead: { dot: 'bg-gray-500', text: 'text-gray-500', bg: 'bg-gray-500/10 border-gray-500/20' }, +} + +export default function StatusBadge({ status }) { + const s = STATUSES[status] ?? STATUSES.dead + return ( + + + {status} + + ) +} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx new file mode 100644 index 0000000..c4c87fe --- /dev/null +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -0,0 +1,95 @@ +import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp } from 'lucide-react' +import { useState } from 'react' +import ContainerRow from './ContainerRow' + +export default function VpsCard({ vps, onAction, onLogs, onDelete }) { + const [collapsed, setCollapsed] = useState(false) + + const running = vps.containers.filter(c => c.status === 'running').length + const total = vps.containers.length + + return ( +
+ {/* Header */} +
+
+ +
+ +
+

{vps.name}

+

{vps.host}

+
+ +
+ {vps.online ? ( + + + {running}/{total} actifs + + ) : ( + + + Hors ligne + + )} + + + + +
+
+ + {/* Erreur de connexion */} + {!vps.online && vps.error && ( +
+ {vps.error} +
+ )} + + {/* Description */} + {vps.description && !collapsed && ( +

{vps.description}

+ )} + + {/* Conteneurs */} + {!collapsed && vps.online && ( +
+ {total === 0 ? ( +

Aucun conteneur détecté.

+ ) : ( + vps.containers.map(c => ( + onAction(vps.id, c.id, action)} + onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)} + /> + )) + )} +
+ )} + + {/* Footer stats */} + {!collapsed && vps.online && total > 0 && ( +
+ {running} en cours + {total - running} arrêtés + {total} total +
+ )} +
+ ) +} diff --git a/vps-monitor/frontend/src/index.css b/vps-monitor/frontend/src/index.css new file mode 100644 index 0000000..542ea6f --- /dev/null +++ b/vps-monitor/frontend/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Scrollbar minimaliste */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #4b5563; } diff --git a/vps-monitor/frontend/src/main.jsx b/vps-monitor/frontend/src/main.jsx new file mode 100644 index 0000000..9af0bb6 --- /dev/null +++ b/vps-monitor/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/vps-monitor/frontend/tailwind.config.js b/vps-monitor/frontend/tailwind.config.js new file mode 100644 index 0000000..aa946de --- /dev/null +++ b/vps-monitor/frontend/tailwind.config.js @@ -0,0 +1,7 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + darkMode: 'class', + theme: { extend: {} }, + plugins: [], +} diff --git a/vps-monitor/frontend/vite.config.js b/vps-monitor/frontend/vite.config.js new file mode 100644 index 0000000..69163ad --- /dev/null +++ b/vps-monitor/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8000', + }, + }, +})