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:
@@ -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