Feat : Better stats
Some checks failed
Build and Push Docker Images / docker (push) Failing after 7s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 7s
This commit is contained in:
@@ -122,12 +122,14 @@ def system_info(_: None = Depends(require_api_key)):
|
|||||||
net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2
|
net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cpu_percent": cpu_percent,
|
"cpu_percent": cpu_percent,
|
||||||
"ram_used": mem.used,
|
"ram_used": mem.used,
|
||||||
"ram_total": mem.total,
|
"ram_total": mem.total,
|
||||||
"ram_percent": mem.percent,
|
"ram_percent": mem.percent,
|
||||||
"net_sent_per_sec": net_sent_per_sec,
|
"net_sent_per_sec": net_sent_per_sec,
|
||||||
"net_recv_per_sec": net_recv_per_sec,
|
"net_recv_per_sec": net_recv_per_sec,
|
||||||
|
"net_bytes_sent": net2.bytes_sent,
|
||||||
|
"net_bytes_recv": net2.bytes_recv,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,6 +26,10 @@ from pydantic import BaseModel
|
|||||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
|
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"))
|
SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret"))
|
||||||
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
|
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
|
||||||
JWT_ALGORITHM = "HS256"
|
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 ──────────────────────────────────────────────────────────────
|
# ─── Routes Auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/auth/status")
|
@app.get("/api/auth/status")
|
||||||
@@ -370,6 +414,14 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get
|
|||||||
return {"status": "ok"}
|
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")
|
@app.get("/api/status")
|
||||||
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
|
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
|
||||||
"""Retourne l'état de tous les VPS en parallèle."""
|
"""Retourne l'état de tous les VPS en parallèle."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import VpsCard from './components/VpsCard'
|
|||||||
import LogsModal from './components/LogsModal'
|
import LogsModal from './components/LogsModal'
|
||||||
import AddVpsModal from './components/AddVpsModal'
|
import AddVpsModal from './components/AddVpsModal'
|
||||||
import EditVpsModal from './components/EditVpsModal'
|
import EditVpsModal from './components/EditVpsModal'
|
||||||
|
import StatsModal from './components/StatsModal'
|
||||||
import LoginPage from './components/LoginPage'
|
import LoginPage from './components/LoginPage'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -46,6 +47,8 @@ export default function App() {
|
|||||||
const [updateContent, setUpdateContent] = useState('')
|
const [updateContent, setUpdateContent] = useState('')
|
||||||
const [updateLoading, setUpdateLoading] = useState(false)
|
const [updateLoading, setUpdateLoading] = useState(false)
|
||||||
|
|
||||||
|
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
|
||||||
|
|
||||||
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authStatus()
|
authStatus()
|
||||||
@@ -263,6 +266,7 @@ export default function App() {
|
|||||||
onDelete={handleDeleteVps}
|
onDelete={handleDeleteVps}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onEdit={setEditVps}
|
onEdit={setEditVps}
|
||||||
|
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -305,6 +309,15 @@ export default function App() {
|
|||||||
onClose={() => setEditVps(null)}
|
onClose={() => setEditVps(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal statistiques */}
|
||||||
|
{statsModal && (
|
||||||
|
<StatsModal
|
||||||
|
vpsId={statsModal.vpsId}
|
||||||
|
vpsName={statsModal.vpsName}
|
||||||
|
onClose={() => setStatsModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,3 +108,8 @@ export async function updateVps(vpsId, data) {
|
|||||||
})
|
})
|
||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchVpsStats(vpsId) {
|
||||||
|
const res = await fetch(`${BASE}/vps/${vpsId}/stats`, { headers: authHeaders() })
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|||||||
388
vps-monitor/frontend/src/components/StatsModal.jsx
Normal file
388
vps-monitor/frontend/src/components/StatsModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { useState } from 'react'
|
||||||
import ContainerRow from './ContainerRow'
|
import ContainerRow from './ContainerRow'
|
||||||
import { tagColor } from './TagInput'
|
import { tagColor } from './TagInput'
|
||||||
@@ -14,7 +14,7 @@ function formatRam(bytes) {
|
|||||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
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 [collapsed, setCollapsed] = useState(false)
|
||||||
const [updatingProject, setUpdatingProject] = useState(null)
|
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} />}
|
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => onEdit(vps)}
|
onClick={() => onEdit(vps)}
|
||||||
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
|
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user