diff --git a/vps-monitor/.pids b/vps-monitor/.pids index f21dac2..b291492 100644 --- a/vps-monitor/.pids +++ b/vps-monitor/.pids @@ -1,2 +1,2 @@ -1456 -1540 +2317 +2396 diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index 5770782..f916528 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -5,9 +5,12 @@ Expose une API REST utilisée par le backend central pour interroger les contene """ import os +import subprocess +import time from datetime import datetime, timezone import docker +import psutil from docker.errors import DockerException, NotFound from fastapi import Depends, FastAPI, HTTPException, Security from fastapi.middleware.cors import CORSMiddleware @@ -59,14 +62,18 @@ def list_containers(_: None = Depends(require_api_key)): result = [] for c in client.containers.list(all=True): image_tag = c.image.tags[0] if c.image.tags else c.image.short_id + health_state = c.attrs.get("State", {}).get("Health", {}) + health = health_state.get("Status", "none") if health_state else "none" result.append({ "id": c.short_id, "name": c.name.lstrip("/"), "status": c.status, + "health": health, "image": image_tag, "created": c.attrs.get("Created", ""), "compose_project": c.labels.get("com.docker.compose.project", ""), "compose_service": c.labels.get("com.docker.compose.service", ""), + "compose_working_dir": c.labels.get("com.docker.compose.project.working_dir", ""), "ports": { host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings] for host, bindings in (c.ports or {}).items() @@ -102,6 +109,69 @@ def container_action(container_id: str, action: str, _: None = Depends(require_a raise HTTPException(status_code=404, detail="Conteneur introuvable") +@app.get("/system") +def system_info(_: None = Depends(require_api_key)): + """Retourne les informations système : CPU, RAM et bande passante.""" + cpu_percent = psutil.cpu_percent(interval=0.5) + mem = psutil.virtual_memory() + + net1 = psutil.net_io_counters() + time.sleep(0.5) + net2 = psutil.net_io_counters() + net_sent_per_sec = (net2.bytes_sent - net1.bytes_sent) * 2 + net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2 + + return { + "cpu_percent": cpu_percent, + "ram_used": mem.used, + "ram_total": mem.total, + "ram_percent": mem.percent, + "net_sent_per_sec": net_sent_per_sec, + "net_recv_per_sec": net_recv_per_sec, + } + + +@app.post("/compose/update") +def compose_update(project: str, _: None = Depends(require_api_key)): + """Pull les nouvelles images et recrée les conteneurs d'un projet compose.""" + client = get_docker_client() + + working_dir = None + for c in client.containers.list(all=True): + if c.labels.get("com.docker.compose.project") == project: + working_dir = c.labels.get("com.docker.compose.project.working_dir") + if working_dir: + break + + if not working_dir: + raise HTTPException( + status_code=404, + detail=f"Projet compose '{project}' introuvable ou sans répertoire de travail", + ) + + output = "" + + pull = subprocess.run( + ["docker", "compose", "pull"], + cwd=working_dir, + capture_output=True, + text=True, + timeout=300, + ) + output += pull.stdout + pull.stderr + + up = subprocess.run( + ["docker", "compose", "up", "-d", "--remove-orphans"], + cwd=working_dir, + capture_output=True, + text=True, + timeout=120, + ) + output += up.stdout + up.stderr + + return {"output": output, "project": project, "working_dir": working_dir} + + # ─── Entrée ─────────────────────────────────────────────────────────────────── if __name__ == "__main__": diff --git a/vps-monitor/agent/requirements.txt b/vps-monitor/agent/requirements.txt index b68a2a6..c31bcbb 100644 --- a/vps-monitor/agent/requirements.txt +++ b/vps-monitor/agent/requirements.txt @@ -1,3 +1,4 @@ fastapi>=0.111.0 uvicorn[standard]>=0.30.0 docker>=7.1.0 +psutil>=5.9.0 diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index 9bf1e4b..0d87bb4 100644 Binary files a/vps-monitor/backend/__pycache__/main.cpython-313.pyc and b/vps-monitor/backend/__pycache__/main.cpython-313.pyc differ diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index 8b29a70..7674604 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -62,6 +62,10 @@ class ActionRequest(BaseModel): action: str # start | stop | restart +class ComposeUpdateRequest(BaseModel): + project: str + + class RegisterRequest(BaseModel): username: str password: str @@ -209,14 +213,22 @@ async def agent_post(vps: dict, path: str, payload: dict | None = None): async def fetch_vps_status(vps: dict) -> dict: """Interroge un agent et retourne son état complet.""" try: - containers = await agent_get(vps, "/containers") + containers_res, system_res = await asyncio.gather( + agent_get(vps, "/containers"), + agent_get(vps, "/system"), + return_exceptions=True, + ) + if isinstance(containers_res, Exception): + raise containers_res + system = system_res if not isinstance(system_res, Exception) else None return { "id": vps["id"], "name": vps["name"], "host": vps["host"], "description": vps.get("description", ""), "online": True, - "containers": containers, + "containers": containers_res, + "system": system, } except Exception as e: return { @@ -227,6 +239,7 @@ async def fetch_vps_status(vps: dict) -> dict: "online": False, "error": str(e), "containers": [], + "system": None, } @@ -347,3 +360,24 @@ async def container_action( return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}") except Exception as e: raise HTTPException(status_code=502, detail=str(e)) + + +@app.post("/api/vps/{vps_id}/compose/update") +async def compose_update( + vps_id: str, body: ComposeUpdateRequest, + _: Annotated[dict, Depends(get_current_user)] = None +): + """Lance docker compose pull + up sur un projet via l'agent.""" + vps = next((v for v in load_vps() if v["id"] == vps_id), None) + if not vps: + raise HTTPException(status_code=404, detail="VPS introuvable") + try: + url = f"http://{vps['host']}:{vps['port']}/compose/update?project={body.project}" + headers = {"X-API-Key": vps["api_key"]} + timeout = aiohttp.ClientTimeout(total=600) + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, timeout=timeout) as r: + r.raise_for_status() + return await r.json() + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index 87fee8e..7cfd995 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken } from './api/client' +import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate } from './api/client' import Header from './components/Header' import VpsCard from './components/VpsCard' import LogsModal from './components/LogsModal' @@ -24,6 +24,10 @@ export default function App() { const [logsLoading, setLogsLoading] = useState(false) const [showAddVps, setShowAddVps] = useState(false) + const [updateModal, setUpdateModal] = useState(null) // { vpsId, project } + const [updateContent, setUpdateContent] = useState('') + const [updateLoading, setUpdateLoading] = useState(false) + // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { authStatus() @@ -114,6 +118,21 @@ export default function App() { await refresh() } + const handleUpdate = async (vpsId, project) => { + setUpdateModal({ vpsId, project }) + setUpdateLoading(true) + setUpdateContent('') + try { + const data = await composeUpdate(vpsId, project) + setUpdateContent(data.output || '(aucune sortie)') + } catch (e) { + setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`) + } finally { + setUpdateLoading(false) + await refresh() + } + } + const handleAddVps = async (formData) => { await addVps(formData) setShowAddVps(false) @@ -214,6 +233,7 @@ export default function App() { onAction={handleAction} onLogs={openLogs} onDelete={handleDeleteVps} + onUpdate={handleUpdate} /> ))} @@ -230,6 +250,16 @@ export default function App() { /> )} + {/* Modal mise à jour compose */} + {updateModal && ( + setUpdateModal(null)} + /> + )} + {/* Modal ajout VPS */} {showAddVps && ( + + {s.label} + + ) +} + export default function ContainerRow({ container, onAction, onLogs }) { const [pending, setPending] = useState(null) const isRunning = container.status === 'running' @@ -16,8 +33,7 @@ export default function ContainerRow({ container, onAction, onLogs }) {
{container.name} - - {container.compose_project && ( + {container.compose_project && ( {container.compose_project} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index c4c87fe..f6723f3 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -1,13 +1,32 @@ -import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp } from 'lucide-react' +import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown } from 'lucide-react' import { useState } from 'react' import ContainerRow from './ContainerRow' -export default function VpsCard({ vps, onAction, onLogs, onDelete }) { +function formatBytes(bps) { + if (bps < 1024) return `${bps.toFixed(0)} B/s` + if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s` + return `${(bps / 1024 / 1024).toFixed(1)} MB/s` +} + +function formatRam(bytes) { + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB` + return `${(bytes / 1024 ** 3).toFixed(1)} GB` +} + +export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate }) { const [collapsed, setCollapsed] = useState(false) + const [updatingProject, setUpdatingProject] = useState(null) const running = vps.containers.filter(c => c.status === 'running').length const total = vps.containers.length + const composeProjects = [...new Set(vps.containers.map(c => c.compose_project).filter(Boolean))] + + const handleUpdate = async (project) => { + setUpdatingProject(project) + try { await onUpdate(vps.id, project) } finally { setUpdatingProject(null) } + } + return (
{/* Header */} @@ -59,6 +78,25 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
)} + {/* Informations système */} + {vps.online && vps.system && !collapsed && ( +
+ + + CPU 80 ? 'text-red-400' : vps.system.cpu_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{vps.system.cpu_percent.toFixed(1)}% + + + + RAM 80 ? 'text-red-400' : vps.system.ram_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{formatRam(vps.system.ram_used)}/{formatRam(vps.system.ram_total)} + ({vps.system.ram_percent.toFixed(0)}%) + + + {formatBytes(vps.system.net_sent_per_sec)} + {formatBytes(vps.system.net_recv_per_sec)} + +
+ )} + {/* Description */} {vps.description && !collapsed && (

{vps.description}

@@ -82,6 +120,24 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
)} + {/* Boutons de mise à jour par projet compose */} + {!collapsed && vps.online && composeProjects.length > 0 && ( +
+ {composeProjects.map(project => ( + + ))} +
+ )} + {/* Footer stats */} {!collapsed && vps.online && total > 0 && (