diff --git a/vps-monitor/agent/INSTALL.md b/vps-monitor/agent/INSTALL.md index 7794657..065ca1e 100644 --- a/vps-monitor/agent/INSTALL.md +++ b/vps-monitor/agent/INSTALL.md @@ -141,6 +141,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) | +| `AGENT_APPS_DIR` | `/home` | Racine des applications compose (voir ci-dessous) | Pour modifier la configuration sans réinstaller : @@ -186,6 +187,40 @@ proxy_read_timeout 3600s; --- +## Applications compose (agent 1.4.0+) + +L'agent expose les fichiers compose rangés selon la convention +`/home//compose.yaml` (les noms `compose.yml`, `docker-compose.yml` +et `docker-compose.yaml` sont également reconnus). L'interface web permet de les +lister, les éditer, en créer de nouveaux et déployer. + +| Route agent | Effet | +|-------------|-------| +| `GET /apps` | Liste les dossiers de `/home` contenant un fichier compose, avec le nombre de conteneurs actifs | +| `GET /apps/{nom}/compose` | Contenu du fichier | +| `PUT /apps/{nom}/compose` | Écrit le fichier (`deploy: true` enchaîne sur un déploiement) | +| `POST /apps` | Crée `/home//` et son fichier compose | +| `POST /apps/{nom}/up` | `docker compose pull` puis `up -d` dans le dossier | + +Garde-fous appliqués : + +- **Validation avant écriture.** Le contenu est d'abord vérifié par + `docker compose config` sur un fichier temporaire, dans le dossier de + l'application pour que `.env` et chemins relatifs soient résolus comme au + déploiement. Un fichier invalide est refusé et **rien n'est écrit**. +- **Sauvegarde.** L'ancienne version est copiée en `.bak` avant chaque + écriture. +- **Périmètre.** Le nom d'application est restreint à + `[A-Za-z0-9][A-Za-z0-9._-]*` et le chemin résolu doit rester directement sous + `AGENT_APPS_DIR` — les `..` et les liens symboliques sortants sont rejetés. +- **Taille** limitée à 512 kB par fichier. +- **Réservé aux administrateurs**, comme le terminal. + +Si vos applications ne sont pas sous `/home`, changez `AGENT_APPS_DIR` dans +`/opt/vps-monitor-agent/.env` puis redémarrez le service. + +--- + ## Dépôt source [https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS) diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index 650e983..526f2f6 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -7,11 +7,14 @@ Expose une API REST utilisée par le backend central pour interroger les contene import asyncio import json import os +import re +import shutil import socket as socket_module import subprocess import threading import time from datetime import datetime, timezone +from pathlib import Path import docker import psutil @@ -19,10 +22,11 @@ from docker.errors import DockerException, NotFound from fastapi import Depends, FastAPI, HTTPException, Security, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.security import APIKeyHeader +from pydantic import BaseModel # ─── Config ─────────────────────────────────────────────────────────────────── -AGENT_VERSION = "1.3.0" +AGENT_VERSION = "1.4.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") @@ -37,7 +41,7 @@ app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_u app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST"], + allow_methods=["GET", "POST", "PUT"], allow_headers=["*"], ) @@ -406,6 +410,256 @@ def compose_update(project: str, _: None = Depends(require_api_key)): return {"output": output, "project": project, "working_dir": working_dir} +# ─── Applications compose ──────────────────────────────────────────────────── +# +# Convention : une application = un dossier sous /home contenant son fichier +# compose, soit /home//compose.yaml (ou l'un des autres noms +# reconnus par Docker). + +APPS_DIR = Path(os.getenv("AGENT_APPS_DIR", "/home")) + +_COMPOSE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml") +_APP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_MAX_COMPOSE_BYTES = 512 * 1024 + + +class ComposeWriteRequest(BaseModel): + content: str + deploy: bool = False + + +class AppCreateRequest(BaseModel): + name: str + content: str + filename: str = "compose.yaml" + deploy: bool = False + + +def _resolve_app_dir(name: str) -> Path: + """Chemin du dossier d'une application, en refusant tout ce qui sort d'APPS_DIR.""" + if not _APP_NAME_RE.match(name or ""): + raise HTTPException(status_code=400, detail="Nom d'application invalide") + base = APPS_DIR.resolve() + path = (base / name).resolve() + # Le nom ne contient ni « / » ni « .. » (regex) ; ce contrôle attrape en plus + # les liens symboliques qui pointeraient hors de /home. + if path.parent != base: + raise HTTPException(status_code=400, detail="Chemin d'application invalide") + return path + + +def _find_compose_file(app_dir: Path) -> Path | None: + for candidate in _COMPOSE_NAMES: + path = app_dir / candidate + if path.is_file(): + return path + return None + + +def _validate_compose(app_dir: Path, content: str, display_name: str) -> tuple[bool, str]: + """Valide le YAML avec `docker compose config` avant d'écrire quoi que ce soit. + + La validation se fait sur un fichier temporaire dans le dossier de + l'application, pour que les chemins relatifs et le `.env` soient résolus + exactement comme au déploiement. + """ + tmp = app_dir / f".compose-check-{os.getpid()}.yaml" + try: + tmp.write_text(content, encoding="utf-8") + proc = subprocess.run( + ["docker", "compose", "-f", str(tmp), "config", "-q"], + cwd=app_dir, capture_output=True, text=True, timeout=30, + ) + message = (proc.stderr or proc.stdout).strip().replace(tmp.name, display_name) + return proc.returncode == 0, message + except FileNotFoundError: + return True, "" # docker compose absent : on n'empêche pas l'enregistrement + except subprocess.TimeoutExpired: + return False, "La validation `docker compose config` a expiré." + except OSError as e: + raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}") + finally: + tmp.unlink(missing_ok=True) + + +def _compose_up(app_dir: Path) -> str: + output = "" + for cmd, timeout in ((["docker", "compose", "pull"], 600), + (["docker", "compose", "up", "-d", "--remove-orphans"], 300)): + proc = subprocess.run(cmd, cwd=app_dir, capture_output=True, text=True, timeout=timeout) + output += proc.stdout + proc.stderr + return output + + +def _app_payload(app_dir: Path, compose: Path, running: dict[str, int]) -> dict: + stat = compose.stat() + return { + "name": app_dir.name, + "path": str(app_dir), + "file": compose.name, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "running_containers": running.get(str(app_dir), 0), + } + + +def _running_by_working_dir() -> dict[str, int]: + """Conteneurs démarrés, indexés par répertoire de projet compose.""" + counts: dict[str, int] = {} + try: + client = docker.from_env() + for container in client.containers.list(): + working_dir = container.labels.get("com.docker.compose.project.working_dir") + if working_dir: + counts[working_dir] = counts.get(working_dir, 0) + 1 + except DockerException: + pass + return counts + + +@app.get("/apps") +def list_apps(_: None = Depends(require_api_key)): + """Liste les applications : dossiers de /home contenant un fichier compose.""" + if not APPS_DIR.is_dir(): + return [] + + running = _running_by_working_dir() + apps = [] + try: + entries = sorted(APPS_DIR.iterdir(), key=lambda p: p.name.lower()) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Lecture de {APPS_DIR} impossible : {e}") + + for entry in entries: + if not entry.is_dir() or entry.name.startswith("."): + continue + compose = _find_compose_file(entry) + if compose: + apps.append(_app_payload(entry, compose, running)) + return apps + + +@app.get("/apps/{name}/compose") +def read_compose(name: str, _: None = Depends(require_api_key)): + """Retourne le contenu du fichier compose d'une application.""" + app_dir = _resolve_app_dir(name) + if not app_dir.is_dir(): + raise HTTPException(status_code=404, detail="Application introuvable") + compose = _find_compose_file(app_dir) + if not compose: + raise HTTPException(status_code=404, detail="Aucun fichier compose dans ce dossier") + try: + content = compose.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + raise HTTPException(status_code=500, detail=f"Lecture impossible : {e}") + + payload = _app_payload(app_dir, compose, _running_by_working_dir()) + payload["content"] = content + return payload + + +@app.put("/apps/{name}/compose") +def write_compose(name: str, body: ComposeWriteRequest, _: None = Depends(require_api_key)): + """Écrit le fichier compose, après validation et sauvegarde de l'ancienne version.""" + app_dir = _resolve_app_dir(name) + if not app_dir.is_dir(): + raise HTTPException(status_code=404, detail="Application introuvable") + if len(body.content.encode("utf-8")) > _MAX_COMPOSE_BYTES: + raise HTTPException(status_code=413, detail="Fichier compose trop volumineux") + + target = _find_compose_file(app_dir) or (app_dir / "compose.yaml") + + valid, message = _validate_compose(app_dir, body.content, target.name) + if not valid: + raise HTTPException(status_code=400, detail=message or "Fichier compose invalide") + + backup = None + try: + if target.exists(): + backup = target.with_name(target.name + ".bak") + shutil.copy2(target, backup) + target.write_text(body.content, encoding="utf-8") + except OSError as e: + raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}") + + result = { + "status": "ok", + "file": target.name, + "path": str(target), + "backup": backup.name if backup else None, + } + if body.deploy: + try: + result["output"] = _compose_up(app_dir) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="Le déploiement a expiré") + return result + + +@app.post("/apps", status_code=201) +def create_app(body: AppCreateRequest, _: None = Depends(require_api_key)): + """Crée une application : dossier /home/ et son fichier compose.""" + if body.filename not in _COMPOSE_NAMES: + raise HTTPException( + status_code=400, + detail=f"Nom de fichier invalide — attendu : {', '.join(_COMPOSE_NAMES)}", + ) + if len(body.content.encode("utf-8")) > _MAX_COMPOSE_BYTES: + raise HTTPException(status_code=413, detail="Fichier compose trop volumineux") + + app_dir = _resolve_app_dir(body.name) + if _find_compose_file(app_dir): + raise HTTPException(status_code=409, detail="Cette application existe déjà") + + created_dir = not app_dir.exists() + try: + app_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Création du dossier impossible : {e}") + + valid, message = _validate_compose(app_dir, body.content, body.filename) + if not valid: + if created_dir: + try: + app_dir.rmdir() # ne laisse pas un dossier vide derrière soi + except OSError: + pass + raise HTTPException(status_code=400, detail=message or "Fichier compose invalide") + + target = app_dir / body.filename + try: + target.write_text(body.content, encoding="utf-8") + except OSError as e: + raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}") + + result = { + "status": "created", + "name": app_dir.name, + "path": str(target), + "file": target.name, + "created_dir": created_dir, + } + if body.deploy: + try: + result["output"] = _compose_up(app_dir) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="Le déploiement a expiré") + return result + + +@app.post("/apps/{name}/up") +def deploy_app(name: str, _: None = Depends(require_api_key)): + """Lance `docker compose pull` puis `up -d` dans le dossier de l'application.""" + app_dir = _resolve_app_dir(name) + compose = _find_compose_file(app_dir) if app_dir.is_dir() else None + if not compose: + raise HTTPException(status_code=404, detail="Application introuvable") + try: + return {"output": _compose_up(app_dir), "name": app_dir.name, "path": str(app_dir)} + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="Le déploiement a expiré") + + @app.get("/services") def list_services(_: None = Depends(require_api_key)): """Retourne la liste des services systemd (hors Docker) avec leur état.""" diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index 78bab67..acfa3f0 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -128,6 +128,18 @@ class ComposeUpdateRequest(BaseModel): project: str +class ComposeWriteRequest(BaseModel): + content: str + deploy: bool = False + + +class AppCreateRequest(BaseModel): + name: str + content: str + filename: str = "compose.yaml" + deploy: bool = False + + class RegisterRequest(BaseModel): username: str password: str @@ -601,6 +613,25 @@ async def agent_post(vps: dict, path: str, payload: dict | None = None): return await r.json() +async def agent_request(vps: dict, method: str, path: str, + payload: dict | None = None, timeout: int = AGENT_TIMEOUT): + """Appel générique vers un agent — utilisé pour les verbes autres que GET/POST + et pour les opérations longues (déploiement compose).""" + url = f"http://{vps['host']}:{vps['port']}{path}" + headers = {"X-API-Key": vps["api_key"]} + async with aiohttp.ClientSession() as session: + async with session.request( + method, url, headers=headers, json=payload, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as r: + body = await r.json(content_type=None) + if r.status >= 400: + # Remonte le message de l'agent (validation compose, conflit…) + detail = body.get("detail") if isinstance(body, dict) else str(body) + raise HTTPException(status_code=r.status, detail=detail or f"HTTP {r.status}") + return body + + async def fetch_vps_status(vps: dict) -> dict: """Interroge un agent et retourne son état complet. @@ -1472,6 +1503,97 @@ async def container_action( raise HTTPException(status_code=502, detail=str(e)) +# ─── Applications compose ──────────────────────────────────────────────────── +# +# Éditer un fichier compose revient à choisir ce qui tourne sur la machine : +# ces routes sont réservées aux administrateurs, comme le terminal. + +DEPLOY_TIMEOUT = 900 + + +def _require_vps(vps_id: str) -> dict: + vps = next((v for v in load_vps() if v["id"] == vps_id), None) + if not vps: + raise HTTPException(status_code=404, detail="VPS introuvable") + return vps + + +@app.get("/api/vps/{vps_id}/apps") +async def list_apps(vps_id: str, _: Annotated[dict, Depends(require_admin)]): + """Liste les applications compose présentes sur le VPS.""" + vps = _require_vps(vps_id) + try: + return await agent_request(vps, "GET", "/apps", timeout=15) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"Agent injoignable ({e}). " + f"Version 1.4.0 de l'agent requise.") + + +@app.get("/api/vps/{vps_id}/apps/{name}/compose") +async def read_app_compose(vps_id: str, name: str, _: Annotated[dict, Depends(require_admin)]): + """Retourne le contenu du fichier compose d'une application.""" + vps = _require_vps(vps_id) + try: + return await agent_request(vps, "GET", f"/apps/{name}/compose", timeout=15) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@app.put("/api/vps/{vps_id}/apps/{name}/compose") +async def write_app_compose( + vps_id: str, name: str, body: ComposeWriteRequest, + _: Annotated[dict, Depends(require_admin)], +): + """Enregistre le fichier compose, avec déploiement optionnel.""" + vps = _require_vps(vps_id) + try: + return await agent_request( + vps, "PUT", f"/apps/{name}/compose", + payload={"content": body.content, "deploy": body.deploy}, + timeout=DEPLOY_TIMEOUT if body.deploy else 30, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@app.post("/api/vps/{vps_id}/apps", status_code=201) +async def create_app( + vps_id: str, body: AppCreateRequest, + _: Annotated[dict, Depends(require_admin)], +): + """Crée une application : dossier et fichier compose sur le VPS.""" + vps = _require_vps(vps_id) + try: + return await agent_request( + vps, "POST", "/apps", + payload={"name": body.name, "content": body.content, + "filename": body.filename, "deploy": body.deploy}, + timeout=DEPLOY_TIMEOUT if body.deploy else 30, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@app.post("/api/vps/{vps_id}/apps/{name}/up") +async def deploy_app(vps_id: str, name: str, _: Annotated[dict, Depends(require_admin)]): + """Déploie une application existante (pull + up -d).""" + vps = _require_vps(vps_id) + try: + return await agent_request(vps, "POST", f"/apps/{name}/up", timeout=DEPLOY_TIMEOUT) + except HTTPException: + raise + except Exception as 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, diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index c2d3ae0..dab3ff1 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -18,6 +18,7 @@ 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 AppsModal = lazy(() => import('./components/AppsModal')) const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, @@ -122,6 +123,7 @@ export default function App() { const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } const [terminalModal, setTerminalModal] = useState(null) // { vps, container } + const [appsModal, setAppsModal] = useState(null) // vps // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { @@ -206,7 +208,7 @@ export default function App() { }, [token, username]) // Raccourcis clavier : « / » cible la recherche, « r » actualise. - const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal) + const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal || appsModal) useEffect(() => { if (!token || page !== 'main' || modalOpen) return const onKeyDown = (e) => { @@ -375,9 +377,11 @@ 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 + // Terminal et édition des fichiers compose reviennent à choisir ce qui tourne + // sur la machine : réservés aux admins. Le terminal est en plus désactivable + // globalement depuis la page d'administration. + const isAdmin = role === 'admin' + const canUseTerminal = isAdmin && terminalEnabled // Statistiques globales const totalOnline = vpsList.filter(v => v.online).length @@ -522,6 +526,7 @@ export default function App() { onUpdateAgent={handleUpdateAgent} onExport={handleExportVps} onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined} + onApps={isAdmin ? setAppsModal : undefined} /> ))} @@ -589,6 +594,17 @@ export default function App() { )} + {/* Applications compose */} + {appsModal && ( + + Chargement… + + }> + { setAppsModal(null); refresh() }} /> + + )} + {/* Confirmation de suppression */} {deleteTarget && ( `services: + ${name}: + image: nginx:alpine + container_name: ${name} + restart: unless-stopped + ports: + - "8080:80" + volumes: + - ./data:/usr/share/nginx/html:ro +` + +export default function AppsModal({ vps, onClose }) { + const toast = useToast() + + const [view, setView] = useState('list') // 'list' | 'editor' | 'create' + const [apps, setApps] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) // nom de l'app en cours de déploiement + + // Édition + const [editing, setEditing] = useState(null) // { name, file, path, original } + const [content, setContent] = useState('') + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [leaving, setLeaving] = useState(false) // demande de sortie avec modifications + + // Création — le modèle suit le nom saisi tant que l'utilisateur n'a pas + // touché à l'éditeur ; dès qu'il y écrit, on ne réécrit plus rien. + const [newName, setNewName] = useState('') + const [newFilename, setNewFilename] = useState('compose.yaml') + const [newContent, setNewContent] = useState('') + const [templateTouched, setTemplateTouched] = useState(false) + + // Sortie de déploiement + const [output, setOutput] = useState(null) + + const dirty = view === 'editor' && editing !== null && content !== editing.original + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + setApps(await fetchApps(vps.id)) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + }, [vps.id]) + + useEffect(() => { load() }, [load]) + + // ─── Actions ────────────────────────────────────────────────────────────── + + const openEditor = async (app) => { + setBusy(app.name) + setSaveError(null) + setOutput(null) + try { + const data = await fetchAppCompose(vps.id, app.name) + setEditing({ name: data.name, file: data.file, path: data.path, original: data.content }) + setContent(data.content) + setView('editor') + } catch (e) { + toast.error(`Lecture de ${app.name} impossible : ${e.message}`) + } finally { + setBusy(null) + } + } + + const handleSave = async (deploy) => { + setSaving(true) + setSaveError(null) + setOutput(null) + try { + const result = await saveAppCompose(vps.id, editing.name, content, deploy) + setEditing(prev => ({ ...prev, original: content, file: result.file })) + toast.success( + deploy ? `${editing.name} enregistrée et déployée.` : `${editing.name} enregistrée.` + ) + if (result.output) setOutput(result.output) + load() + } catch (e) { + setSaveError(e.message) + } finally { + setSaving(false) + } + } + + const handleCreate = async (deploy) => { + setSaving(true) + setSaveError(null) + setOutput(null) + try { + const result = await createApp(vps.id, { + name: newName.trim(), content: newContent, filename: newFilename, deploy, + }) + toast.success(`Application « ${result.name} » créée dans ${result.path}.`) + if (result.output) setOutput(result.output) + await load() + setEditing({ name: result.name, file: result.file, path: result.path, original: newContent }) + setContent(newContent) + setView('editor') + } catch (e) { + setSaveError(e.message) + } finally { + setSaving(false) + } + } + + const handleDeploy = async (app) => { + setBusy(app.name) + setOutput(null) + try { + const result = await deployApp(vps.id, app.name) + toast.success(`${app.name} déployée.`) + setOutput(result.output) + load() + } catch (e) { + toast.error(`Déploiement de ${app.name} impossible : ${e.message}`) + } finally { + setBusy(null) + } + } + + const startCreate = () => { + setNewName('') + setNewFilename('compose.yaml') + setNewContent(TEMPLATE('mon-app')) + setTemplateTouched(false) + setSaveError(null) + setOutput(null) + setView('create') + } + + const handleNewName = (value) => { + setNewName(value) + const trimmed = value.trim() + if (!templateTouched) setNewContent(TEMPLATE(APP_NAME_RE.test(trimmed) ? trimmed : 'mon-app')) + } + + const handleNewContent = (value) => { + setTemplateTouched(true) + setNewContent(value) + } + + const backToList = () => { + if (dirty && !leaving) { setLeaving(true); return } + setLeaving(false) + setEditing(null) + setSaveError(null) + setView('list') + } + + const requestClose = () => { + if (dirty && !leaving) { setLeaving(true); return } + onClose() + } + + const nameValid = APP_NAME_RE.test(newName.trim()) + + // ─── Rendu ──────────────────────────────────────────────────────────────── + + const title = { + list: `Applications — ${vps.name}`, + editor: editing ? `${editing.name} / ${editing.file}` : 'Édition', + create: `Nouvelle application — ${vps.name}`, + }[view] + + const subtitle = { + list: `Fichiers compose sous /home sur ${vps.host}`, + editor: editing?.path, + create: `Créé dans /home/${newName.trim() || ''}/${newFilename}`, + }[view] + + return ( + } + onClose={requestClose} + // Tab sert à indenter le YAML ; Échap ne doit pas jeter des modifications. + trapTab={false} + closeOnEscape={!dirty} + bodyClassName="flex flex-col overflow-hidden p-0" + headerRight={view === 'list' && ( + <> + + + + )} + footer={ +
+ {leaving ? ( + <> +

+ + Modifications non enregistrées. Quitter sans enregistrer ? +

+
+ + +
+ + ) : view === 'list' ? ( + <> +

+ {apps.length} application{apps.length > 1 ? 's' : ''} détectée{apps.length > 1 ? 's' : ''} +

+ + + ) : view === 'editor' ? ( + <> +

+ {dirty + ? 'Modifications non enregistrées · l\'ancienne version est sauvegardée en .bak' + : 'Validé par `docker compose config` avant écriture'} +

+
+ + + +
+ + ) : ( + <> +

+ Le dossier est créé s'il n'existe pas encore. +

+
+ + + +
+ + )} +
+ } + > + {/* ── Liste ── */} + {view === 'list' && ( +
+ {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ {Array.from({ length: 4 }, (_, i) => )} +
+ ) : apps.length === 0 && !error ? ( + Nouvelle application} + /> + ) : ( +
    + {apps.map(app => ( +
  • +
    +
    + {app.name} + + {app.file} + + {app.running_containers > 0 ? ( + + {app.running_containers} conteneur{app.running_containers > 1 ? 's' : ''} actif{app.running_containers > 1 ? 's' : ''} + + ) : ( + + arrêtée + + )} +
    +

    + {app.path} · {formatBytes(app.size)} · modifié le {new Date(app.modified).toLocaleString('fr-FR')} +

    +
    +
    + + +
    +
  • + ))} +
+ )} + + {output && setOutput(null)} />} +
+ )} + + {/* ── Édition ── */} + {view === 'editor' && editing && ( +
+ {saveError && ( +
+ {saveError} +
+ )} +
+ + {output && setOutput(null)} />} +
+
+ )} + + {/* ── Création ── */} + {view === 'create' && ( +
+ {saveError && ( +
+ {saveError} +
+ )} + +
+
+ + handleNewName(e.target.value)} + placeholder="mon-app" + autoFocus + className={inputClass} + /> +

+ {newName && !nameValid + ? 'Lettres, chiffres, point, tiret et souligné uniquement.' + : `Dossier créé : /home/${newName.trim() || ''}`} +

+
+
+ + +
+
+ + + + {output && setOutput(null)} />} +
+ )} +
+ ) +} + +function DeployOutput({ output, onClear }) { + return ( +
+
+ + + Sortie du déploiement + + +
+
+        {output || '(aucune sortie)'}
+      
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index fb1679f..cca0244 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -1,16 +1,21 @@ import { useEffect, useMemo, useState } from 'react' -import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload, Copy, Check, Activity } from 'lucide-react' +import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload, Copy, Check, Activity, Boxes } from 'lucide-react' import ContainerRow from './ContainerRow' 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 +/** + * Compare la version d'un agent à un minimum requis. + * Terminal : 1.3.0 · édition des fichiers compose : 1.4.0. + * Une version inconnue laisse passer : on préfère une erreur explicite à un + * bouton grisé sans raison. + */ +function agentAtLeast(version, minMajor, minMinor) { + if (!version || version === 'unknown') return true const [major, minor] = version.split('.').map(n => parseInt(n, 10)) if (Number.isNaN(major)) return true - return major > 1 || (major === 1 && (minor || 0) >= 3) + return major > minMajor || (major === minMajor && (minor || 0) >= minMinor) } /** Métrique système avec barre de progression (CPU, RAM). */ @@ -31,7 +36,7 @@ function Metric({ icon: Icon, label, percent: rawPercent, detail }) { ) } -export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport, onTerminal }) { +export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport, onTerminal, onApps }) { const storageKey = `vps:${vps.id}:collapsed` const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1') const [updatingProject, setUpdatingProject] = useState(null) @@ -110,6 +115,20 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o onClick={() => setCollapsed(c => !c)} /> + {vps.online && onApps && ( + onApps(vps)} + /> + )} + {vps.online && ( onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)} onTerminal={onTerminal ? () => onTerminal(vps, c) : undefined} execDisabledReason={ - agentSupportsExec(vps.agent_version) + agentAtLeast(vps.agent_version, 1, 3) ? undefined : `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.` } diff --git a/vps-monitor/frontend/src/components/YamlEditor.jsx b/vps-monitor/frontend/src/components/YamlEditor.jsx new file mode 100644 index 0000000..336c2bf --- /dev/null +++ b/vps-monitor/frontend/src/components/YamlEditor.jsx @@ -0,0 +1,62 @@ +import { useMemo, useRef } from 'react' + +/** + * Éditeur YAML minimal : zone de saisie monospace, gouttière de numéros de + * ligne synchronisée au défilement, et Tab qui indente au lieu de sortir du + * champ (l'indentation fait le sens d'un fichier compose). + */ +export default function YamlEditor({ value, onChange, disabled = false, height = 'h-[52vh]' }) { + const areaRef = useRef(null) + const gutterRef = useRef(null) + + const lineCount = useMemo(() => value.split('\n').length, [value]) + + const syncScroll = () => { + if (gutterRef.current && areaRef.current) { + gutterRef.current.scrollTop = areaRef.current.scrollTop + } + } + + const handleKeyDown = (e) => { + if (e.key !== 'Tab') return + e.preventDefault() + const area = areaRef.current + const start = area.selectionStart + const end = area.selectionEnd + const next = `${value.slice(0, start)} ${value.slice(end)}` + onChange(next) + // Repositionne le curseur après les deux espaces insérés + requestAnimationFrame(() => { + area.selectionStart = area.selectionEnd = start + 2 + }) + } + + return ( +
+ +