feat: add VPS Monitor backend and frontend services
Some checks failed
Build and Push Docker Images / docker (push) Failing after 5s
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user