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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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."""
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -305,6 +309,15 @@ export default function App() {
|
||||
onClose={() => setEditVps(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal statistiques */}
|
||||
{statsModal && (
|
||||
<StatsModal
|
||||
vpsId={statsModal.vpsId}
|
||||
vpsName={statsModal.vpsName}
|
||||
onClose={() => setStatsModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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 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 ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</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
|
||||
onClick={() => onEdit(vps)}
|
||||
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