Feat : Graph data expand
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s

This commit is contained in:
jeanotx32
2026-05-19 01:16:59 -04:00
parent 3080826806
commit 43dd3c614d
5 changed files with 220 additions and 47 deletions

View File

@@ -1,2 +1,2 @@
5812
5870
7942
8002

View File

@@ -9,6 +9,7 @@ import json
import os
import secrets
import sqlite3
import time
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
@@ -134,6 +135,26 @@ def init_db() -> None:
except Exception:
pass # colonne déjà présente
conn.execute("""
CREATE TABLE IF NOT EXISTS vps_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vps_id TEXT NOT NULL,
ts INTEGER NOT NULL,
cpu REAL NOT NULL,
ram_percent REAL NOT NULL,
ram_used INTEGER NOT NULL,
ram_total INTEGER NOT NULL,
net_sent_per_sec REAL NOT NULL DEFAULT 0,
net_recv_per_sec REAL NOT NULL DEFAULT 0,
net_bytes_sent INTEGER NOT NULL DEFAULT 0,
net_bytes_recv INTEGER NOT NULL DEFAULT 0
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_vps_stats
ON vps_stats(vps_id, ts DESC)
""")
init_db()
@@ -250,16 +271,34 @@ 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."""
"""Interroge un agent et retourne son état complet.
Les données système (CPU, RAM, réseau) proviennent en priorité du ring buffer
alimenté par le collecteur toutes les 5 s — même source que le modal de stats.
Si le buffer est encore vide (premier démarrage), un appel direct est fait.
"""
try:
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
containers_res = await agent_get(vps, "/containers")
# Source unique : ring buffer → carte et modal affichent exactement la même valeur
history = _stats_history.get(vps["id"])
if history:
last = history[-1]
system = {
"cpu_percent": last["cpu"],
"ram_used": last["ram_used"],
"ram_total": last["ram_total"],
"ram_percent": last["ram_percent"],
"net_sent_per_sec": last["net_sent_per_sec"],
"net_recv_per_sec": last["net_recv_per_sec"],
}
else:
# Buffer vide (premier démarrage) — appel direct en fallback
try:
system = await agent_get(vps, "/system")
except Exception:
system = None
return {
"id": vps["id"],
"name": vps["name"],
@@ -287,12 +326,15 @@ 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."""
"""Collecte un point de stats système pour un VPS et le persiste (ring buffer + SQLite)."""
try:
data = await agent_get(vps, "/system")
now = int(time.time())
# Ring buffer en mémoire (source pour la carte VPS — valeur courante)
buf = _stats_history.setdefault(vps["id"], deque(maxlen=_STATS_MAX_POINTS))
buf.append({
"ts": datetime.now(timezone.utc).isoformat(),
"ts": datetime.fromtimestamp(now, tz=timezone.utc).isoformat(),
"cpu": data["cpu_percent"],
"ram_percent": data["ram_percent"],
"ram_used": data["ram_used"],
@@ -302,6 +344,21 @@ async def _fetch_system_stat(vps: dict) -> None:
"net_bytes_sent": data.get("net_bytes_sent", 0),
"net_bytes_recv": data.get("net_bytes_recv", 0),
})
# Persistance SQLite (historique long terme)
with get_db() as conn:
conn.execute("""
INSERT INTO vps_stats
(vps_id, ts, cpu, ram_percent, ram_used, ram_total,
net_sent_per_sec, net_recv_per_sec, net_bytes_sent, net_bytes_recv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
vps["id"], now,
data["cpu_percent"], data["ram_percent"],
data["ram_used"], data["ram_total"],
data["net_sent_per_sec"], data["net_recv_per_sec"],
data.get("net_bytes_sent", 0), data.get("net_bytes_recv", 0),
))
except Exception:
pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement
@@ -321,6 +378,16 @@ async def _stats_collector() -> None:
@app.on_event("startup")
async def startup_event() -> None:
asyncio.create_task(_stats_collector())
asyncio.create_task(_cleanup_old_stats())
async def _cleanup_old_stats() -> None:
"""Supprime les statistiques de plus de 31 jours (s'exécute toutes les heures)."""
while True:
await asyncio.sleep(3600)
cutoff = int(time.time()) - 31 * 24 * 3600
with get_db() as conn:
conn.execute("DELETE FROM vps_stats WHERE ts < ?", (cutoff,))
# ─── Routes Auth ──────────────────────────────────────────────────────────────
@@ -415,11 +482,55 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get
@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)."""
def get_vps_stats(
vps_id: str,
duration: int = 600, # secondes, défaut 10 min
_: Annotated[dict, Depends(get_current_user)] = None,
):
"""Retourne l'historique des stats système d'un VPS avec downsampling automatique.
Le paramètre `duration` (en secondes) détermine la fenêtre temporelle.
La réponse contient toujours ~300 points maximum (agrégation par bucket SQL).
"""
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()))
duration = max(60, min(duration, 31 * 24 * 3600)) # clamp 1 min 31 j
since = int(time.time()) - duration
bucket = max(5, duration // 300) # ≤ 300 points retournés
with get_db() as conn:
rows = conn.execute("""
SELECT
(ts / :b) * :b AS ts,
AVG(cpu) AS cpu,
AVG(ram_percent) AS ram_percent,
CAST(AVG(ram_used) AS INTEGER) AS ram_used,
MAX(ram_total) AS ram_total,
AVG(net_sent_per_sec) AS net_sent_per_sec,
AVG(net_recv_per_sec) AS net_recv_per_sec,
MAX(net_bytes_sent) AS net_bytes_sent,
MAX(net_bytes_recv) AS net_bytes_recv
FROM vps_stats
WHERE vps_id = :vps_id AND ts >= :since
GROUP BY (ts / :b)
ORDER BY ts ASC
""", {"b": bucket, "vps_id": vps_id, "since": since}).fetchall()
return [
{
"ts": datetime.fromtimestamp(int(row["ts"]), tz=timezone.utc).isoformat(),
"cpu": row["cpu"],
"ram_percent": row["ram_percent"],
"ram_used": row["ram_used"],
"ram_total": row["ram_total"],
"net_sent_per_sec": row["net_sent_per_sec"],
"net_recv_per_sec": row["net_recv_per_sec"],
"net_bytes_sent": row["net_bytes_sent"],
"net_bytes_recv": row["net_bytes_recv"],
}
for row in rows
]
@app.get("/api/status")

View File

@@ -109,7 +109,7 @@ export async function updateVps(vpsId, data) {
return handleResponse(res)
}
export async function fetchVpsStats(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}/stats`, { headers: authHeaders() })
export async function fetchVpsStats(vpsId, duration = 600) {
const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() })
return handleResponse(res)
}

View File

@@ -30,6 +30,22 @@ function avg(arr) {
return (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1)
}
/** Calcule un min/max adapté à la série pour que les variations soient visibles. */
function autoRange(data, absMin = 0, absMax = 100, minRange = 8) {
if (!data.length) return { min: absMin, max: absMax }
const lo = Math.min(...data)
const hi = Math.max(...data)
const margin = Math.max((hi - lo) * 0.2, 1)
let min = Math.max(absMin, lo - margin)
let max = Math.min(absMax, hi + margin)
if (max - min < minRange) {
const mid = (min + max) / 2
min = Math.max(absMin, mid - minRange / 2)
max = Math.min(absMax, min + minRange)
}
return { min: +min.toFixed(1), max: +max.toFixed(1) }
}
// ─── SVG Sparkline ────────────────────────────────────────────────────────────
function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
@@ -116,13 +132,19 @@ function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
)}
</svg>
{/* Labels min/max axe Y */}
<div className="absolute inset-y-0 right-0 flex flex-col justify-between text-right pointer-events-none" style={{ width: 28 }}>
<span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{max}%</span>
<span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{min}%</span>
</div>
{/* 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}
{typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}%
</div>
)}
</div>
@@ -215,25 +237,43 @@ function StatCard({ title, icon, current, average, unit, children }) {
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
// ─── Options de durée ─────────────────────────────────────────────────────────
const DURATION_OPTIONS = [
{ label: '10 min', value: 600 },
{ label: '1 h', value: 3_600 },
{ label: '6 h', value: 21_600 },
{ label: '24 h', value: 86_400 },
{ label: '7 j', value: 604_800 },
{ label: '30 j', value: 2_592_000 },
]
// ─── Modal principal ──────────────────────────────────────────────────────────────
export default function StatsModal({ vpsId, vpsName, onClose }) {
const [stats, setStats] = useState([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState([])
const [loading, setLoading] = useState(true)
const [duration, setDuration] = useState(600)
// Refresh adaptatif : 5 s pour la vue courte, 30 s pour les vues historiques
const refreshMs = duration <= 600 ? 5_000 : 30_000
const load = useCallback(async () => {
try {
const data = await fetchVpsStats(vpsId)
const data = await fetchVpsStats(vpsId, duration)
setStats(data)
} catch { /* silencieux */ }
setLoading(false)
}, [vpsId])
}, [vpsId, duration])
// Réinit + recharge quand la durée change
useEffect(() => {
setLoading(true)
setStats([])
load()
const id = setInterval(load, 5000)
const id = setInterval(load, refreshMs)
return () => clearInterval(id)
}, [load])
}, [load, refreshMs])
const last = stats[stats.length - 1]
@@ -241,11 +281,15 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
const cpuData = stats.map(s => s.cpu)
const ramData = stats.map(s => s.ram_percent)
// Axes auto-scalés pour voir les variations même à faible charge
const cpuRange = autoRange(cpuData, 0, 100)
const ramRange = autoRange(ramData, 0, 100)
// 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)
// Trafic cumulé (delta first → last)
const sessionSent = stats.length > 1
? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent)
: 0
@@ -253,32 +297,50 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
? 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` : '—'
const durationLabel = DURATION_OPTIONS.find(o => o.value === duration)?.label ?? ''
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 className="px-5 py-3 border-b border-gray-800 sticky top-0 bg-gray-950 z-10">
<div className="flex items-center justify-between mb-3">
<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">
{stats.length > 0
? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} 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>
{/* Sélecteur de durée */}
<div className="flex gap-1 flex-wrap">
{DURATION_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setDuration(opt.value)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
duration === opt.value
? 'bg-indigo-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</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 */}
@@ -303,7 +365,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
average={avg(cpuData)}
unit="%"
>
<Sparkline data={cpuData} min={0} max={100} color="#818cf8" fill="#818cf8" height={56} />
<Sparkline data={cpuData} min={cpuRange.min} max={cpuRange.max} color="#818cf8" fill="#818cf8" height={56} />
</StatCard>
<StatCard
@@ -313,7 +375,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
average={avg(ramData)}
unit="%"
>
<Sparkline data={ramData} min={0} max={100} color="#34d399" fill="#34d399" height={56} />
<Sparkline data={ramData} min={ramRange.min} max={ramRange.max} color="#34d399" fill="#34d399" height={56} />
{last && (
<p className="text-xs text-gray-600 -mt-1">
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}