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_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 :
|
||||
|
||||
@@ -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
|
||||
|
||||
[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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket as socket_module
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -13,13 +16,13 @@ from datetime import datetime, timezone
|
||||
import docker
|
||||
import psutil
|
||||
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.security import APIKeyHeader
|
||||
|
||||
# ─── 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")
|
||||
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")
|
||||
|
||||
|
||||
# ─── 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")
|
||||
def system_info(_: None = Depends(require_api_key)):
|
||||
"""Retourne les informations système : CPU, RAM et bande passante."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Annotated
|
||||
|
||||
import bcrypt as _bcrypt
|
||||
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.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
@@ -265,6 +265,9 @@ def init_db() -> None:
|
||||
conn.execute("""
|
||||
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("""
|
||||
CREATE TABLE IF NOT EXISTS container_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -507,6 +510,49 @@ def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> d
|
||||
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:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
@@ -801,8 +847,9 @@ async def _cleanup_old_stats() -> None:
|
||||
def auth_status():
|
||||
"""Indique si des utilisateurs existent déjà (pour le frontend)."""
|
||||
return {
|
||||
"has_users": len(load_users()) > 0,
|
||||
"has_users": len(load_users()) > 0,
|
||||
"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)],
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
||||
with get_db() as conn:
|
||||
@@ -1424,6 +1472,105 @@ async def container_action(
|
||||
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")
|
||||
async def compose_update(
|
||||
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 {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
@@ -8,6 +15,16 @@ server {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
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
|
||||
|
||||
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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -900,6 +915,21 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^0.396.0",
|
||||
"react": "^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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^0.396.0",
|
||||
"react": "^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 { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
|
||||
import Header from './components/Header'
|
||||
@@ -16,6 +16,9 @@ import { useToast } from './components/ui/Toast'
|
||||
import { Button, EmptyState, Skeleton } from './components/ui/controls'
|
||||
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 = [
|
||||
{ label: '10 s', value: 10_000 },
|
||||
{ label: '30 s', value: 30_000 },
|
||||
@@ -60,6 +63,7 @@ export default function App() {
|
||||
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
|
||||
const [isFirstUser, setIsFirstUser] = useState(false)
|
||||
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
|
||||
const [terminalEnabled, setTerminalEnabled] = useState(true)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
|
||||
const [vpsList, setVpsList] = useState([])
|
||||
@@ -117,13 +121,15 @@ export default function App() {
|
||||
const [updateLoading, setUpdateLoading] = useState(false)
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
authStatus()
|
||||
.then(({ has_users, passkey_enabled }) => {
|
||||
.then(({ has_users, passkey_enabled, terminal_enabled }) => {
|
||||
setIsFirstUser(!has_users)
|
||||
setPasskeyEnabled(!!passkey_enabled)
|
||||
setTerminalEnabled(terminal_enabled !== false)
|
||||
})
|
||||
.catch(() => setIsFirstUser(false))
|
||||
.finally(() => setAuthChecked(true))
|
||||
@@ -200,7 +206,7 @@ export default function App() {
|
||||
}, [token, username])
|
||||
|
||||
// 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(() => {
|
||||
if (!token || page !== 'main' || modalOpen) return
|
||||
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
|
||||
const totalOnline = vpsList.filter(v => v.online).length
|
||||
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 })}
|
||||
onUpdateAgent={handleUpdateAgent}
|
||||
onExport={handleExportVps}
|
||||
onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -127,6 +127,27 @@ export async function fetchVpsStats(vpsId, duration = 600) {
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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) ────────────────────────────────────────────
|
||||
const [pushoverToken, setPushoverToken] = useState('')
|
||||
const [pushoverUserKey, setPushoverUserKey] = useState('')
|
||||
@@ -411,6 +425,13 @@ export default function AdminPage({ onBack }) {
|
||||
onChange={togglePasskeys}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
const HEALTH_STYLES = {
|
||||
@@ -28,7 +28,7 @@ function hostPorts(ports) {
|
||||
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 isRunning = container.status === 'running'
|
||||
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'}>
|
||||
<RotateCcw size={13} />
|
||||
</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}>
|
||||
<FileText size={13} />
|
||||
</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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
disabled={loading}
|
||||
disabled={loading || disabled}
|
||||
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
|
||||
danger
|
||||
? '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 { 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). */
|
||||
function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
|
||||
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 [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
|
||||
const [updatingProject, setUpdatingProject] = useState(null)
|
||||
@@ -294,6 +302,12 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
|
||||
container={c}
|
||||
onAction={(action) => onAction(vps.id, c.id, action)}
|
||||
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,
|
||||
bodyClassName = 'overflow-y-auto p-4',
|
||||
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 onCloseRef = useRef(onClose)
|
||||
const titleId = useId()
|
||||
const panelRef = useRef(null)
|
||||
const titleId = useId()
|
||||
|
||||
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
|
||||
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
|
||||
// au premier champ à chaque rafraîchissement automatique).
|
||||
useEffect(() => { onCloseRef.current = onClose })
|
||||
// `onClose` est souvent une lambda recréée à chaque rendu du parent : le
|
||||
// gestionnaire de touches lit ces valeurs via une ref pour que l'effet ne se
|
||||
// rejoue pas (sinon le focus sauterait au premier champ à chaque
|
||||
// rafraîchissement automatique).
|
||||
const handlersRef = useRef({ onClose, trapTab, closeOnEscape })
|
||||
useEffect(() => { handlersRef.current = { onClose, trapTab, closeOnEscape } })
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement
|
||||
@@ -50,12 +55,12 @@ export default function Modal({
|
||||
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && handlersRef.current.closeOnEscape) {
|
||||
e.stopPropagation()
|
||||
onCloseRef.current?.()
|
||||
handlersRef.current.onClose?.()
|
||||
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)
|
||||
if (items.length === 0) return
|
||||
|
||||
@@ -5,7 +5,8 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
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