feat: add interactive terminal support for containers via WebSocket
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
- Implemented WebSocket endpoint for executing interactive shells in Docker containers. - Added ticket-based authentication for terminal access to enhance security. - Updated frontend to support terminal interactions, including lazy loading of terminal components. - Enhanced backend to manage terminal session tickets and settings for enabling/disabling terminal access. - Updated Nginx configuration to support WebSocket connections for terminal sessions. - Added necessary dependencies for terminal functionality in the frontend. - Improved user interface to include terminal access controls and feedback on terminal status.
This commit is contained in:
@@ -140,6 +140,7 @@ Le fichier `.env` supporte les variables suivantes :
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `AGENT_API_KEY` | *(généré)* | Clé secrète partagée avec le backend |
|
| `AGENT_API_KEY` | *(généré)* | Clé secrète partagée avec le backend |
|
||||||
| `AGENT_PORT` | `8001` | Port TCP d'écoute |
|
| `AGENT_PORT` | `8001` | Port TCP d'écoute |
|
||||||
|
| `AGENT_MAX_EXEC_SESSIONS` | `10` | Nombre de terminaux ouverts simultanément (voir ci-dessous) |
|
||||||
|
|
||||||
Pour modifier la configuration sans réinstaller :
|
Pour modifier la configuration sans réinstaller :
|
||||||
|
|
||||||
@@ -150,6 +151,41 @@ sudo systemctl restart vps-monitor-agent
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Terminal interactif (agent 1.3.0+)
|
||||||
|
|
||||||
|
Depuis la version 1.3.0, l'agent expose un WebSocket `/containers/{id}/exec` qui
|
||||||
|
ouvre un shell dans un conteneur, l'équivalent de `docker exec -it <conteneur> bash`
|
||||||
|
(repli automatique sur `sh` si l'image n'a pas bash). L'interface web s'en sert
|
||||||
|
pour le bouton « terminal » de chaque conteneur démarré.
|
||||||
|
|
||||||
|
À savoir :
|
||||||
|
|
||||||
|
- **Réservé aux administrateurs.** Le backend n'ouvre le WebSocket que pour un
|
||||||
|
compte de rôle `admin`, après échange du JWT contre un ticket à usage unique
|
||||||
|
valable 60 secondes — le jeton d'authentification ne transite donc jamais en
|
||||||
|
query string.
|
||||||
|
- **Désactivable** globalement depuis *Administration → Paramètres → Terminal des
|
||||||
|
conteneurs*.
|
||||||
|
- **Portée réelle des droits** : le shell s'exécute avec l'utilisateur par défaut
|
||||||
|
de l'image, souvent `root` *dans le conteneur*. Un conteneur privilégié ou avec
|
||||||
|
le socket Docker monté permet d'atteindre l'hôte — n'ouvrez ce droit qu'à des
|
||||||
|
comptes de confiance.
|
||||||
|
- **Mise à jour** : bouton « Mettre à jour l'agent » dans l'interface, ou
|
||||||
|
`sudo bash install.sh --update`. Les agents < 1.3.0 affichent un bouton terminal
|
||||||
|
désactivé expliquant qu'une mise à jour est nécessaire.
|
||||||
|
|
||||||
|
Si l'interface est servie derrière un reverse proxy maison (autre que le nginx
|
||||||
|
fourni), pensez à y relayer les WebSockets :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dépôt source
|
## Dépôt source
|
||||||
|
|
||||||
[https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS)
|
[https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ VPS Monitor Agent — à déployer sur chaque VPS.
|
|||||||
Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker.
|
Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket as socket_module
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -13,13 +16,13 @@ from datetime import datetime, timezone
|
|||||||
import docker
|
import docker
|
||||||
import psutil
|
import psutil
|
||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, NotFound
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Security
|
from fastapi import Depends, FastAPI, HTTPException, Security, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
AGENT_VERSION = "1.2.0"
|
AGENT_VERSION = "1.3.0"
|
||||||
|
|
||||||
REPO_BASE = os.getenv("AGENT_REPO_BASE", "https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main")
|
REPO_BASE = os.getenv("AGENT_REPO_BASE", "https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main")
|
||||||
INSTALL_DIR = os.getenv("AGENT_INSTALL_DIR", "/opt/vps-monitor-agent")
|
INSTALL_DIR = os.getenv("AGENT_INSTALL_DIR", "/opt/vps-monitor-agent")
|
||||||
@@ -154,6 +157,190 @@ def container_action(container_id: str, action: str, _: None = Depends(require_a
|
|||||||
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Terminal interactif (WebSocket) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# Choisit bash s'il est présent dans l'image, sinon sh — beaucoup d'images
|
||||||
|
# (alpine, distroless-ish) n'embarquent pas bash.
|
||||||
|
_SHELL_PICKER = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
|
||||||
|
|
||||||
|
# Nombre maximal de sessions terminal simultanées, pour éviter qu'un client
|
||||||
|
# emballé n'ouvre des exec à l'infini.
|
||||||
|
_MAX_EXEC_SESSIONS = int(os.getenv("AGENT_MAX_EXEC_SESSIONS", "10"))
|
||||||
|
_exec_sessions = 0
|
||||||
|
_exec_sessions_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _sock_recv(sock, size=4096):
|
||||||
|
"""Lecture bloquante — `recv` sur une vraie socket, `read` sur un SocketIO."""
|
||||||
|
try:
|
||||||
|
if hasattr(sock, "recv"):
|
||||||
|
return sock.recv(size)
|
||||||
|
return sock.read(size)
|
||||||
|
except OSError:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def _sock_send(sock, data: bytes) -> None:
|
||||||
|
if hasattr(sock, "sendall"):
|
||||||
|
sock.sendall(data)
|
||||||
|
else:
|
||||||
|
sock.write(data)
|
||||||
|
sock.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _sock_close(sock) -> None:
|
||||||
|
"""Ferme la socket : débloque aussi le thread resté dans recv()."""
|
||||||
|
try:
|
||||||
|
if hasattr(sock, "shutdown"):
|
||||||
|
sock.shutdown(socket_module.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/containers/{container_id}/exec")
|
||||||
|
async def exec_terminal(websocket: WebSocket, container_id: str, cols: int = 80, rows: int = 24):
|
||||||
|
"""Shell interactif dans un conteneur (équivalent `docker exec -it`).
|
||||||
|
|
||||||
|
Protocole :
|
||||||
|
• client → agent : JSON texte {"t":"i","d":"…"} (saisie) ou
|
||||||
|
{"t":"r","cols":N,"rows":N} (redimensionnement) ;
|
||||||
|
• agent → client : trames binaires (sortie brute du TTY) et JSON texte
|
||||||
|
pour les messages d'état ({"t":"error"|"exit"}).
|
||||||
|
|
||||||
|
L'authentification se fait par l'en-tête X-API-Key : le navigateur ne peut
|
||||||
|
pas en poser sur un WebSocket, mais c'est le backend central qui se connecte
|
||||||
|
ici, et lui le peut.
|
||||||
|
"""
|
||||||
|
global _exec_sessions
|
||||||
|
|
||||||
|
if websocket.headers.get("x-api-key") != API_KEY:
|
||||||
|
await websocket.close(code=1008, reason="Clé API invalide")
|
||||||
|
return
|
||||||
|
|
||||||
|
with _exec_sessions_lock:
|
||||||
|
if _exec_sessions >= _MAX_EXEC_SESSIONS:
|
||||||
|
await websocket.close(code=1013, reason="Trop de sessions terminal ouvertes")
|
||||||
|
return
|
||||||
|
_exec_sessions += 1
|
||||||
|
|
||||||
|
stream = None
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = docker.from_env()
|
||||||
|
container = client.containers.get(container_id)
|
||||||
|
except NotFound:
|
||||||
|
await websocket.send_text(json.dumps({"t": "error", "m": "Conteneur introuvable."}))
|
||||||
|
return
|
||||||
|
except DockerException as e:
|
||||||
|
await websocket.send_text(json.dumps({"t": "error", "m": f"Docker inaccessible : {e}"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
if container.status != "running":
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"t": "error",
|
||||||
|
"m": f"Le conteneur est « {container.status} » — démarrez-le pour ouvrir un terminal.",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
api = client.api
|
||||||
|
exec_id = api.exec_create(
|
||||||
|
container.id,
|
||||||
|
cmd=["/bin/sh", "-c", _SHELL_PICKER],
|
||||||
|
stdin=True, stdout=True, stderr=True, tty=True,
|
||||||
|
)["Id"]
|
||||||
|
|
||||||
|
stream = api.exec_start(exec_id, tty=True, socket=True, demux=False)
|
||||||
|
sock = getattr(stream, "_sock", stream)
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.exec_resize(exec_id, height=rows, width=cols)
|
||||||
|
except Exception:
|
||||||
|
pass # non bloquant : le shell tourne, seule la taille est approximative
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
async def pump_output():
|
||||||
|
"""Sortie du TTY → client, en binaire (xterm gère l'UTF-8 partiel)."""
|
||||||
|
while True:
|
||||||
|
chunk = await loop.run_in_executor(None, _sock_recv, sock)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
await websocket.send_bytes(chunk)
|
||||||
|
|
||||||
|
async def pump_input():
|
||||||
|
"""Saisie et redimensionnements du client → TTY."""
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
|
||||||
|
text = message.get("text")
|
||||||
|
if text is not None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
kind = payload.get("t")
|
||||||
|
if kind == "i":
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, _sock_send, sock, payload.get("d", "").encode("utf-8")
|
||||||
|
)
|
||||||
|
elif kind == "r":
|
||||||
|
try:
|
||||||
|
api.exec_resize(
|
||||||
|
exec_id,
|
||||||
|
height=int(payload.get("rows", rows)),
|
||||||
|
width=int(payload.get("cols", cols)),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = message.get("bytes")
|
||||||
|
if data:
|
||||||
|
await loop.run_in_executor(None, _sock_send, sock, data)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(pump_output()), asyncio.create_task(pump_input())]
|
||||||
|
try:
|
||||||
|
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
# Fermer la socket débloque le thread encore dans recv().
|
||||||
|
_sock_close(sock)
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = api.exec_inspect(exec_id).get("ExitCode")
|
||||||
|
await websocket.send_text(json.dumps({"t": "exit", "code": code}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if sock is not None:
|
||||||
|
_sock_close(sock)
|
||||||
|
if stream is not None and stream is not sock:
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with _exec_sessions_lock:
|
||||||
|
_exec_sessions -= 1
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.get("/system")
|
@app.get("/system")
|
||||||
def system_info(_: None = Depends(require_api_key)):
|
def system_info(_: None = Depends(require_api_key)):
|
||||||
"""Retourne les informations système : CPU, RAM et bande passante."""
|
"""Retourne les informations système : CPU, RAM et bande passante."""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
@@ -265,6 +265,9 @@ def init_db() -> None:
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '')
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '')
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('terminal_enabled', 'true')
|
||||||
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS container_events (
|
CREATE TABLE IF NOT EXISTS container_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -507,6 +510,49 @@ def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> d
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tickets de terminal ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Un WebSocket ouvert par le navigateur ne peut pas porter d'en-tête
|
||||||
|
# Authorization. Plutôt que de faire transiter le JWT en query string (où il
|
||||||
|
# finirait dans les logs d'accès nginx et l'historique du navigateur), le client
|
||||||
|
# échange d'abord son JWT contre un ticket à usage unique et de courte durée.
|
||||||
|
|
||||||
|
EXEC_TICKET_TTL = 60 # secondes
|
||||||
|
_exec_tickets: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_exec_tickets() -> None:
|
||||||
|
now = time.time()
|
||||||
|
for ticket, info in list(_exec_tickets.items()):
|
||||||
|
if info["expires_at"] < now:
|
||||||
|
del _exec_tickets[ticket]
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_exec_ticket(vps_id: str, container_id: str, username: str) -> str:
|
||||||
|
_prune_exec_tickets()
|
||||||
|
ticket = secrets.token_urlsafe(32)
|
||||||
|
_exec_tickets[ticket] = {
|
||||||
|
"vps_id": vps_id,
|
||||||
|
"container_id": container_id,
|
||||||
|
"username": username,
|
||||||
|
"expires_at": time.time() + EXEC_TICKET_TTL,
|
||||||
|
}
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_exec_ticket(ticket: str, vps_id: str, container_id: str) -> dict | None:
|
||||||
|
"""Valide et invalide un ticket : il ne sert qu'une fois."""
|
||||||
|
_prune_exec_tickets()
|
||||||
|
info = _exec_tickets.pop(ticket, None)
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
if info["vps_id"] != vps_id or info["container_id"] != container_id:
|
||||||
|
return None
|
||||||
|
if info["expires_at"] < time.time():
|
||||||
|
return None
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
def _get_client_ip(request: Request) -> str:
|
def _get_client_ip(request: Request) -> str:
|
||||||
forwarded = request.headers.get("X-Forwarded-For")
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
if forwarded:
|
if forwarded:
|
||||||
@@ -803,6 +849,7 @@ def auth_status():
|
|||||||
return {
|
return {
|
||||||
"has_users": len(load_users()) > 0,
|
"has_users": len(load_users()) > 0,
|
||||||
"passkey_enabled": _get_setting("passkey_enabled") == "true",
|
"passkey_enabled": _get_setting("passkey_enabled") == "true",
|
||||||
|
"terminal_enabled": _get_setting("terminal_enabled") != "false",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -887,7 +934,8 @@ def admin_update_setting(
|
|||||||
_: Annotated[dict, Depends(require_admin)],
|
_: Annotated[dict, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Met à jour un paramètre d'administration."""
|
"""Met à jour un paramètre d'administration."""
|
||||||
allowed_keys = {"registration_open", "passkey_enabled", "pushover_enabled", "pushover_app_token", "pushover_user_key"}
|
allowed_keys = {"registration_open", "passkey_enabled", "pushover_enabled",
|
||||||
|
"pushover_app_token", "pushover_user_key", "terminal_enabled"}
|
||||||
if key not in allowed_keys:
|
if key not in allowed_keys:
|
||||||
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -1424,6 +1472,105 @@ async def container_action(
|
|||||||
raise HTTPException(status_code=502, detail=str(e))
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/vps/{vps_id}/containers/{container_id}/exec/ticket")
|
||||||
|
async def container_exec_ticket(
|
||||||
|
vps_id: str, container_id: str,
|
||||||
|
current_user: Annotated[dict, Depends(require_admin)],
|
||||||
|
):
|
||||||
|
"""Délivre un ticket à usage unique pour ouvrir un terminal sur un conteneur.
|
||||||
|
|
||||||
|
Réservé aux administrateurs : un shell dans un conteneur donne bien plus de
|
||||||
|
pouvoir que les actions start/stop.
|
||||||
|
"""
|
||||||
|
if _get_setting("terminal_enabled") == "false":
|
||||||
|
raise HTTPException(status_code=403, detail="Terminal désactivé par l'administrateur")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
ticket = _issue_exec_ticket(vps_id, container_id, current_user["username"])
|
||||||
|
return {"ticket": ticket, "expires_in": EXEC_TICKET_TTL}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/vps/{vps_id}/containers/{container_id}/exec")
|
||||||
|
async def container_exec(
|
||||||
|
websocket: WebSocket, vps_id: str, container_id: str,
|
||||||
|
ticket: str = "", cols: int = 80, rows: int = 24,
|
||||||
|
):
|
||||||
|
"""Relaie le terminal du navigateur vers l'agent du VPS.
|
||||||
|
|
||||||
|
Le backend ne fait que pomper les trames dans les deux sens : c'est l'agent
|
||||||
|
qui parle à Docker. Les trames binaires portent la sortie du TTY, les trames
|
||||||
|
texte les messages JSON (saisie, redimensionnement, erreurs).
|
||||||
|
"""
|
||||||
|
session = _consume_exec_ticket(ticket, vps_id, container_id)
|
||||||
|
if not session:
|
||||||
|
await websocket.close(code=1008, reason="Ticket invalide ou expiré")
|
||||||
|
return
|
||||||
|
|
||||||
|
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
|
||||||
|
if not vps:
|
||||||
|
await websocket.close(code=1011, reason="VPS introuvable")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
url = (f"ws://{vps['host']}:{vps['port']}/containers/{container_id}/exec"
|
||||||
|
f"?cols={cols}&rows={rows}")
|
||||||
|
headers = {"X-API-Key": vps["api_key"]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(headers=headers) as http:
|
||||||
|
async with http.ws_connect(url, heartbeat=30) as agent_ws:
|
||||||
|
|
||||||
|
async def client_to_agent():
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
if message.get("text") is not None:
|
||||||
|
await agent_ws.send_str(message["text"])
|
||||||
|
elif message.get("bytes") is not None:
|
||||||
|
await agent_ws.send_bytes(message["bytes"])
|
||||||
|
|
||||||
|
async def agent_to_client():
|
||||||
|
async for message in agent_ws:
|
||||||
|
if message.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
await websocket.send_bytes(message.data)
|
||||||
|
elif message.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await websocket.send_text(message.data)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(client_to_agent()),
|
||||||
|
asyncio.create_task(agent_to_client())]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# Cas le plus fréquent : agent < 1.3.0, qui n'expose pas encore /exec.
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"t": "error",
|
||||||
|
"m": f"Connexion à l'agent impossible ({e}). "
|
||||||
|
f"Vérifiez que l'agent est en version 1.3.0 ou supérieure.",
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/vps/{vps_id}/compose/update")
|
@app.post("/api/vps/{vps_id}/compose/update")
|
||||||
async def compose_update(
|
async def compose_update(
|
||||||
vps_id: str, body: ComposeUpdateRequest,
|
vps_id: str, body: ComposeUpdateRequest,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
# Bascule l'en-tête Connection selon qu'il s'agit d'un WebSocket ou non —
|
||||||
|
# nécessaire pour le terminal des conteneurs, qui passe par /api/….
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -8,6 +15,16 @@ server {
|
|||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
# Relais WebSocket (terminal interactif)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# Un terminal ouvert peut rester inactif longtemps sans être coupé
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
|
|||||||
30
vps-monitor/frontend/node_modules/.package-lock.json
generated
vendored
30
vps-monitor/frontend/node_modules/.package-lock.json
generated
vendored
@@ -498,6 +498,21 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@@ -900,6 +915,21 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
17
vps-monitor/frontend/package-lock.json
generated
17
vps-monitor/frontend/package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "vps-monitor-frontend",
|
"name": "vps-monitor-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"lucide-react": "^0.396.0",
|
"lucide-react": "^0.396.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -1224,6 +1226,21 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"lucide-react": "^0.396.0",
|
"lucide-react": "^0.396.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'
|
||||||
import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react'
|
import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react'
|
||||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
|
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
@@ -16,6 +16,9 @@ import { useToast } from './components/ui/Toast'
|
|||||||
import { Button, EmptyState, Skeleton } from './components/ui/controls'
|
import { Button, EmptyState, Skeleton } from './components/ui/controls'
|
||||||
import { copyText, downloadText } from './lib/clipboard'
|
import { copyText, downloadText } from './lib/clipboard'
|
||||||
|
|
||||||
|
// xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal.
|
||||||
|
const TerminalModal = lazy(() => import('./components/TerminalModal'))
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
{ label: '10 s', value: 10_000 },
|
{ label: '10 s', value: 10_000 },
|
||||||
{ label: '30 s', value: 30_000 },
|
{ label: '30 s', value: 30_000 },
|
||||||
@@ -60,6 +63,7 @@ export default function App() {
|
|||||||
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
|
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
|
||||||
const [isFirstUser, setIsFirstUser] = useState(false)
|
const [isFirstUser, setIsFirstUser] = useState(false)
|
||||||
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
|
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
|
||||||
|
const [terminalEnabled, setTerminalEnabled] = useState(true)
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
const [vpsList, setVpsList] = useState([])
|
const [vpsList, setVpsList] = useState([])
|
||||||
@@ -117,13 +121,15 @@ export default function App() {
|
|||||||
const [updateLoading, setUpdateLoading] = useState(false)
|
const [updateLoading, setUpdateLoading] = useState(false)
|
||||||
|
|
||||||
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
|
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
|
||||||
|
const [terminalModal, setTerminalModal] = useState(null) // { vps, container }
|
||||||
|
|
||||||
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authStatus()
|
authStatus()
|
||||||
.then(({ has_users, passkey_enabled }) => {
|
.then(({ has_users, passkey_enabled, terminal_enabled }) => {
|
||||||
setIsFirstUser(!has_users)
|
setIsFirstUser(!has_users)
|
||||||
setPasskeyEnabled(!!passkey_enabled)
|
setPasskeyEnabled(!!passkey_enabled)
|
||||||
|
setTerminalEnabled(terminal_enabled !== false)
|
||||||
})
|
})
|
||||||
.catch(() => setIsFirstUser(false))
|
.catch(() => setIsFirstUser(false))
|
||||||
.finally(() => setAuthChecked(true))
|
.finally(() => setAuthChecked(true))
|
||||||
@@ -200,7 +206,7 @@ export default function App() {
|
|||||||
}, [token, username])
|
}, [token, username])
|
||||||
|
|
||||||
// Raccourcis clavier : « / » cible la recherche, « r » actualise.
|
// Raccourcis clavier : « / » cible la recherche, « r » actualise.
|
||||||
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget)
|
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || page !== 'main' || modalOpen) return
|
if (!token || page !== 'main' || modalOpen) return
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
@@ -369,6 +375,10 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le terminal donne un shell root dans le conteneur : réservé aux admins, et
|
||||||
|
// désactivable globalement depuis la page d'administration.
|
||||||
|
const canUseTerminal = role === 'admin' && terminalEnabled
|
||||||
|
|
||||||
// Statistiques globales
|
// Statistiques globales
|
||||||
const totalOnline = vpsList.filter(v => v.online).length
|
const totalOnline = vpsList.filter(v => v.online).length
|
||||||
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
|
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
|
||||||
@@ -511,6 +521,7 @@ export default function App() {
|
|||||||
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
|
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
|
||||||
onUpdateAgent={handleUpdateAgent}
|
onUpdateAgent={handleUpdateAgent}
|
||||||
onExport={handleExportVps}
|
onExport={handleExportVps}
|
||||||
|
onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -563,6 +574,21 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Terminal interactif */}
|
||||||
|
{terminalModal && (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-sm text-gray-400">
|
||||||
|
Chargement du terminal…
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<TerminalModal
|
||||||
|
vps={terminalModal.vps}
|
||||||
|
container={terminalModal.container}
|
||||||
|
onClose={() => setTerminalModal(null)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Confirmation de suppression */}
|
{/* Confirmation de suppression */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -127,6 +127,27 @@ export async function fetchVpsStats(vpsId, duration = 600) {
|
|||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Terminal conteneur ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Échange le JWT contre un ticket à usage unique (60 s) pour ouvrir le terminal. */
|
||||||
|
export async function requestExecTicket(vpsId, containerId) {
|
||||||
|
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/exec/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL du WebSocket de terminal. Le token n'y figure jamais : seul un ticket
|
||||||
|
* jetable transite en query string.
|
||||||
|
*/
|
||||||
|
export function execSocketUrl(vpsId, containerId, ticket, cols, rows) {
|
||||||
|
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const params = new URLSearchParams({ ticket, cols: String(cols), rows: String(rows) })
|
||||||
|
return `${scheme}://${window.location.host}${BASE}/vps/${vpsId}/containers/${containerId}/exec?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Profile ──────────────────────────────────────────────────────────────────
|
// ─── Profile ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function changePassword(oldPassword, newPassword) {
|
export async function changePassword(oldPassword, newPassword) {
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ export default function AdminPage({ onBack }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleTerminal = async () => {
|
||||||
|
if (!settings) return
|
||||||
|
const newValue = settings.terminal_enabled === 'false' ? 'true' : 'false'
|
||||||
|
setToggleLoading(true)
|
||||||
|
try {
|
||||||
|
await setAdminSetting('terminal_enabled', newValue)
|
||||||
|
setSettings(prev => ({ ...prev, terminal_enabled: newValue }))
|
||||||
|
} catch (err) {
|
||||||
|
setSettingsError(err.message)
|
||||||
|
} finally {
|
||||||
|
setToggleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Notifications (Pushover) ────────────────────────────────────────────
|
// ─── Notifications (Pushover) ────────────────────────────────────────────
|
||||||
const [pushoverToken, setPushoverToken] = useState('')
|
const [pushoverToken, setPushoverToken] = useState('')
|
||||||
const [pushoverUserKey, setPushoverUserKey] = useState('')
|
const [pushoverUserKey, setPushoverUserKey] = useState('')
|
||||||
@@ -411,6 +425,13 @@ export default function AdminPage({ onBack }) {
|
|||||||
onChange={togglePasskeys}
|
onChange={togglePasskeys}
|
||||||
loading={toggleLoading}
|
loading={toggleLoading}
|
||||||
/>
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Terminal des conteneurs"
|
||||||
|
description="Permet aux administrateurs d'ouvrir un shell dans un conteneur (agent 1.3.0+)."
|
||||||
|
enabled={settings?.terminal_enabled !== 'false'}
|
||||||
|
onChange={toggleTerminal}
|
||||||
|
loading={toggleLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
|
import { Play, Square, RotateCcw, FileText, Loader2, Heart, TerminalSquare } from 'lucide-react'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
|
|
||||||
const HEALTH_STYLES = {
|
const HEALTH_STYLES = {
|
||||||
@@ -28,7 +28,7 @@ function hostPorts(ports) {
|
|||||||
return [...found].sort((a, b) => Number(a) - Number(b))
|
return [...found].sort((a, b) => Number(a) - Number(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContainerRow({ container, onAction, onLogs }) {
|
export default function ContainerRow({ container, onAction, onLogs, onTerminal, execDisabledReason }) {
|
||||||
const [pending, setPending] = useState(null)
|
const [pending, setPending] = useState(null)
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const ports = useMemo(() => hostPorts(container.ports), [container.ports])
|
const ports = useMemo(() => hostPorts(container.ports), [container.ports])
|
||||||
@@ -91,6 +91,15 @@ export default function ContainerRow({ container, onAction, onLogs }) {
|
|||||||
<ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}>
|
<ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} />
|
||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
|
{onTerminal && isRunning && (
|
||||||
|
<ActionBtn
|
||||||
|
title={execDisabledReason ?? `Ouvrir un terminal dans ${container.name}`}
|
||||||
|
onClick={onTerminal}
|
||||||
|
disabled={!!execDisabledReason}
|
||||||
|
>
|
||||||
|
<TerminalSquare size={13} />
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
<ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
|
<ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
|
||||||
<FileText size={13} />
|
<FileText size={13} />
|
||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
@@ -99,13 +108,13 @@ export default function ContainerRow({ container, onAction, onLogs }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionBtn({ children, onClick, title, danger = false, loading = false }) {
|
function ActionBtn({ children, onClick, title, danger = false, loading = false, disabled = false }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
disabled={loading}
|
disabled={loading || disabled}
|
||||||
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
|
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
|
||||||
danger
|
danger
|
||||||
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'
|
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'
|
||||||
|
|||||||
175
vps-monitor/frontend/src/components/TerminalModal.jsx
Normal file
175
vps-monitor/frontend/src/components/TerminalModal.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { TerminalSquare, RotateCcw } from 'lucide-react'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import Modal from './ui/Modal'
|
||||||
|
import { Button } from './ui/controls'
|
||||||
|
import { requestExecTicket, execSocketUrl } from '../api/client'
|
||||||
|
|
||||||
|
const THEME = {
|
||||||
|
background: '#030712',
|
||||||
|
foreground: '#e5e7eb',
|
||||||
|
cursor: '#818cf8',
|
||||||
|
cursorAccent: '#030712',
|
||||||
|
selectionBackground: '#312e81',
|
||||||
|
black: '#1f2937', red: '#f87171', green: '#34d399', yellow: '#fbbf24',
|
||||||
|
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e5e7eb',
|
||||||
|
brightBlack: '#4b5563', brightRed: '#fca5a5', brightGreen: '#6ee7b7',
|
||||||
|
brightYellow:'#fcd34d', brightBlue: '#93c5fd', brightMagenta:'#d8b4fe',
|
||||||
|
brightCyan: '#67e8f9', brightWhite: '#f9fafb',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
connecting: { label: 'Connexion…', dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400' },
|
||||||
|
open: { label: 'Connecté', dot: 'bg-emerald-400', text: 'text-emerald-400' },
|
||||||
|
closed: { label: 'Session terminée', dot: 'bg-gray-500', text: 'text-gray-500' },
|
||||||
|
error: { label: 'Erreur', dot: 'bg-red-400', text: 'text-red-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalModal({ vps, container, onClose }) {
|
||||||
|
const hostRef = useRef(null)
|
||||||
|
const [status, setStatus] = useState('connecting')
|
||||||
|
const [attempt, setAttempt] = useState(0) // incrémenté pour relancer la session
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = new Terminal({
|
||||||
|
theme: THEME,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
convertEol: false,
|
||||||
|
})
|
||||||
|
const fit = new FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.open(hostRef.current)
|
||||||
|
term.focus()
|
||||||
|
|
||||||
|
let socket = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
// Ajuster la grille demande que le nœud ait déjà une taille et que le moteur
|
||||||
|
// de rendu de xterm soit initialisé : on passe donc toujours par une frame.
|
||||||
|
const safeFit = () => {
|
||||||
|
const host = hostRef.current
|
||||||
|
if (cancelled || !host || host.clientWidth === 0 || host.clientHeight === 0) return
|
||||||
|
try { fit.fit() } catch { /* rendu pas encore prêt */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(safeFit)
|
||||||
|
const observer = new ResizeObserver(() => requestAnimationFrame(safeFit))
|
||||||
|
observer.observe(hostRef.current)
|
||||||
|
|
||||||
|
const send = (payload) => {
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSub = term.onData(data => send({ t: 'i', d: data }))
|
||||||
|
const resizeSub = term.onResize(({ cols, rows }) => send({ t: 'r', cols, rows }))
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
setStatus('connecting')
|
||||||
|
try {
|
||||||
|
const { ticket } = await requestExecTicket(vps.id, container.id)
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
socket = new WebSocket(execSocketUrl(vps.id, container.id, ticket, term.cols, term.rows))
|
||||||
|
socket.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setStatus('open')
|
||||||
|
send({ t: 'r', cols: term.cols, rows: term.rows })
|
||||||
|
term.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
// Binaire = sortie brute du TTY ; texte = message de contrôle JSON.
|
||||||
|
if (typeof event.data !== 'string') {
|
||||||
|
term.write(new Uint8Array(event.data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
if (message.t === 'error') {
|
||||||
|
setStatus('error')
|
||||||
|
term.writeln(`\r\n\x1b[31m${message.m}\x1b[0m`)
|
||||||
|
} else if (message.t === 'exit') {
|
||||||
|
term.writeln(`\r\n\x1b[90m— shell terminé (code ${message.code ?? '?'})\x1b[0m`)
|
||||||
|
}
|
||||||
|
} catch { /* message non JSON : ignoré */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (!cancelled) setStatus(current => (current === 'open' ? current : 'error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus(current => (current === 'error' ? current : 'closed'))
|
||||||
|
term.writeln('\r\n\x1b[90m— connexion fermée\x1b[0m')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus('error')
|
||||||
|
term.writeln(`\r\n\x1b[31mImpossible d'ouvrir le terminal : ${e.message}\x1b[0m`)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
observer.disconnect()
|
||||||
|
dataSub.dispose()
|
||||||
|
resizeSub.dispose()
|
||||||
|
socket?.close()
|
||||||
|
// xterm garde ses propres callbacks de redimensionnement en file : les
|
||||||
|
// laisser s'exécuter sur une instance vivante, sinon ils lèvent une erreur
|
||||||
|
// sur un cœur déjà libéré (visible au double montage de StrictMode).
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => term.dispose()))
|
||||||
|
}
|
||||||
|
}, [vps.id, container.id, attempt])
|
||||||
|
|
||||||
|
const state = STATUS[status] ?? STATUS.connecting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
size="xl"
|
||||||
|
title={`${vps.name} / ${container.name}`}
|
||||||
|
subtitle={container.image}
|
||||||
|
icon={<TerminalSquare size={16} className="text-indigo-400 flex-shrink-0" />}
|
||||||
|
onClose={onClose}
|
||||||
|
// Le shell a besoin d'Échap (vim) et de Tab (complétion) : la modale les
|
||||||
|
// laisse passer et se ferme via la croix ou le bouton du pied de page.
|
||||||
|
closeOnEscape={false}
|
||||||
|
trapTab={false}
|
||||||
|
bodyClassName="flex flex-col overflow-hidden p-0"
|
||||||
|
headerRight={
|
||||||
|
<span className={`hidden sm:flex items-center gap-1.5 text-xs ${state.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${state.dot}`} />
|
||||||
|
{state.label}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
Échap et Tab sont transmis au shell — fermez avec ✕.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(status === 'closed' || status === 'error') && (
|
||||||
|
<Button variant="secondary" size="sm" icon={RotateCcw} onClick={() => setAttempt(a => a + 1)}>
|
||||||
|
Reconnecter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>Fermer</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className="h-[60vh] bg-gray-950 px-3 py-2"
|
||||||
|
aria-label={`Terminal du conteneur ${container.name}`}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ import { tagColor } from './TagInput'
|
|||||||
import { IconButton, ProgressBar } from './ui/controls'
|
import { IconButton, ProgressBar } from './ui/controls'
|
||||||
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format'
|
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format'
|
||||||
|
|
||||||
|
/** Le terminal interactif n'existe qu'à partir de l'agent 1.3.0. */
|
||||||
|
function agentSupportsExec(version) {
|
||||||
|
if (!version || version === 'unknown') return true // version inconnue : on laisse tenter
|
||||||
|
const [major, minor] = version.split('.').map(n => parseInt(n, 10))
|
||||||
|
if (Number.isNaN(major)) return true
|
||||||
|
return major > 1 || (major === 1 && (minor || 0) >= 3)
|
||||||
|
}
|
||||||
|
|
||||||
/** Métrique système avec barre de progression (CPU, RAM). */
|
/** Métrique système avec barre de progression (CPU, RAM). */
|
||||||
function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
|
function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
|
||||||
const percent = Number.isFinite(rawPercent) ? rawPercent : 0
|
const percent = Number.isFinite(rawPercent) ? rawPercent : 0
|
||||||
@@ -23,7 +31,7 @@ function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
|
export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport, onTerminal }) {
|
||||||
const storageKey = `vps:${vps.id}:collapsed`
|
const storageKey = `vps:${vps.id}:collapsed`
|
||||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
|
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
|
||||||
const [updatingProject, setUpdatingProject] = useState(null)
|
const [updatingProject, setUpdatingProject] = useState(null)
|
||||||
@@ -294,6 +302,12 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
|
|||||||
container={c}
|
container={c}
|
||||||
onAction={(action) => onAction(vps.id, c.id, action)}
|
onAction={(action) => onAction(vps.id, c.id, action)}
|
||||||
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
|
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
|
||||||
|
onTerminal={onTerminal ? () => onTerminal(vps, c) : undefined}
|
||||||
|
execDisabledReason={
|
||||||
|
agentSupportsExec(vps.agent_version)
|
||||||
|
? undefined
|
||||||
|
: `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,15 +33,20 @@ export default function Modal({
|
|||||||
children,
|
children,
|
||||||
bodyClassName = 'overflow-y-auto p-4',
|
bodyClassName = 'overflow-y-auto p-4',
|
||||||
panelClassName = '',
|
panelClassName = '',
|
||||||
|
// Le terminal a besoin de Tab (complétion) et d'Échap (vim) : il désactive
|
||||||
|
// ces deux raccourcis et se ferme via la croix.
|
||||||
|
trapTab = true,
|
||||||
|
closeOnEscape = true,
|
||||||
}) {
|
}) {
|
||||||
const panelRef = useRef(null)
|
const panelRef = useRef(null)
|
||||||
const onCloseRef = useRef(onClose)
|
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
|
||||||
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
|
// `onClose` est souvent une lambda recréée à chaque rendu du parent : le
|
||||||
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
|
// gestionnaire de touches lit ces valeurs via une ref pour que l'effet ne se
|
||||||
// au premier champ à chaque rafraîchissement automatique).
|
// rejoue pas (sinon le focus sauterait au premier champ à chaque
|
||||||
useEffect(() => { onCloseRef.current = onClose })
|
// rafraîchissement automatique).
|
||||||
|
const handlersRef = useRef({ onClose, trapTab, closeOnEscape })
|
||||||
|
useEffect(() => { handlersRef.current = { onClose, trapTab, closeOnEscape } })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previouslyFocused = document.activeElement
|
const previouslyFocused = document.activeElement
|
||||||
@@ -50,12 +55,12 @@ export default function Modal({
|
|||||||
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
|
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
|
||||||
|
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape' && handlersRef.current.closeOnEscape) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onCloseRef.current?.()
|
handlersRef.current.onClose?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key !== 'Tab' || !panel) return
|
if (e.key !== 'Tab' || !handlersRef.current.trapTab || !panel) return
|
||||||
|
|
||||||
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
|
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8000',
|
// `ws: true` : le terminal des conteneurs passe par un WebSocket sur /api
|
||||||
|
'/api': { target: 'http://localhost:8000', ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user