diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index f916528..1662abd 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -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, } diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index 99e4c74..ace01d1 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 4fdba3c..fc33da9 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 +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.""" diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index e3763be..e22ee06 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -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 })} /> ))} @@ -305,6 +309,15 @@ export default function App() { onClose={() => setEditVps(null)} /> )} + + {/* Modal statistiques */} + {statsModal && ( + setStatsModal(null)} + /> + )} ) } diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index a56ef78..044a7a8 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -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) +} diff --git a/vps-monitor/frontend/src/components/StatsModal.jsx b/vps-monitor/frontend/src/components/StatsModal.jsx new file mode 100644 index 0000000..f3afdef --- /dev/null +++ b/vps-monitor/frontend/src/components/StatsModal.jsx @@ -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 ( +
+ En attente de données… +
+ ) + } + + 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 ( +
+ setTooltip(null)} + > + {/* Grid lines */} + {[25, 50, 75].map(pct => { + const y = sy(min + range * pct / 100) + return ( + + ) + })} + + {/* Area fill */} + + + {/* Line */} + + + {/* Hover dot */} + {tooltip && ( + + )} + + + {/* Tooltip bubble */} + {tooltip && ( +
+ {typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value} +
+ )} +
+ ) +} + +// 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 ( +
+ setTooltip(null)} + > + {[25, 50, 75].map(pct => ( + + ))} + + + + + {tooltip && sentData.length > 0 && ( + <> + + + + )} + + {tooltip && ( +
+ ↑ {fmtBps((tooltip.sent ?? 0) * 1024)} + ↓ {fmtBps((tooltip.recv ?? 0) * 1024)} +
+ )} +
+ ) +} + +// ─── Carte de stat ──────────────────────────────────────────────────────────── + +function StatCard({ title, icon, current, average, unit, children }) { + return ( +
+
+
+ {icon} + {title} +
+ {average !== undefined && ( + moy. {average}{unit} + )} +
+
+ {current}{unit} +
+ {children} +
+ ) +} + +// ─── 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 ( +
+
+ + {/* En-tête */} +
+
+ +
+

{vpsName}

+

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

+
+
+ +
+ + {/* Contenu */} + {loading ? ( +
+ Chargement… +
+ ) : stats.length === 0 ? ( +
+ + Aucune donnée disponible — le collecteur démarre dans quelques secondes. +
+ ) : ( +
+ + {/* CPU + RAM */} +
+ } + current={last ? last.cpu.toFixed(1) : '—'} + average={avg(cpuData)} + unit="%" + > + + + + } + current={last ? last.ram_percent.toFixed(1) : '—'} + average={avg(ramData)} + unit="%" + > + + {last && ( +

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

+ )} +
+
+ + {/* Bande passante */} +
+
+
+ + Bande passante +
+ {last && ( +
+ + {fmtBps(last.net_sent_per_sec)} + + + {fmtBps(last.net_recv_per_sec)} + +
+ )} +
+ +
+ + + Upload + + + + Download + +
+
+ + {/* Trafic total session */} +
+

+ Trafic total (depuis le début de la collecte) +

+
+
+
+ +
+
+

{fmtBytes(sessionSent)}

+

Envoyés

+
+
+
+
+ +
+
+

{fmtBytes(sessionRecv)}

+

Reçus

+
+
+
+
+ +
+ )} +
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index 72f0590..60d49c8 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -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 ? : } + {vps.online && ( + + )} +