feat: add applications management to the agent and backend
All checks were successful
Build and Push Docker Images / docker (push) Successful in 25s

- Updated agent to version 1.4.0 with new endpoints for managing Docker Compose applications.
- Implemented API requests in the backend for listing, creating, editing, and deploying applications.
- Introduced a new AppsModal component in the frontend for user interaction with applications.
- Added YAML editor for editing Docker Compose files with validation.
- Enhanced VpsCard component to include options for managing applications.
- Updated client API functions to support new application management features.
This commit is contained in:
jeanotx32
2026-08-01 02:11:24 -04:00
parent e277e99155
commit 71c17e3dc1
8 changed files with 978 additions and 13 deletions

View File

@@ -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_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) | | `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 : 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/<application>/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/<nom>/` 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 `<fichier>.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 ## 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)

View File

@@ -7,11 +7,14 @@ Expose une API REST utilisée par le backend central pour interroger les contene
import asyncio import asyncio
import json import json
import os import os
import re
import shutil
import socket as socket_module import socket as socket_module
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
import docker import docker
import psutil import psutil
@@ -19,10 +22,11 @@ from docker.errors import DockerException, NotFound
from fastapi import Depends, FastAPI, HTTPException, Security, WebSocket, WebSocketDisconnect 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
from pydantic import BaseModel
# ─── Config ─────────────────────────────────────────────────────────────────── # ─── 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") 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")
@@ -37,7 +41,7 @@ app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_u
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_methods=["GET", "POST"], allow_methods=["GET", "POST", "PUT"],
allow_headers=["*"], 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} return {"output": output, "project": project, "working_dir": working_dir}
# ─── Applications compose ────────────────────────────────────────────────────
#
# Convention : une application = un dossier sous /home contenant son fichier
# compose, soit /home/<application>/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/<nom> 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") @app.get("/services")
def list_services(_: None = Depends(require_api_key)): def list_services(_: None = Depends(require_api_key)):
"""Retourne la liste des services systemd (hors Docker) avec leur état.""" """Retourne la liste des services systemd (hors Docker) avec leur état."""

View File

@@ -128,6 +128,18 @@ class ComposeUpdateRequest(BaseModel):
project: str 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): class RegisterRequest(BaseModel):
username: str username: str
password: str password: str
@@ -601,6 +613,25 @@ async def agent_post(vps: dict, path: str, payload: dict | None = None):
return await r.json() 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: async def fetch_vps_status(vps: dict) -> dict:
"""Interroge un agent et retourne son état complet. """Interroge un agent et retourne son état complet.
@@ -1472,6 +1503,97 @@ async def container_action(
raise HTTPException(status_code=502, detail=str(e)) 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") @app.post("/api/vps/{vps_id}/containers/{container_id}/exec/ticket")
async def container_exec_ticket( async def container_exec_ticket(
vps_id: str, container_id: str, vps_id: str, container_id: str,

View File

@@ -18,6 +18,7 @@ import { copyText, downloadText } from './lib/clipboard'
// xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal. // xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal.
const TerminalModal = lazy(() => import('./components/TerminalModal')) const TerminalModal = lazy(() => import('./components/TerminalModal'))
const AppsModal = lazy(() => import('./components/AppsModal'))
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 }, { label: '10 s', value: 10_000 },
@@ -122,6 +123,7 @@ export default function App() {
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
const [terminalModal, setTerminalModal] = useState(null) // { vps, container } const [terminalModal, setTerminalModal] = useState(null) // { vps, container }
const [appsModal, setAppsModal] = useState(null) // vps
// Vérifie si des utilisateurs existent (pour afficher login ou register) // Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => { useEffect(() => {
@@ -206,7 +208,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 || terminalModal) const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal || appsModal)
useEffect(() => { useEffect(() => {
if (!token || page !== 'main' || modalOpen) return if (!token || page !== 'main' || modalOpen) return
const onKeyDown = (e) => { 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 // Terminal et édition des fichiers compose reviennent à choisir ce qui tourne
// désactivable globalement depuis la page d'administration. // sur la machine : réservés aux admins. Le terminal est en plus désactivable
const canUseTerminal = role === 'admin' && terminalEnabled // globalement depuis la page d'administration.
const isAdmin = role === 'admin'
const canUseTerminal = isAdmin && terminalEnabled
// Statistiques globales // Statistiques globales
const totalOnline = vpsList.filter(v => v.online).length const totalOnline = vpsList.filter(v => v.online).length
@@ -522,6 +526,7 @@ export default function App() {
onUpdateAgent={handleUpdateAgent} onUpdateAgent={handleUpdateAgent}
onExport={handleExportVps} onExport={handleExportVps}
onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined} onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined}
onApps={isAdmin ? setAppsModal : undefined}
/> />
))} ))}
</div> </div>
@@ -589,6 +594,17 @@ export default function App() {
</Suspense> </Suspense>
)} )}
{/* Applications compose */}
{appsModal && (
<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
</div>
}>
<AppsModal vps={appsModal} onClose={() => { setAppsModal(null); refresh() }} />
</Suspense>
)}
{/* Confirmation de suppression */} {/* Confirmation de suppression */}
{deleteTarget && ( {deleteTarget && (
<ConfirmDialog <ConfirmDialog

View File

@@ -127,6 +127,46 @@ export async function fetchVpsStats(vpsId, duration = 600) {
return handleResponse(res) return handleResponse(res)
} }
// ─── Applications compose ─────────────────────────────────────────────────────
export async function fetchApps(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}/apps`, { headers: authHeaders() })
return handleResponse(res)
}
export async function fetchAppCompose(vpsId, name) {
const res = await fetch(`${BASE}/vps/${vpsId}/apps/${encodeURIComponent(name)}/compose`, {
headers: authHeaders(),
})
return handleResponse(res)
}
export async function saveAppCompose(vpsId, name, content, deploy = false) {
const res = await fetch(`${BASE}/vps/${vpsId}/apps/${encodeURIComponent(name)}/compose`, {
method: 'PUT',
headers: authHeaders(),
body: JSON.stringify({ content, deploy }),
})
return handleResponse(res)
}
export async function createApp(vpsId, { name, content, filename = 'compose.yaml', deploy = false }) {
const res = await fetch(`${BASE}/vps/${vpsId}/apps`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ name, content, filename, deploy }),
})
return handleResponse(res)
}
export async function deployApp(vpsId, name) {
const res = await fetch(`${BASE}/vps/${vpsId}/apps/${encodeURIComponent(name)}/up`, {
method: 'POST',
headers: authHeaders(),
})
return handleResponse(res)
}
// ─── Terminal conteneur ─────────────────────────────────────────────────────── // ─── Terminal conteneur ───────────────────────────────────────────────────────
/** Échange le JWT contre un ticket à usage unique (60 s) pour ouvrir le terminal. */ /** Échange le JWT contre un ticket à usage unique (60 s) pour ouvrir le terminal. */

