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

@@ -122,12 +122,14 @@ def system_info(_: None = Depends(require_api_key)):
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,
"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,
"net_bytes_sent": net2.bytes_sent,
"net_bytes_recv": net2.bytes_recv,
}

View File

@@ -9,6 +9,7 @@ import json
import os
import secrets
import sqlite3
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -25,6 +26,10 @@ from pydantic import BaseModel
# ─── Config ───────────────────────────────────────────────────────────────────
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"))
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
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 ──────────────────────────────────────────────────────────────
@app.get("/api/auth/status")
@@ -370,6 +414,14 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get
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")
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
"""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 AddVpsModal from './components/AddVpsModal'
import EditVpsModal from './components/EditVpsModal'
import StatsModal from './components/StatsModal'
import LoginPage from './components/LoginPage'
const INTERVAL_OPTIONS = [
@@ -46,6 +47,8 @@ export default function App() {
const [updateContent, setUpdateContent] = useState('')
const [updateLoading, setUpdateLoading] = useState(false)
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
// Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => {
authStatus()
@@ -263,6 +266,7 @@ export default function App() {
onDelete={handleDeleteVps}
onUpdate={handleUpdate}
onEdit={setEditVps}
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
/>
))}
</div>
@@ -305,6 +309,15 @@ export default function App() {
onClose={() => setEditVps(null)}
/>
)}
{/* Modal statistiques */}
{statsModal && (
<StatsModal
vpsId={statsModal.vpsId}
vpsName={statsModal.vpsName}
onClose={() => setStatsModal(null)}
/>
)}
</div>
)
}

View File

@@ -108,3 +108,8 @@ export async function updateVps(vpsId, data) {
})
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 ContainerRow from './ContainerRow'
import { tagColor } from './TagInput'
@@ -14,7 +14,7 @@ function formatRam(bytes) {
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 [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} />}
</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
onClick={() => onEdit(vps)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"