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 ace01d1..a1830c6 100644 Binary files a/vps-monitor/backend/__pycache__/main.cpython-313.pyc and b/vps-monitor/backend/__pycache__/main.cpython-313.pyc differ 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 */} +
- {points > 0 - ? `${points} points sur ~${windowMin} · collecte toutes les 5 s` - : 'En attente de données…'} -
++ {stats.length > 0 + ? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s` + : 'En attente de données…'} +
+{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}