From 43dd3c614d9c4c4ef214056a32aae35ac9c1450e Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 19 May 2026 01:16:59 -0400 Subject: [PATCH] Feat : Graph data expand --- vps-monitor/.pids | 4 +- .../backend/__pycache__/main.cpython-313.pyc | Bin 29230 -> 33944 bytes vps-monitor/backend/main.py | 139 ++++++++++++++++-- vps-monitor/frontend/src/api/client.js | 4 +- .../frontend/src/components/StatsModal.jsx | 120 +++++++++++---- 5 files changed, 220 insertions(+), 47 deletions(-) diff --git a/vps-monitor/.pids b/vps-monitor/.pids index cb8ea29..698ed21 100644 --- a/vps-monitor/.pids +++ b/vps-monitor/.pids @@ -1,2 +1,2 @@ -5812 -5870 +7942 +8002 diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index ace01d1729499f79c0ae20ad5ea994cc0f0343dc..a1830c657e2dc05fcbe6107b6636e43303cf9b38 100644 GIT binary patch delta 8918 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 UucIYKD_i5yW`=~mJ3mAI7nF-pHvj+t delta 4981 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 BV?F=? diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index fc33da9..673f09d 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -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") diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index 044a7a8..6916bce 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -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) } diff --git a/vps-monitor/frontend/src/components/StatsModal.jsx b/vps-monitor/frontend/src/components/StatsModal.jsx index f3afdef..7002fe7 100644 --- a/vps-monitor/frontend/src/components/StatsModal.jsx +++ b/vps-monitor/frontend/src/components/StatsModal.jsx @@ -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 }) { )} + {/* Labels min/max axe Y */} +
+ {max}% + {min}% +
+ {/* Tooltip bubble */} {tooltip && (
- {typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value} + {typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}%
)} @@ -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 (
{/* En-tête */} -
-
- -
-

{vpsName}

-

- {points > 0 - ? `${points} points sur ~${windowMin} · collecte toutes les 5 s` - : 'En attente de données…'} -

+
+
+
+ +
+

{vpsName}

+

+ {stats.length > 0 + ? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s` + : 'En attente de données…'} +

+
+ +
+ + {/* Sélecteur de durée */} +
+ {DURATION_OPTIONS.map(opt => ( + + ))}
-
{/* Contenu */} @@ -303,7 +365,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) { average={avg(cpuData)} unit="%" > - + - + {last && (

{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}