From dfca25ab03d9179c18dd27a179a547e6845f9182 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Mon, 18 May 2026 23:29:18 -0400 Subject: [PATCH] feat : update agent 2 --- vps-monitor/.pids | 4 +- vps-monitor/agent/agent.py | 70 ++++++++++++++++++ vps-monitor/agent/requirements.txt | 1 + .../backend/__pycache__/main.cpython-313.pyc | Bin 20400 -> 22890 bytes vps-monitor/backend/main.py | 38 +++++++++- vps-monitor/frontend/src/App.jsx | 32 +++++++- vps-monitor/frontend/src/api/client.js | 9 +++ .../frontend/src/components/ContainerRow.jsx | 22 +++++- .../frontend/src/components/VpsCard.jsx | 60 ++++++++++++++- 9 files changed, 226 insertions(+), 10 deletions(-) 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 9bf1e4b935df32e14e0dc6ecb79e5b2a899c0eaa..0d87bb46cf548ae79d22f941e845fb5405866705 100644 GIT binary patch delta 5552 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 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 && (