feat : update agent 2
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s

This commit is contained in:
jeanotx32
2026-05-18 23:29:18 -04:00
parent a235116669
commit dfca25ab03
9 changed files with 226 additions and 10 deletions

View File

@@ -1,2 +1,2 @@
1456
1540
2317
2396

View File

@@ -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__":

View File

@@ -1,3 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
docker>=7.1.0
psutil>=5.9.0

View File

@@ -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))

View File

@@ -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}
/>
))}
</div>
@@ -230,6 +250,16 @@ export default function App() {
/>
)}
{/* Modal mise à jour compose */}
{updateModal && (
<LogsModal
name={`🔄 Update — ${updateModal.project}`}
logs={updateContent}
loading={updateLoading}
onClose={() => setUpdateModal(null)}
/>
)}
{/* Modal ajout VPS */}
{showAddVps && (
<AddVpsModal

View File

@@ -90,3 +90,12 @@ export async function deleteVps(vpsId) {
})
return handleResponse(res)
}
export async function composeUpdate(vpsId, project) {
const res = await fetch(`${BASE}/vps/${vpsId}/compose/update`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ project }),
})
return handleResponse(res)
}

View File

@@ -1,7 +1,24 @@
import { useState } from 'react'
import { Play, Square, RotateCcw, FileText, Loader2 } from 'lucide-react'
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
import StatusBadge from './StatusBadge'
const HEALTH_STYLES = {
healthy: { dot: 'bg-emerald-400', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'healthy' },
unhealthy: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', label: 'unhealthy' },
starting: { dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' },
}
function HealthBadge({ health }) {
const s = HEALTH_STYLES[health]
if (!s) return null
return (
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium border ${s.bg} ${s.text}`}>
<Heart size={9} className="flex-shrink-0" />
{s.label}
</span>
)
}
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 }) {
<div className="min-w-0 flex-1 pr-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
<StatusBadge status={container.status} />
{container.compose_project && (
<StatusBadge status={container.status} /> <HealthBadge health={container.health} /> {container.compose_project && (
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{container.compose_project}
</span>

View File

@@ -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 (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Header */}
@@ -59,6 +78,25 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
</div>
)}
{/* Informations système */}
{vps.online && vps.system && !collapsed && (
<div className="px-4 py-2 border-b border-gray-800/60 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400 bg-gray-900/50">
<span className="flex items-center gap-1">
<Cpu size={11} className="text-indigo-400" />
CPU <span className={`font-medium ml-0.5 ${vps.system.cpu_percent > 80 ? 'text-red-400' : vps.system.cpu_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{vps.system.cpu_percent.toFixed(1)}%</span>
</span>
<span className="flex items-center gap-1">
<MemoryStick size={11} className="text-indigo-400" />
RAM <span className={`font-medium ml-0.5 ${vps.system.ram_percent > 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)}</span>
<span className="text-gray-600">({vps.system.ram_percent.toFixed(0)}%)</span>
</span>
<span className="flex items-center gap-1">
<ArrowUp size={11} className="text-sky-400" />{formatBytes(vps.system.net_sent_per_sec)}
<ArrowDown size={11} className="text-violet-400 ml-1" />{formatBytes(vps.system.net_recv_per_sec)}
</span>
</div>
)}
{/* Description */}
{vps.description && !collapsed && (
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
@@ -82,6 +120,24 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
</div>
)}
{/* Boutons de mise à jour par projet compose */}
{!collapsed && vps.online && composeProjects.length > 0 && (
<div className="px-4 py-2 border-t border-gray-800/60 flex flex-wrap gap-2">
{composeProjects.map(project => (
<button
key={project}
onClick={() => handleUpdate(project)}
disabled={!!updatingProject}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-indigo-600/20 border border-indigo-500/30 text-indigo-300 hover:bg-indigo-600/40 hover:text-indigo-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`docker compose pull && up -d (${project})`}
>
<RefreshCw size={11} className={updatingProject === project ? 'animate-spin' : ''} />
Update {project}
</button>
))}
</div>
)}
{/* Footer stats */}
{!collapsed && vps.online && total > 0 && (
<div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600">