feat: add VPS Monitor backend and frontend services
Some checks failed
Build and Push Docker Images / docker (push) Failing after 5s

- Create systemd service for VPS Monitor agent.
- Add FastAPI backend with endpoints for managing VPS configurations and statuses.
- Implement Dockerfile for backend service with required dependencies.
- Create frontend using React with Vite and Tailwind CSS for styling.
- Add API client for communicating with the backend.
- Implement components for displaying VPS information and logs.
- Set up Docker Compose for orchestrating backend and frontend services.
- Add environment configuration files for backend and agent.
- Implement CORS support in the backend for frontend communication.
This commit is contained in:
jeanotx32
2026-05-18 22:31:36 -04:00
parent f83f8f97fa
commit cf0b3f0acf
28 changed files with 1601 additions and 16 deletions

View File

@@ -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

View File

@@ -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)
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 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
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:

View File

@@ -0,0 +1,2 @@
AGENT_API_KEY=changeme-please
AGENT_PORT=8001

109
vps-monitor/agent/agent.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,3 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
docker>=7.1.0

View File

@@ -0,0 +1,3 @@
CONFIG_FILE=data/vps.json
AGENT_TIMEOUT=5
CORS_ORIGINS=http://localhost:5173,http://localhost:3000

View File

@@ -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"]

179
vps-monitor/backend/main.py Normal file
View File

@@ -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))

View File

@@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
aiohttp>=3.9.0
pydantic>=2.0.0

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPS Monitor</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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 (
<div className="min-h-screen bg-gray-950 text-gray-100">
<Header
lastUpdate={lastUpdate}
onRefresh={() => refresh(true)}
onAddVps={() => setShowAddVps(true)}
refreshing={refreshing}
/>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Barre d'erreur backend */}
{error && (
<div className="mb-6 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
Impossible de joindre le backend : <span className="font-mono">{error}</span>
</div>
)}
{/* Stats globales */}
{!loading && vpsList.length > 0 && (
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ 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 }) => (
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
))}
</div>
)}
{/* Chargement initial */}
{loading && (
<div className="text-center py-24 text-gray-600">
<svg className="w-8 h-8 animate-spin mx-auto mb-3 text-indigo-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement
</div>
)}
{/* Aucun VPS */}
{!loading && vpsList.length === 0 && !error && (
<div className="text-center py-24 text-gray-600">
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p>
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p>
<button
onClick={() => setShowAddVps(true)}
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors"
>
Ajouter un VPS
</button>
</div>
)}
{/* Grille de VPS */}
{!loading && vpsList.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2">
{vpsList.map(vps => (
<VpsCard
key={vps.id}
vps={vps}
onAction={handleAction}
onLogs={openLogs}
onDelete={handleDeleteVps}
/>
))}
</div>
)}
</main>
{/* Modal logs */}
{logsModal && (
<LogsModal
name={logsModal.name}
logs={logsContent}
loading={logsLoading}
onClose={() => setLogsModal(null)}
/>
)}
{/* Modal ajout VPS */}
{showAddVps && (
<AddVpsModal
onSave={handleAddVps}
onClose={() => setShowAddVps(false)}
/>
)}
</div>
)
}

View File

@@ -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()
}

View File