View File

@@ -0,0 +1,417 @@
import { useCallback, useEffect, useState } from 'react'
import {
Boxes, FilePlus2, RefreshCw, Pencil, Rocket, ArrowLeft, AlertTriangle,
FolderOpen, Terminal as TerminalIcon,
} from 'lucide-react'
import Modal from './ui/Modal'
import YamlEditor from './YamlEditor'
import { Button, inputClass, Skeleton, EmptyState } from './ui/controls'
import { useToast } from './ui/Toast'
import { fetchApps, fetchAppCompose, saveAppCompose, createApp, deployApp } from '../api/client'
import { formatBytes } from '../lib/format'
const APP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
const COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']
const TEMPLATE = (name) => `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() || '<nom>'}/${newFilename}`,
}[view]
return (
<Modal
size="xl"
title={title}
subtitle={subtitle}
icon={<Boxes size={16} className="text-indigo-400 flex-shrink-0" />}
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' && (
<>
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load} disabled={loading}>
<span className="hidden sm:inline">Actualiser</span>
</Button>
<Button variant="primary" size="sm" icon={FilePlus2} onClick={startCreate}>
<span className="hidden sm:inline">Nouvelle application</span>
</Button>
</>
)}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
{leaving ? (
<>
<p className="text-xs text-orange-300 flex items-center gap-1.5">
<AlertTriangle size={13} />
Modifications non enregistrées. Quitter sans enregistrer ?
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setLeaving(false)}>Rester</Button>
<Button variant="danger" size="sm" onClick={() => { setLeaving(false); setEditing(null); setView('list') }}>
Abandonner
</Button>
</div>
</>
) : view === 'list' ? (
<>
<p className="text-[11px] text-gray-600">
{apps.length} application{apps.length > 1 ? 's' : ''} détectée{apps.length > 1 ? 's' : ''}
</p>
<Button variant="outline" size="sm" onClick={onClose}>Fermer</Button>
</>
) : view === 'editor' ? (
<>
<p className="text-[11px] text-gray-600">
{dirty
? 'Modifications non enregistrées · l\'ancienne version est sauvegardée en .bak'
: 'Validé par `docker compose config` avant écriture'}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" icon={ArrowLeft} onClick={backToList}>Retour</Button>
<Button variant="secondary" size="sm" onClick={() => handleSave(false)} loading={saving} disabled={!dirty}>
Enregistrer
</Button>
<Button variant="primary" size="sm" icon={Rocket} onClick={() => handleSave(true)} loading={saving}>
Enregistrer et déployer
</Button>
</div>
</>
) : (
<>
<p className="text-[11px] text-gray-600">
Le dossier est créé s'il n'existe pas encore.
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" icon={ArrowLeft} onClick={() => setView('list')}>Retour</Button>
<Button variant="secondary" size="sm" onClick={() => handleCreate(false)} loading={saving} disabled={!nameValid}>
Créer
</Button>
<Button variant="primary" size="sm" icon={Rocket} onClick={() => handleCreate(true)} loading={saving} disabled={!nameValid}>
Créer et déployer
</Button>
</div>
</>
)}
</div>
}
>
{/* ── Liste ── */}
{view === 'list' && (
<div className="flex-1 overflow-y-auto p-4">
{error && (
<div role="alert" className="mb-4 bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-14" />)}
</div>
) : apps.length === 0 && !error ? (
<EmptyState
icon={FolderOpen}
title="Aucune application détectée"
description="Aucun dossier de /home ne contient de fichier compose. Créez-en une pour démarrer."
action={<Button variant="primary" icon={FilePlus2} onClick={startCreate}>Nouvelle application</Button>}
/>
) : (
<ul className="divide-y divide-gray-800/60">
{apps.map(app => (
<li key={app.name} className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-200">{app.name}</span>
<span className="text-[10px] font-mono text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded">
{app.file}
</span>
{app.running_containers > 0 ? (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400">
{app.running_containers} conteneur{app.running_containers > 1 ? 's' : ''} actif{app.running_containers > 1 ? 's' : ''}
</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-700/40 text-gray-500">
arrêtée
</span>
)}
</div>
<p className="text-xs text-gray-600 font-mono truncate mt-0.5">
{app.path} · {formatBytes(app.size)} · modifié le {new Date(app.modified).toLocaleString('fr-FR')}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="secondary" size="sm" icon={Pencil}
onClick={() => openEditor(app)} loading={busy === app.name}>
Éditer
</Button>
<Button variant="outline" size="sm" icon={Rocket}
onClick={() => handleDeploy(app)} loading={busy === app.name}>
Déployer
</Button>
</div>
</li>
))}
</ul>
)}
{output && <DeployOutput output={output} onClear={() => setOutput(null)} />}
</div>
)}
{/* ── Édition ── */}
{view === 'editor' && editing && (
<div className="flex-1 flex flex-col overflow-hidden">
{saveError && (
<div role="alert" className="m-4 mb-0 bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 whitespace-pre-wrap font-mono max-h-32 overflow-y-auto">
{saveError}
</div>
)}
<div className="p-4 pb-0 flex-1 flex flex-col overflow-hidden">
<YamlEditor value={content} onChange={setContent} disabled={saving} />
{output && <DeployOutput output={output} onClear={() => setOutput(null)} />}
</div>
</div>
)}
{/* ── Création ── */}
{view === 'create' && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{saveError && (
<div role="alert" className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 whitespace-pre-wrap font-mono max-h-32 overflow-y-auto">
{saveError}
</div>
)}
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[200px]">
<label htmlFor="app-name" className="block text-xs text-gray-400 mb-1">
Nom de l'application <span className="text-red-400" aria-hidden="true">*</span>
</label>
<input
id="app-name"
value={newName}
onChange={e => handleNewName(e.target.value)}
placeholder="mon-app"
autoFocus
className={inputClass}
/>
<p className={`text-[11px] mt-1 ${newName && !nameValid ? 'text-red-400' : 'text-gray-600'}`}>
{newName && !nameValid
? 'Lettres, chiffres, point, tiret et souligné uniquement.'
: `Dossier créé : /home/${newName.trim() || '<nom>'}`}
</p>
</div>
<div>
<label htmlFor="app-filename" className="block text-xs text-gray-400 mb-1">Nom du fichier</label>
<select
id="app-filename"
value={newFilename}
onChange={e => setNewFilename(e.target.value)}
className={inputClass}
>
{COMPOSE_FILENAMES.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
</div>
<YamlEditor value={newContent} onChange={handleNewContent} disabled={saving} height="h-[38vh]" />
{output && <DeployOutput output={output} onClear={() => setOutput(null)} />}
</div>
)}
</Modal>
)
}
function DeployOutput({ output, onClear }) {
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="flex items-center gap-1.5 text-xs text-gray-400">
<TerminalIcon size={12} className="text-indigo-400" />
Sortie du déploiement
</span>
<button onClick={onClear} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">
Masquer
</button>
</div>
<pre className="bg-gray-950 border border-gray-800 rounded-lg p-3 text-[11px] font-mono text-gray-300 whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
{output || '(aucune sortie)'}
</pre>
</div>
)
}

