Feat : Graph data expand
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
5812
|
7942
|
||||||
5870
|
8002
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -134,6 +135,26 @@ def init_db() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # colonne déjà présente
|
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()
|
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:
|
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:
|
try:
|
||||||
containers_res, system_res = await asyncio.gather(
|
containers_res = await agent_get(vps, "/containers")
|
||||||
agent_get(vps, "/containers"),
|
|
||||||
agent_get(vps, "/system"),
|
# Source unique : ring buffer → carte et modal affichent exactement la même valeur
|
||||||
return_exceptions=True,
|
history = _stats_history.get(vps["id"])
|
||||||
)
|
if history:
|
||||||
if isinstance(containers_res, Exception):
|
last = history[-1]
|
||||||
raise containers_res
|
system = {
|
||||||
system = system_res if not isinstance(system_res, Exception) else None
|
"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 {
|
return {
|
||||||
"id": vps["id"],
|
"id": vps["id"],
|
||||||
"name": vps["name"],
|
"name": vps["name"],
|
||||||
@@ -287,12 +326,15 @@ async def fetch_vps_status(vps: dict) -> dict:
|
|||||||
# ─── Collecteur de stats (tâche de fond) ─────────────────────────────────────
|
# ─── Collecteur de stats (tâche de fond) ─────────────────────────────────────
|
||||||
|
|
||||||
async def _fetch_system_stat(vps: dict) -> None:
|
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:
|
try:
|
||||||
data = await agent_get(vps, "/system")
|
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 = _stats_history.setdefault(vps["id"], deque(maxlen=_STATS_MAX_POINTS))
|
||||||
buf.append({
|
buf.append({
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
"ts": datetime.fromtimestamp(now, tz=timezone.utc).isoformat(),
|
||||||
"cpu": data["cpu_percent"],
|
"cpu": data["cpu_percent"],
|
||||||
"ram_percent": data["ram_percent"],
|
"ram_percent": data["ram_percent"],
|
||||||
"ram_used": data["ram_used"],
|
"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_sent": data.get("net_bytes_sent", 0),
|
||||||
"net_bytes_recv": data.get("net_bytes_recv", 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:
|
except Exception:
|
||||||
pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement
|
pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement
|
||||||
|
|
||||||
@@ -321,6 +378,16 @@ async def _stats_collector() -> None:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event() -> None:
|
async def startup_event() -> None:
|
||||||
asyncio.create_task(_stats_collector())
|
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 ──────────────────────────────────────────────────────────────
|
# ─── 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")
|
@app.get("/api/vps/{vps_id}/stats")
|
||||||
def get_vps_stats(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
|
def get_vps_stats(
|
||||||
"""Retourne l'historique des stats système d'un VPS (ring buffer en mémoire)."""
|
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()):
|
if not any(v["id"] == vps_id for v in load_vps()):
|
||||||
raise HTTPException(status_code=404, detail="VPS introuvable")
|
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")
|
@app.get("/api/status")
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export async function updateVps(vpsId, data) {
|
|||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVpsStats(vpsId) {
|
export async function fetchVpsStats(vpsId, duration = 600) {
|
||||||
const res = await fetch(`${BASE}/vps/${vpsId}/stats`, { headers: authHeaders() })
|
const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() })
|
||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ function avg(arr) {
|
|||||||
return (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1)
|
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 ────────────────────────────────────────────────────────────
|
// ─── SVG Sparkline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
|
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 }) {
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Labels min/max axe Y */}
|
||||||
|
<div className="absolute inset-y-0 right-0 flex flex-col justify-between text-right pointer-events-none" style={{ width: 28 }}>
|
||||||
|
<span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{max}%</span>
|
||||||
|
<span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{min}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tooltip bubble */}
|
{/* Tooltip bubble */}
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<div
|
<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"
|
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' }}
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
>
|
>
|
||||||
{typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}
|
{typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,25 +237,43 @@ function StatCard({ title, icon, current, average, unit, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Modal principal ──────────────────────────────────────────────────────────
|
// ─── Options de durée ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DURATION_OPTIONS = [
|
||||||
|
{ label: '10 min', value: 600 },
|
||||||
|
{ label: '1 h', value: 3_600 },
|
||||||
|
{ label: '6 h', value: 21_600 },
|
||||||
|
{ label: '24 h', value: 86_400 },
|
||||||
|
{ label: '7 j', value: 604_800 },
|
||||||
|
{ label: '30 j', value: 2_592_000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Modal principal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function StatsModal({ vpsId, vpsName, onClose }) {
|
export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||||
const [stats, setStats] = useState([])
|
const [stats, setStats] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [duration, setDuration] = useState(600)
|
||||||
|
|
||||||
|
// Refresh adaptatif : 5 s pour la vue courte, 30 s pour les vues historiques
|
||||||
|
const refreshMs = duration <= 600 ? 5_000 : 30_000
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchVpsStats(vpsId)
|
const data = await fetchVpsStats(vpsId, duration)
|
||||||
setStats(data)
|
setStats(data)
|
||||||
} catch { /* silencieux */ }
|
} catch { /* silencieux */ }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [vpsId])
|
}, [vpsId, duration])
|
||||||
|
|
||||||
|
// Réinit + recharge quand la durée change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setStats([])
|
||||||
load()
|
load()
|
||||||
const id = setInterval(load, 5000)
|
const id = setInterval(load, refreshMs)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [load])
|
}, [load, refreshMs])
|
||||||
|
|
||||||
const last = stats[stats.length - 1]
|
const last = stats[stats.length - 1]
|
||||||
|
|
||||||
@@ -241,11 +281,15 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
|||||||
const cpuData = stats.map(s => s.cpu)
|
const cpuData = stats.map(s => s.cpu)
|
||||||
const ramData = stats.map(s => s.ram_percent)
|
const ramData = stats.map(s => s.ram_percent)
|
||||||
|
|
||||||
|
// Axes auto-scalés pour voir les variations même à faible charge
|
||||||
|
const cpuRange = autoRange(cpuData, 0, 100)
|
||||||
|
const ramRange = autoRange(ramData, 0, 100)
|
||||||
|
|
||||||
// Réseau : KB/s pour l'affichage du graphique
|
// Réseau : KB/s pour l'affichage du graphique
|
||||||
const sentKB = stats.map(s => s.net_sent_per_sec / 1024)
|
const sentKB = stats.map(s => s.net_sent_per_sec / 1024)
|
||||||
const recvKB = stats.map(s => s.net_recv_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)
|
// Trafic cumulé (delta first → last)
|
||||||
const sessionSent = stats.length > 1
|
const sessionSent = stats.length > 1
|
||||||
? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent)
|
? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent)
|
||||||
: 0
|
: 0
|
||||||
@@ -253,22 +297,22 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
|||||||
? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv)
|
? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const points = stats.length
|
const durationLabel = DURATION_OPTIONS.find(o => o.value === duration)?.label ?? ''
|
||||||
const windowMin = points > 0 ? `${(points * 5 / 60).toFixed(0)} min` : '—'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<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">
|
<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 */}
|
{/* 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="px-5 py-3 border-b border-gray-800 sticky top-0 bg-gray-950 z-10">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<BarChart2 size={18} className="text-indigo-400" />
|
<BarChart2 size={18} className="text-indigo-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-sm">{vpsName}</h2>
|
<h2 className="font-semibold text-sm">{vpsName}</h2>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{points > 0
|
{stats.length > 0
|
||||||
? `${points} points sur ~${windowMin} · collecte toutes les 5 s`
|
? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s`
|
||||||
: 'En attente de données…'}
|
: 'En attente de données…'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +325,24 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sélecteur de durée */}
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{DURATION_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setDuration(opt.value)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
duration === opt.value
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contenu */}
|
{/* Contenu */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-600 text-sm">
|
<div className="flex items-center justify-center py-16 text-gray-600 text-sm">
|
||||||
@@ -303,7 +365,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
|||||||
average={avg(cpuData)}
|
average={avg(cpuData)}
|
||||||
unit="%"
|
unit="%"
|
||||||
>
|
>
|
||||||
<Sparkline data={cpuData} min={0} max={100} color="#818cf8" fill="#818cf8" height={56} />
|
<Sparkline data={cpuData} min={cpuRange.min} max={cpuRange.max} color="#818cf8" fill="#818cf8" height={56} />
|
||||||
</StatCard>
|
</StatCard>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -313,7 +375,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
|||||||
average={avg(ramData)}
|
average={avg(ramData)}
|
||||||
unit="%"
|
unit="%"
|
||||||
>
|
>
|
||||||
<Sparkline data={ramData} min={0} max={100} color="#34d399" fill="#34d399" height={56} />
|
<Sparkline data={ramData} min={ramRange.min} max={ramRange.max} color="#34d399" fill="#34d399" height={56} />
|
||||||
{last && (
|
{last && (
|
||||||
<p className="text-xs text-gray-600 -mt-1">
|
<p className="text-xs text-gray-600 -mt-1">
|
||||||
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}
|
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}
|
||||||
|
|||||||
Reference in New Issue
Block a user