@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-md bg-gray-900 border border-gray-700 rounded-xl shadow-2xl">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<h3 className="font-semibold text-sm">Ajouter un VPS</h3>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors">
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-3">
{FIELDS.map(({ key, label, placeholder, required, type }) => (
<div key={key}>
<label className="block text-xs text-gray-400 mb-1">
{label} {required && <span className="text-red-400">*</span>}
</label>
<input
type={type}
value={form[key]}
onChange={set(key)}
placeholder={placeholder}
required={required}
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
))}
{error && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
{error}
</p>
)}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
>
{saving ? 'Enregistrement…' : 'Ajouter'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/40 transition-colors group">
<div className="min-w-0 flex-1 pr-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
<StatusBadge status={container.status} />
{container.compose_project && (
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{container.compose_project}
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate mt-0.5">{container.image}</p>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isRunning && (
<ActionBtn title="Démarrer" onClick={() => handle('start')} loading={pending === 'start'}>
<Play size={13} />
</ActionBtn>
)}
{isRunning && (
<ActionBtn title="Arrêter" onClick={() => handle('stop')} loading={pending === 'stop'} danger>
<Square size={13} />
</ActionBtn>
)}
<ActionBtn title="Redémarrer" onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} />
</ActionBtn>
<ActionBtn title="Logs" onClick={onLogs}>
<FileText size={13} />
</ActionBtn>
</div>
</div>
)
}
function ActionBtn({ children, onClick, title, danger = false, loading = false }) {
return (
<button
onClick={onClick}
title={title}
disabled={loading}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'
: 'hover:bg-gray-700 text-gray-500 hover:text-gray-200'
}`}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : children}
</button>
)
}

View File

@@ -0,0 +1,48 @@
import { Monitor } from 'lucide-react'
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing }) {
return (
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-indigo-500/15">
<Monitor size={18} className="text-indigo-400" />
</div>
<span className="font-semibold">VPS Monitor</span>
{lastUpdate && (
<span className="hidden sm:block text-xs text-gray-500 ml-2">
· mis à jour {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`}
fill="none" stroke="currentColor" strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualiser
</button>
<button
onClick={onAddVps}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-indigo-600 hover:bg-indigo-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Ajouter un VPS
</button>
</div>
</div>
</header>
)
}

View File

@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-4xl bg-gray-900 border border-gray-700 rounded-xl flex flex-col max-h-[85vh] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 flex-shrink-0">
<h3 className="font-mono text-sm text-gray-300 truncate">📄 {name}</h3>
<div className="flex items-center gap-2">
{logs && (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
>
<Download size={12} />
Télécharger
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Logs */}
<div className="flex-1 overflow-auto bg-gray-950 rounded-b-xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement des logs
</div>
) : (
<>
<pre className="text-xs font-mono text-gray-300 whitespace-pre-wrap leading-5 break-all">
{logs || '(aucun log disponible)'}
</pre>
<div ref={bottomRef} />
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -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 (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${s.bg} ${s.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.dot}`} />
{status}
</span>
)
}

View File

@@ -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 (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<div className={`p-2 rounded-lg flex-shrink-0 ${vps.online ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
<Server size={15} className={vps.online ? 'text-emerald-400' : 'text-red-400'} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">{vps.name}</h3>
<p className="text-xs text-gray-500 truncate">{vps.host}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{vps.online ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
<Wifi size={12} />
<span className="hidden sm:inline">{running}/{total} actifs</span>
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<WifiOff size={12} />
<span className="hidden sm:inline">Hors ligne</span>
</span>
)}
<button
onClick={() => setCollapsed(c => !c)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
title={collapsed ? 'Déplier' : 'Replier'}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
<button
onClick={() => onDelete(vps.id)}
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
title="Supprimer ce VPS"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Erreur de connexion */}
{!vps.online && vps.error && (
<div className="px-4 py-2.5 bg-red-950/30 border-b border-red-900/30 text-xs text-red-400 font-mono">
{vps.error}
</div>
)}
{/* Description */}
{vps.description && !collapsed && (
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
)}
{/* Conteneurs */}
{!collapsed && vps.online && (
<div className="divide-y divide-gray-800/50 flex-1">
{total === 0 ? (
<p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p>
) : (
vps.containers.map(c => (
<ContainerRow
key={c.id}
container={c}
onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
/>
))
)}
</div>
)}
{/* Footer stats */}
{!collapsed && vps.online && total > 0 && (
<div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600">
<span><span className="text-emerald-500">{running}</span> en cours</span>
<span><span className="text-gray-400">{total - running}</span> arrêtés</span>
<span><span className="text-gray-400">{total}</span> total</span>
</div>
)}
</div>
)
}

View File

@@ -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; }

View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
darkMode: 'class',
theme: { extend: {} },
plugins: [],
}

View File

@@ -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',
},
},
})