Feat : Better stats
Some checks failed
Build and Push Docker Images / docker (push) Failing after 7s

This commit is contained in:
jeanotx32
2026-05-18 23:54:44 -04:00
parent c7cc18101a
commit 3080826806
7 changed files with 476 additions and 6 deletions

View File

@@ -128,6 +128,8 @@ def system_info(_: None = Depends(require_api_key)):
"ram_percent": mem.percent, "ram_percent": mem.percent,
"net_sent_per_sec": net_sent_per_sec, "net_sent_per_sec": net_sent_per_sec,
"net_recv_per_sec": net_recv_per_sec, "net_recv_per_sec": net_recv_per_sec,
"net_bytes_sent": net2.bytes_sent,
"net_bytes_recv": net2.bytes_recv,
} }

View File

@@ -9,6 +9,7 @@ import json
import os import os
import secrets import secrets
import sqlite3 import sqlite3
from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -25,6 +26,10 @@ from pydantic import BaseModel
# ─── Config ─────────────────────────────────────────────────────────────────── # ─── Config ───────────────────────────────────────────────────────────────────
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db")) DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
# ─── Ring buffer de stats (en mémoire) ───────────────────────────────────────
_STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle
_stats_history: dict[str, deque] = {}
SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret")) SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret"))
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5")) AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
@@ -279,6 +284,45 @@ async def fetch_vps_status(vps: dict) -> dict:
} }
# ─── Collecteur de stats (tâche de fond) ─────────────────────────────────────
async def _fetch_system_stat(vps: dict) -> None:
"""Collecte un point de stats système pour un VPS et l'insère dans le ring buffer."""
try:
data = await agent_get(vps, "/system")
buf = _stats_history.setdefault(vps["id"], deque(maxlen=_STATS_MAX_POINTS))
buf.append({
"ts": datetime.now(timezone.utc).isoformat(),
"cpu": data["cpu_percent"],
"ram_percent": data["ram_percent"],
"ram_used": data["ram_used"],
"ram_total": data["ram_total"],
"net_sent_per_sec": data["net_sent_per_sec"],
"net_recv_per_sec": data["net_recv_per_sec"],
"net_bytes_sent": data.get("net_bytes_sent", 0),
"net_bytes_recv": data.get("net_bytes_recv", 0),
})
except Exception:
pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement
async def _stats_collector() -> None:
"""Tâche de fond : collecte les stats de tous les VPS toutes les 5 secondes."""
await asyncio.sleep(3) # laisse l'app finir de démarrer
while True:
vps_list = load_vps()
await asyncio.gather(
*[_fetch_system_stat(v) for v in vps_list],
return_exceptions=True,
)
await asyncio.sleep(5)
@app.on_event("startup")
async def startup_event() -> None:
asyncio.create_task(_stats_collector())
# ─── Routes Auth ────────────────────────────────────────────────────────────── # ─── Routes Auth ──────────────────────────────────────────────────────────────
@app.get("/api/auth/status") @app.get("/api/auth/status")
@@ -370,6 +414,14 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/vps/{vps_id}/stats")
def get_vps_stats(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
"""Retourne l'historique des stats système d'un VPS (ring buffer en mémoire)."""
if not any(v["id"] == vps_id for v in load_vps()):
raise HTTPException(status_code=404, detail="VPS introuvable")
return list(_stats_history.get(vps_id, deque()))
@app.get("/api/status") @app.get("/api/status")
async def all_status(_: Annotated[dict, Depends(get_current_user)]): async def all_status(_: Annotated[dict, Depends(get_current_user)]):
"""Retourne l'état de tous les VPS en parallèle.""" """Retourne l'état de tous les VPS en parallèle."""

View File

@@ -5,6 +5,7 @@ import VpsCard from './components/VpsCard'
import LogsModal from './components/LogsModal' import LogsModal from './components/LogsModal'
import AddVpsModal from './components/AddVpsModal' import AddVpsModal from './components/AddVpsModal'
import EditVpsModal from './components/EditVpsModal' import EditVpsModal from './components/EditVpsModal'
import StatsModal from './components/StatsModal'
import LoginPage from './components/LoginPage' import LoginPage from './components/LoginPage'
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
@@ -46,6 +47,8 @@ export default function App() {
const [updateContent, setUpdateContent] = useState('') const [updateContent, setUpdateContent] = useState('')
const [updateLoading, setUpdateLoading] = useState(false) const [updateLoading, setUpdateLoading] = useState(false)
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
// Vérifie si des utilisateurs existent (pour afficher login ou register) // Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => { useEffect(() => {
authStatus() authStatus()
@@ -263,6 +266,7 @@ export default function App() {
onDelete={handleDeleteVps} onDelete={handleDeleteVps}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onEdit={setEditVps} onEdit={setEditVps}
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
/> />
))} ))}
</div> </div>
@@ -305,6 +309,15 @@ export default function App() {
onClose={() => setEditVps(null)} onClose={() => setEditVps(null)}
/> />
)} )}
{/* Modal statistiques */}
{statsModal && (
<StatsModal
vpsId={statsModal.vpsId}
vpsName={statsModal.vpsName}
onClose={() => setStatsModal(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -108,3 +108,8 @@ export async function updateVps(vpsId, data) {
}) })
return handleResponse(res) return handleResponse(res)
} }
export async function fetchVpsStats(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}/stats`, { headers: authHeaders() })
return handleResponse(res)
}

View File

@@ -0,0 +1,388 @@
import { useEffect, useState, useCallback } from 'react'
import { X, BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react'
import { fetchVpsStats } from '../api/client'
// ─── Formatters ───────────────────────────────────────────────────────────────
function fmtBytes(bytes) {
if (!bytes || bytes < 1) return '0 B'
if (bytes < 1024) return `${bytes.toFixed(0)} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
function fmtBps(bps) {
if (bps === undefined || bps === null) return '—'
if (bps < 1024) return `${bps.toFixed(0)} B/s`
if (bps < 1024 ** 2) return `${(bps / 1024).toFixed(1)} KB/s`
return `${(bps / 1024 ** 2).toFixed(2)} MB/s`
}
function fmtRam(bytes) {
if (!bytes) return '—'
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
}
function avg(arr) {
if (!arr.length) return '—'
return (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1)
}
// ─── SVG Sparkline ────────────────────────────────────────────────────────────
function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
if (!data.length) {
return (
<div
className="flex items-center justify-center text-gray-700 text-xs italic"
style={{ height }}
>
En attente de données
</div>
)
}
const W = 500
const H = height
const P = 3
const range = (max - min) || 1
const sx = (i) => P + (i / Math.max(data.length - 1, 1)) * (W - P * 2)
const sy = (v) => H - P - (Math.max(0, Math.min(1, (v - min) / range)) * (H - P * 2))
const linePts = data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ')
const areaPts = [
`${sx(0).toFixed(1)},${(H - P).toFixed(1)}`,
...data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`),
`${sx(data.length - 1).toFixed(1)},${(H - P).toFixed(1)}`,
].join(' ')
// Hover tooltip state
const [tooltip, setTooltip] = useState(null)
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const xRatio = (e.clientX - rect.left) / rect.width
const idx = Math.min(data.length - 1, Math.max(0, Math.round(xRatio * (data.length - 1))))
setTooltip({ idx, value: data[idx] })
}
return (
<div className="relative" style={{ height }}>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="w-full cursor-crosshair"
style={{ height }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
>
{/* Grid lines */}
{[25, 50, 75].map(pct => {
const y = sy(min + range * pct / 100)
return (
<line
key={pct}
x1={P} y1={y.toFixed(1)}
x2={W - P} y2={y.toFixed(1)}
stroke="#1f2937" strokeWidth="1"
/>
)
})}
{/* Area fill */}
<polygon points={areaPts} fill={fill} opacity="0.2" />
{/* Line */}
<polyline
points={linePts}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Hover dot */}
{tooltip && (
<circle
cx={sx(tooltip.idx).toFixed(1)}
cy={sy(tooltip.value).toFixed(1)}
r="3"
fill={color}
/>
)}
</svg>
{/* Tooltip bubble */}
{tooltip && (
<div
className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-white pointer-events-none"
style={{ whiteSpace: 'nowrap' }}
>
{typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}
</div>
)}
</div>
)
}
// Dual sparkline (upload + download on same chart)
function DualSparkline({ sentData, recvData, height = 60 }) {
const allValues = [...sentData, ...recvData]
const maxVal = Math.max(...allValues, 1) * 1.15
const W = 500, H = height, P = 3
const sx = (i, len) => P + (i / Math.max(len - 1, 1)) * (W - P * 2)
const sy = (v) => H - P - (Math.max(0, Math.min(1, v / maxVal)) * (H - P * 2))
const mkLine = (data) => data.map((v, i) => `${sx(i, data.length).toFixed(1)},${sy(v).toFixed(1)}`).join(' ')
const mkArea = (data) => [
`${sx(0, data.length).toFixed(1)},${(H - P).toFixed(1)}`,
...data.map((v, i) => `${sx(i, data.length).toFixed(1)},${sy(v).toFixed(1)}`),
`${sx(data.length - 1, data.length).toFixed(1)},${(H - P).toFixed(1)}`,
].join(' ')
const [tooltip, setTooltip] = useState(null)
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const xRatio = (e.clientX - rect.left) / rect.width
const idx = Math.min(sentData.length - 1, Math.max(0, Math.round(xRatio * (sentData.length - 1))))
setTooltip({ idx, sent: sentData[idx], recv: recvData[idx] })
}
return (
<div className="relative" style={{ height }}>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="w-full cursor-crosshair"
style={{ height }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
>
{[25, 50, 75].map(pct => (
<line
key={pct}
x1={P} y1={sy(maxVal * pct / 100).toFixed(1)}
x2={W - P} y2={sy(maxVal * pct / 100).toFixed(1)}
stroke="#1f2937" strokeWidth="1"
/>
))}
<polygon points={mkArea(sentData)} fill="#38bdf8" opacity="0.15" />
<polyline points={mkLine(sentData)} fill="none" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<polygon points={mkArea(recvData)} fill="#a78bfa" opacity="0.15" />
<polyline points={mkLine(recvData)} fill="none" stroke="#a78bfa" strokeWidth="1.5" strokeLinejoin="round" />
{tooltip && sentData.length > 0 && (
<>
<circle cx={sx(tooltip.idx, sentData.length).toFixed(1)} cy={sy(tooltip.sent).toFixed(1)} r="3" fill="#38bdf8" />
<circle cx={sx(tooltip.idx, recvData.length).toFixed(1)} cy={sy(tooltip.recv).toFixed(1)} r="3" fill="#a78bfa" />
</>
)}
</svg>
{tooltip && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white pointer-events-none flex gap-3" style={{ whiteSpace: 'nowrap' }}>
<span className="text-sky-400"> {fmtBps((tooltip.sent ?? 0) * 1024)}</span>
<span className="text-violet-400"> {fmtBps((tooltip.recv ?? 0) * 1024)}</span>
</div>
)}
</div>
)
}
// ─── Carte de stat ────────────────────────────────────────────────────────────
function StatCard({ title, icon, current, average, unit, children }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wide">
{icon}
{title}
</div>
{average !== undefined && (
<span className="text-xs text-gray-600">moy. {average}{unit}</span>
)}
</div>
<div className="text-2xl font-bold tabular-nums">
{current}<span className="text-sm font-normal text-gray-500 ml-1">{unit}</span>
</div>
{children}
</div>
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
export default function StatsModal({ vpsId, vpsName, onClose }) {
const [stats, setStats] = useState([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
try {
const data = await fetchVpsStats(vpsId)
setStats(data)
} catch { /* silencieux */ }
setLoading(false)
}, [vpsId])
useEffect(() => {
load()
const id = setInterval(load, 5000)
return () => clearInterval(id)
}, [load])
const last = stats[stats.length - 1]
// Séries CPU / RAM
const cpuData = stats.map(s => s.cpu)
const ramData = stats.map(s => s.ram_percent)
// Réseau : KB/s pour l'affichage du graphique
const sentKB = stats.map(s => s.net_sent_per_sec / 1024)
const recvKB = stats.map(s => s.net_recv_per_sec / 1024)
// Trafic cumulé (delta first → last, bytes depuis démarrage du collecteur)
const sessionSent = stats.length > 1
? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent)
: 0
const sessionRecv = stats.length > 1
? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv)
: 0
const points = stats.length
const windowMin = points > 0 ? `${(points * 5 / 60).toFixed(0)} min` : '—'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-gray-950 border border-gray-800 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl">
{/* En-tête */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800 sticky top-0 bg-gray-950 z-10">
<div className="flex items-center gap-3">
<BarChart2 size={18} className="text-indigo-400" />
<div>
<h2 className="font-semibold text-sm">{vpsName}</h2>
<p className="text-xs text-gray-500">
{points > 0
? `${points} points sur ~${windowMin} · collecte toutes les 5 s`
: 'En attente de données…'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Contenu */}
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-600 text-sm">
Chargement
</div>
) : stats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-600 text-sm">
<BarChart2 size={32} className="text-gray-700" />
Aucune donnée disponible le collecteur démarre dans quelques secondes.
</div>
) : (
<div className="p-5 flex flex-col gap-4">
{/* CPU + RAM */}
<div className="grid grid-cols-2 gap-4">
<StatCard
title="CPU"
icon={<Cpu size={12} />}
current={last ? last.cpu.toFixed(1) : '—'}
average={avg(cpuData)}
unit="%"
>
<Sparkline data={cpuData} min={0} max={100} color="#818cf8" fill="#818cf8" height={56} />
</StatCard>
<StatCard
title="RAM"
icon={<MemoryStick size={12} />}
current={last ? last.ram_percent.toFixed(1) : '—'}
average={avg(ramData)}
unit="%"
>
<Sparkline data={ramData} min={0} max={100} color="#34d399" fill="#34d399" height={56} />
{last && (
<p className="text-xs text-gray-600 -mt-1">
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}
</p>
)}
</StatCard>
</div>
{/* Bande passante */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wide">
<TrendingUp size={12} />
Bande passante
</div>
{last && (
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1 text-sky-400">
<ArrowUp size={10} /> {fmtBps(last.net_sent_per_sec)}
</span>
<span className="flex items-center gap-1 text-violet-400">
<ArrowDown size={10} /> {fmtBps(last.net_recv_per_sec)}
</span>
</div>
)}
</div>
<DualSparkline sentData={sentKB} recvData={recvKB} height={64} />
<div className="flex gap-4 text-xs text-gray-600">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-sky-400 inline-block" />
Upload
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-violet-400 inline-block" />
Download
</span>
</div>
</div>
{/* Trafic total session */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 mb-4">
Trafic total (depuis le début de la collecte)
</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-sky-500/10 flex-shrink-0">
<ArrowUp size={16} className="text-sky-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{fmtBytes(sessionSent)}</p>
<p className="text-xs text-gray-600">Envoyés</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-violet-500/10 flex-shrink-0">
<ArrowDown size={16} className="text-violet-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{fmtBytes(sessionRecv)}</p>
<p className="text-xs text-gray-600">Reçus</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil } from 'lucide-react' import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import ContainerRow from './ContainerRow' import ContainerRow from './ContainerRow'
import { tagColor } from './TagInput' import { tagColor } from './TagInput'
@@ -14,7 +14,7 @@ function formatRam(bytes) {
return `${(bytes / 1024 ** 3).toFixed(1)} GB` return `${(bytes / 1024 ** 3).toFixed(1)} GB`
} }
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit }) { export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats }) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [updatingProject, setUpdatingProject] = useState(null) const [updatingProject, setUpdatingProject] = useState(null)
@@ -62,6 +62,16 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />} {collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button> </button>
{vps.online && (
<button
onClick={() => onStats(vps.id, vps.name)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-indigo-400 transition-colors"
title="Graphiques de performance"
>
<BarChart2 size={14} />
</button>
)}
<button <button
onClick={() => onEdit(vps)} onClick={() => onEdit(vps)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors" className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"