View File

@@ -1,16 +1,21 @@
import { useEffect, useMemo, useState } from 'react' 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 ContainerRow from './ContainerRow'
import { tagColor } from './TagInput' 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) { * Compare la version d'un agent à un minimum requis.
if (!version || version === 'unknown') return true // version inconnue : on laisse tenter * 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)) const [major, minor] = version.split('.').map(n => parseInt(n, 10))
if (Number.isNaN(major)) return true 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). */ /** 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 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)
@@ -110,6 +115,20 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
onClick={() => setCollapsed(c => !c)} onClick={() => setCollapsed(c => !c)}
/> />
{vps.online && onApps && (
<IconButton
icon={Boxes}
label={
agentAtLeast(vps.agent_version, 1, 4)
? 'Applications compose (/home)'
: `Édition des fichiers compose : agent v${vps.agent_version} — mettez à jour vers v1.4.0 ou plus.`
}
tone="accent"
disabled={!agentAtLeast(vps.agent_version, 1, 4)}
onClick={() => onApps(vps)}
/>
)}
{vps.online && ( {vps.online && (
<IconButton <IconButton
icon={BarChart2} icon={BarChart2}
@@ -304,7 +323,7 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
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} onTerminal={onTerminal ? () => onTerminal(vps, c) : undefined}
execDisabledReason={ execDisabledReason={
agentSupportsExec(vps.agent_version) agentAtLeast(vps.agent_version, 1, 3)
? undefined ? undefined
: `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.` : `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.`
} }

View File

@@ -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 (
<div className={`flex ${height} bg-gray-950 border-y border-gray-800 font-mono text-xs leading-5`}>
<div
ref={gutterRef}
aria-hidden="true"
className="flex-shrink-0 w-12 overflow-hidden py-2 text-right text-gray-700 select-none bg-gray-900/40"
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i} className="px-2">{i + 1}</div>
))}
</div>
<textarea
ref={areaRef}
value={value}
onChange={e => onChange(e.target.value)}
onScroll={syncScroll}
onKeyDown={handleKeyDown}
disabled={disabled}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
wrap="off"
aria-label="Contenu du fichier compose"
className="flex-1 resize-none bg-transparent py-2 px-3 text-gray-200 outline-none
placeholder-gray-700 disabled:opacity-60 leading-5"
/>
</div>
)
}