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:
41
.gitea/workflows/build-push.yml
Normal file
41
.gitea/workflows/build-push.yml
Normal 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
vps-monitor/agent/.env.example
Normal file
2
vps-monitor/agent/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
AGENT_API_KEY=changeme-please
|
||||
AGENT_PORT=8001
|
||||
109
vps-monitor/agent/agent.py
Normal file
109
vps-monitor/agent/agent.py
Normal 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)
|
||||
16
vps-monitor/agent/agent.service
Normal file
16
vps-monitor/agent/agent.service
Normal 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
|
||||
3
vps-monitor/agent/requirements.txt
Normal file
3
vps-monitor/agent/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
docker>=7.1.0
|
||||
3
vps-monitor/backend/.env.example
Normal file
3
vps-monitor/backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
CONFIG_FILE=data/vps.json
|
||||
AGENT_TIMEOUT=5
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
6
vps-monitor/backend/Dockerfile
Normal file
6
vps-monitor/backend/Dockerfile
Normal 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
179
vps-monitor/backend/main.py
Normal 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))
|
||||
4
vps-monitor/backend/requirements.txt
Normal file
4
vps-monitor/backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
aiohttp>=3.9.0
|
||||
pydantic>=2.0.0
|
||||
17
vps-monitor/docker-compose.yml
Normal file
17
vps-monitor/docker-compose.yml
Normal 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
|
||||
11
vps-monitor/frontend/Dockerfile
Normal file
11
vps-monitor/frontend/Dockerfile
Normal 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
|
||||
12
vps-monitor/frontend/index.html
Normal file
12
vps-monitor/frontend/index.html
Normal 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>
|
||||
17
vps-monitor/frontend/nginx.conf
Normal file
17
vps-monitor/frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
23
vps-monitor/frontend/package.json
Normal file
23
vps-monitor/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
vps-monitor/frontend/postcss.config.js
Normal file
6
vps-monitor/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
171
vps-monitor/frontend/src/App.jsx
Normal file
171
vps-monitor/frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
vps-monitor/frontend/src/api/client.js
Normal file
42
vps-monitor/frontend/src/api/client.js
Normal 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()
|
||||
}
|
||||
96
vps-monitor/frontend/src/components/AddVpsModal.jsx
Normal file
96
vps-monitor/frontend/src/components/AddVpsModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
vps-monitor/frontend/src/components/ContainerRow.jsx
Normal file
66
vps-monitor/frontend/src/components/ContainerRow.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
vps-monitor/frontend/src/components/Header.jsx
Normal file
48
vps-monitor/frontend/src/components/Header.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
vps-monitor/frontend/src/components/LogsModal.jsx
Normal file
79
vps-monitor/frontend/src/components/LogsModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
vps-monitor/frontend/src/components/StatusBadge.jsx
Normal file
17
vps-monitor/frontend/src/components/StatusBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
vps-monitor/frontend/src/components/VpsCard.jsx
Normal file
95
vps-monitor/frontend/src/components/VpsCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
vps-monitor/frontend/src/index.css
Normal file
9
vps-monitor/frontend/src/index.css
Normal 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; }
|
||||
10
vps-monitor/frontend/src/main.jsx
Normal file
10
vps-monitor/frontend/src/main.jsx
Normal 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>
|
||||
)
|
||||
7
vps-monitor/frontend/tailwind.config.js
Normal file
7
vps-monitor/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
11
vps-monitor/frontend/vite.config.js
Normal file
11
vps-monitor/frontend/vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user