Feat : Lot of stuff
All checks were successful
Build and Push Docker Images / docker (push) Successful in 51s

This commit is contained in:
jeanotx32
2026-06-02 18:55:11 -04:00
parent daf68d98fa
commit f2e5a24b37
9 changed files with 655 additions and 151 deletions

View File

@@ -1,2 +1,2 @@
8423 80001
8473 80063

View File

@@ -6,6 +6,7 @@ Expose une API REST utilisée par le backend central pour interroger les contene
import os import os
import subprocess import subprocess
import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -18,6 +19,11 @@ from fastapi.security import APIKeyHeader
# ─── Config ─────────────────────────────────────────────────────────────────── # ─── Config ───────────────────────────────────────────────────────────────────
AGENT_VERSION = "1.1.0"
REPO_BASE = os.getenv("AGENT_REPO_BASE", "https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main")
INSTALL_DIR = os.getenv("AGENT_INSTALL_DIR", "/opt/vps-monitor-agent")
API_KEY = os.getenv("AGENT_API_KEY", "changeme-please") API_KEY = os.getenv("AGENT_API_KEY", "changeme-please")
AGENT_PORT = int(os.getenv("AGENT_PORT", "8001")) AGENT_PORT = int(os.getenv("AGENT_PORT", "8001"))
@@ -55,6 +61,45 @@ def health():
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
@app.get("/version")
def get_version():
"""Retourne la version de l'agent — sans authentification."""
return {"version": AGENT_VERSION}
@app.post("/self-update")
def self_update(_: None = Depends(require_api_key)):
"""Télécharge la dernière version de l'agent depuis le dépôt et redémarre le service."""
def _do_update():
time.sleep(0.5) # laisse la réponse HTTP partir
try:
for filename in ("agent.py", "requirements.txt"):
src = f"{REPO_BASE}/vps-monitor/agent/{filename}"
dst = f"{INSTALL_DIR}/{filename}"
subprocess.run(
["curl", "-fsSL", src, "-o", dst],
timeout=60,
check=True,
)
subprocess.run(
[f"{INSTALL_DIR}/venv/bin/pip", "install", "-r",
f"{INSTALL_DIR}/requirements.txt", "-q"],
timeout=120,
check=True,
)
subprocess.run(
["systemctl", "restart", "vps-monitor-agent"],
timeout=30,
check=True,
)
except Exception:
pass
threading.Thread(target=_do_update, daemon=True).start()
return {"status": "update_started"}
@app.get("/containers") @app.get("/containers")
def list_containers(_: None = Depends(require_api_key)): def list_containers(_: None = Depends(require_api_key)):
"""Retourne tous les conteneurs (actifs et arrêtés).""" """Retourne tous les conteneurs (actifs et arrêtés)."""

View File

@@ -27,6 +27,7 @@ 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"))
EXPECTED_AGENT_VERSION = os.getenv("EXPECTED_AGENT_VERSION", "1.1.0")
# ─── Ring buffer de stats (en mémoire) ─────────────────────────────────────── # ─── Ring buffer de stats (en mémoire) ───────────────────────────────────────
_STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle _STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle
@@ -102,6 +103,13 @@ class SettingUpdateRequest(BaseModel):
value: str value: str
class PurgeRequest(BaseModel):
table: str # 'vps_stats' | 'login_logs' | 'all'
period: str # 'last_24h' | 'last_7d' | 'last_30d' | 'all' | 'custom'
from_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
to_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
# ─── SQLite ─────────────────────────────────────────────────────────────────── # ─── SQLite ───────────────────────────────────────────────────────────────────
@contextmanager @contextmanager
@@ -360,6 +368,13 @@ async def fetch_vps_status(vps: dict) -> dict:
except Exception: except Exception:
system = None system = None
# Version de l'agent (endpoint sans auth)
try:
version_res = await agent_get(vps, "/version")
agent_version = version_res.get("version", "unknown")
except Exception:
agent_version = "unknown"
return { return {
"id": vps["id"], "id": vps["id"],
"name": vps["name"], "name": vps["name"],
@@ -369,6 +384,9 @@ async def fetch_vps_status(vps: dict) -> dict:
"containers": containers_res, "containers": containers_res,
"system": system, "system": system,
"tags": vps.get("tags", []), "tags": vps.get("tags", []),
"agent_version": agent_version,
"expected_agent_version": EXPECTED_AGENT_VERSION,
"agent_up_to_date": agent_version == EXPECTED_AGENT_VERSION,
} }
except Exception as e: except Exception as e:
return { return {
@@ -381,6 +399,9 @@ async def fetch_vps_status(vps: dict) -> dict:
"containers": [], "containers": [],
"system": None, "system": None,
"tags": vps.get("tags", []), "tags": vps.get("tags", []),
"agent_version": None,
"expected_agent_version": EXPECTED_AGENT_VERSION,
"agent_up_to_date": False,
} }
@@ -591,6 +612,74 @@ def admin_list_users(_: Annotated[dict, Depends(require_admin)]):
] ]
@app.get("/api/admin/db/info")
def admin_db_info(_: Annotated[dict, Depends(require_admin)]):
"""Retourne des informations sur le contenu de la base de données."""
with get_db() as conn:
def _table_info(table: str) -> dict:
row = conn.execute(
f"SELECT COUNT(*) AS cnt, MIN(ts) AS oldest, MAX(ts) AS newest FROM {table}"
).fetchone()
return {
"count": row["cnt"],
"oldest_ts": row["oldest"],
"newest_ts": row["newest"],
}
return {
"vps_stats": _table_info("vps_stats"),
"login_logs": _table_info("login_logs"),
}
_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs"})
_ALLOWED_PERIODS = frozenset({"last_24h", "last_7d", "last_30d", "all", "custom"})
@app.delete("/api/admin/db/purge")
def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin)]):
"""Supprime des entrées de la base de données selon la table et la période choisies."""
if body.table not in _ALLOWED_TABLES and body.table != "all":
raise HTTPException(status_code=400, detail="Table inconnue")
if body.period not in _ALLOWED_PERIODS:
raise HTTPException(status_code=400, detail="Période inconnue")
tables = list(_ALLOWED_TABLES) if body.table == "all" else [body.table]
now = int(time.time())
period_cutoff = {
"last_24h": now - 24 * 3600,
"last_7d": now - 7 * 24 * 3600,
"last_30d": now - 30 * 24 * 3600,
}
deleted: dict[str, int] = {}
with get_db() as conn:
for tbl in tables:
if body.period == "all":
cur = conn.execute(f"DELETE FROM {tbl}") # nosec tbl validated above
elif body.period == "custom":
if body.from_ts is None or body.to_ts is None:
raise HTTPException(
status_code=400,
detail="Période personnalisée : from_ts et to_ts sont requis",
)
if body.from_ts > body.to_ts:
raise HTTPException(status_code=400, detail="from_ts doit être ≤ to_ts")
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ts >= ? AND ts <= ?", # nosec
(body.from_ts, body.to_ts),
)
else:
cutoff = period_cutoff[body.period]
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ts >= ?", # nosec
(cutoff,),
)
deleted[tbl] = cur.rowcount
return {"deleted": deleted, "status": "ok"}
# ─── Routes VPS ─────────────────────────────────────────────────────────────── # ─── Routes VPS ───────────────────────────────────────────────────────────────
@app.get("/api/vps") @app.get("/api/vps")
@@ -756,3 +845,18 @@ async def compose_update(
return await r.json() return await r.json()
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=str(e)) raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/vps/{vps_id}/agent/update")
async def agent_self_update(
vps_id: str,
_: Annotated[dict, Depends(get_current_user)] = None,
):
"""Déclenche la mise à jour de l'agent sur le VPS."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_post(vps, "/self-update")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))

View File

@@ -1,6 +1,6 @@
services: services:
backend: backend:
build: ./backend image: git.jeanbonapp.com/jeanbon/scriptvps/backend:latest
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
@@ -9,9 +9,10 @@ services:
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: ./frontend image: git.jeanbonapp.com/jeanbon/scriptvps/frontend:latest
ports: ports:
- "3000:80" - "3000:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps } from './api/client' import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent } from './api/client'
import Header from './components/Header' import Header from './components/Header'
import VpsCard from './components/VpsCard' import VpsCard from './components/VpsCard'
import LogsModal from './components/LogsModal' import LogsModal from './components/LogsModal'
@@ -165,6 +165,21 @@ export default function App() {
} }
} }
const handleUpdateAgent = async (vpsId) => {
setUpdateModal({ vpsId, project: 'agent' })
setUpdateLoading(true)
setUpdateContent('Lancement de la mise à jour de l\'agent…\n')
try {
await updateAgent(vpsId)
setUpdateContent('Mise à jour lancée. L\'agent va redémarrer dans quelques secondes.\nActualisez dans un moment pour vérifier la nouvelle version.')
} catch (e) {
setUpdateContent(`Erreur lors de la mise à jour de l'agent :\n${e.message}`)
} finally {
setUpdateLoading(false)
setTimeout(() => refresh(), 8000)
}
}
const handleAddVps = async (formData) => { const handleAddVps = async (formData) => {
await addVps(formData) await addVps(formData)
setShowAddVps(false) setShowAddVps(false)
@@ -289,6 +304,7 @@ export default function App() {
onUpdate={handleUpdate} onUpdate={handleUpdate}
onEdit={setEditVps} onEdit={setEditVps}
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
onUpdateAgent={handleUpdateAgent}
/> />
))} ))}
</div> </div>

View File

@@ -100,6 +100,14 @@ export async function composeUpdate(vpsId, project) {
return handleResponse(res) return handleResponse(res)
} }
export async function updateAgent(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}/agent/update`, {
method: 'POST',
headers: authHeaders(),
})
return handleResponse(res)
}
export async function updateVps(vpsId, data) { export async function updateVps(vpsId, data) {
const res = await fetch(`${BASE}/vps/${vpsId}`, { const res = await fetch(`${BASE}/vps/${vpsId}`, {
method: 'PUT', method: 'PUT',
@@ -152,3 +160,22 @@ export async function getAdminUsers() {
const res = await fetch(`${BASE}/admin/users`, { headers: authHeaders() }) const res = await fetch(`${BASE}/admin/users`, { headers: authHeaders() })
return handleResponse(res) return handleResponse(res)
} }
export async function getDbInfo() {
const res = await fetch(`${BASE}/admin/db/info`, { headers: authHeaders() })
return handleResponse(res)
}
export async function purgeDb({ table, period, fromTs, toTs }) {
const body = { table, period }
if (period === 'custom') {
body.from_ts = fromTs
body.to_ts = toTs
}
const res = await fetch(`${BASE}/admin/db/purge`, {
method: 'DELETE',
headers: authHeaders(),
body: JSON.stringify(body),
})
return handleResponse(res)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X } from 'lucide-react' import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs } from '../api/client' import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@@ -27,6 +27,8 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
} }
export default function AdminPage({ onBack }) { export default function AdminPage({ onBack }) {
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database'
// ─── Settings ──────────────────────────────────────────────────────────── // ─── Settings ────────────────────────────────────────────────────────────
const [settings, setSettings] = useState(null) const [settings, setSettings] = useState(null)
const [settingsLoading, setSettingsLoading] = useState(true) const [settingsLoading, setSettingsLoading] = useState(true)
@@ -96,6 +98,82 @@ export default function AdminPage({ onBack }) {
const totalPages = Math.ceil(logsTotal / PAGE_SIZE) const totalPages = Math.ceil(logsTotal / PAGE_SIZE)
// ─── Database management ─────────────────────────────────────────────────
const [dbInfo, setDbInfo] = useState(null)
const [dbInfoLoading, setDbInfoLoading] = useState(false)
const [dbInfoError, setDbInfoError] = useState(null)
const [purgeLoading, setPurgeLoading] = useState(false)
const [purgeResult, setPurgeResult] = useState(null)
const [purgeError, setPurgeError] = useState(null)
const [confirmState, setConfirmState] = useState(null) // { table, period, fromTs?, toTs? }
// Custom range
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const loadDbInfo = useCallback(async () => {
setDbInfoLoading(true)
setDbInfoError(null)
try {
const data = await getDbInfo()
setDbInfo(data)
} catch (err) {
setDbInfoError(err.message)
} finally {
setDbInfoLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'database') loadDbInfo()
}, [activeTab, loadDbInfo])
const requestPurge = (table, period, extraOpts = {}) => {
setPurgeResult(null)
setPurgeError(null)
setConfirmState({ table, period, ...extraOpts })
}
const confirmPurge = async () => {
if (!confirmState) return
setPurgeLoading(true)
setPurgeResult(null)
setPurgeError(null)
try {
const result = await purgeDb(confirmState)
const total = Object.values(result.deleted).reduce((a, b) => a + b, 0)
setPurgeResult(`${total} entrée${total !== 1 ? 's' : ''} supprimée${total !== 1 ? 's' : ''}.`)
loadDbInfo()
} catch (err) {
setPurgeError(err.message)
} finally {
setPurgeLoading(false)
setConfirmState(null)
}
}
const periodLabel = {
last_24h: '24 dernières heures',
last_7d: '7 derniers jours',
last_30d: '30 derniers jours',
all: 'toutes les entrées',
custom: 'la période personnalisée',
}
const tableLabel = {
vps_stats: 'Statistiques VPS',
login_logs: 'Logs de connexion',
all: 'toutes les tables',
}
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('fr-FR')
}
function fmtCount(n) {
return new Intl.NumberFormat('fr-FR').format(n)
}
return ( return (
<div className="min-h-screen bg-gray-950 text-gray-100"> <div className="min-h-screen bg-gray-950 text-gray-100">
<div className="max-w-5xl mx-auto px-4 py-10"> <div className="max-w-5xl mx-auto px-4 py-10">
@@ -107,15 +185,37 @@ export default function AdminPage({ onBack }) {
Retour au tableau de bord Retour au tableau de bord
</button> </button>
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-xl bg-violet-500/15"> <div className="p-2 rounded-xl bg-violet-500/15">
<ShieldCheck size={20} className="text-violet-400" /> <ShieldCheck size={20} className="text-violet-400" />
</div> </div>
<h1 className="text-lg font-semibold">Administration</h1> <h1 className="text-lg font-semibold">Administration</h1>
</div> </div>
{/* ── Section Paramètres ── */} {/* ── Tabs ── */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6 mb-8"> <div className="flex gap-1 mb-8 border-b border-gray-800">
{[
{ key: 'settings', label: 'Paramètres' },
{ key: 'logs', label: 'Connexions' },
{ key: 'database', label: 'Base de données' },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── Tab: Paramètres ── */}
{activeTab === 'settings' && (
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-sm font-semibold text-gray-300 mb-1">Paramètres</h2> <h2 className="text-sm font-semibold text-gray-300 mb-1">Paramètres</h2>
<p className="text-xs text-gray-500 mb-4">Configuration globale de l'application.</p> <p className="text-xs text-gray-500 mb-4">Configuration globale de l'application.</p>
@@ -140,8 +240,10 @@ export default function AdminPage({ onBack }) {
) )
} }
</section> </section>
)}
{/* ── Section Logs de connexion ── */} {/* ── Tab: Connexions ── */}
{activeTab === 'logs' && (
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6"> <section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap"> <div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
<div> <div>
@@ -149,7 +251,6 @@ export default function AdminPage({ onBack }) {
<p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p> <p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* Filtre utilisateur */}
<input <input
type="text" type="text"
placeholder="Filtrer par utilisateur…" placeholder="Filtrer par utilisateur…"
@@ -157,7 +258,6 @@ export default function AdminPage({ onBack }) {
onChange={(e) => setFilterUser(e.target.value)} onChange={(e) => setFilterUser(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors w-44" className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors w-44"
/> />
{/* Filtre succès */}
<select <select
value={filterSuccess} value={filterSuccess}
onChange={(e) => setFilterSuccess(e.target.value)} onChange={(e) => setFilterSuccess(e.target.value)}
@@ -230,7 +330,6 @@ export default function AdminPage({ onBack }) {
) )
} }
{/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
<button <button
@@ -253,6 +352,178 @@ export default function AdminPage({ onBack }) {
</div> </div>
)} )}
</section> </section>
)}
{/* ── Tab: Base de données ── */}
{activeTab === 'database' && (
<div className="space-y-6">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-gray-300">Gestion de la base de données</h2>
<p className="text-xs text-gray-500 mt-0.5">Supprimez les données historiques par table et par période.</p>
</div>
<button
onClick={loadDbInfo}
disabled={dbInfoLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<RefreshCw size={12} className={dbInfoLoading ? 'animate-spin' : ''} />
Actualiser
</button>
</div>
{dbInfoError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{dbInfoError}
</div>
)}
{purgeResult && (
<div className="bg-emerald-950/40 border border-emerald-800/50 rounded-lg px-3 py-2 text-xs text-emerald-300">
{purgeResult}
</div>
)}
{purgeError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{purgeError}
</div>
)}
{/* Cards par table */}
{[
{ key: 'vps_stats', label: 'Statistiques VPS', icon: <Database size={15} className="text-indigo-400" /> },
{ key: 'login_logs', label: 'Logs de connexion', icon: <Database size={15} className="text-violet-400" /> },
].map(({ key, label, icon }) => {
const info = dbInfo?.[key]
return (
<section key={key} className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
{icon}
<h3 className="text-sm font-semibold text-gray-300">{label}</h3>
{info && (
<span className="ml-auto text-xs text-gray-500">
{fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''}
{info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{[
{ period: 'last_24h', label: '24 dernières heures' },
{ period: 'last_7d', label: '7 derniers jours' },
{ period: 'last_30d', label: '30 derniers jours' },
{ period: 'all', label: 'Tout effacer', danger: true },
].map(({ period, label: btnLabel, danger }) => (
<button
key={period}
onClick={() => requestPurge(key, period)}
disabled={purgeLoading || dbInfoLoading}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50 ${
danger
? 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50'
: 'bg-gray-800 hover:bg-gray-700 text-gray-300'
}`}
>
<Trash2 size={11} />
{btnLabel}
</button>
))}
</div>
</section>
)
})}
{/* Période personnalisée */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-gray-300 mb-1">Période personnalisée</h3>
<p className="text-xs text-gray-500 mb-4">Supprimez les données comprises entre deux dates précises.</p>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Du</label>
<input
type="datetime-local"
value={customFrom}
onChange={e => setCustomFrom(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Au</label>
<input
type="datetime-local"
value={customTo}
onChange={e => setCustomTo(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Table</label>
<select
id="custom-table"
defaultValue="all"
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors"
>
<option value="all">Toutes</option>
<option value="vps_stats">Statistiques VPS</option>
<option value="login_logs">Logs de connexion</option>
</select>
</div>
<button
disabled={!customFrom || !customTo || purgeLoading}
onClick={() => {
const tbl = document.getElementById('custom-table').value
const fromTs = Math.floor(new Date(customFrom).getTime() / 1000)
const toTs = Math.floor(new Date(customTo).getTime() / 1000)
requestPurge(tbl, 'custom', { fromTs, toTs })
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50 transition-colors disabled:opacity-50"
>
<Trash2 size={11} />
Supprimer
</button>
</div>
</section>
</div>
)}
{/* ── Modale de confirmation de purge ── */}
{confirmState && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-gray-900 border border-gray-700 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-xl bg-red-500/15">
<AlertTriangle size={18} className="text-red-400" />
</div>
<h3 className="text-sm font-semibold">Confirmer la suppression</h3>
</div>
<p className="text-xs text-gray-400 mb-6">
Vous êtes sur le point de supprimer{' '}
<span className="text-gray-200 font-medium">{periodLabel[confirmState.period]}</span>{' '}
dans{' '}
<span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
Cette action est irréversible.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setConfirmState(null)}
className="px-4 py-2 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 transition-colors"
>
Annuler
</button>
<button
onClick={confirmPurge}
disabled={purgeLoading}
className="px-4 py-2 rounded-lg text-xs bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors text-white font-medium"
>
{purgeLoading ? 'Suppression' : 'Supprimer'}
</button>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2 } from 'lucide-react' import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload } 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,9 +14,10 @@ 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, onStats }) { export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent }) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [updatingProject, setUpdatingProject] = useState(null) const [updatingProject, setUpdatingProject] = useState(null)
const [updatingAgent, setUpdatingAgent] = useState(false)
const running = vps.containers.filter(c => c.status === 'running').length const running = vps.containers.filter(c => c.status === 'running').length
const total = vps.containers.length const total = vps.containers.length
@@ -28,6 +29,11 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
try { await onUpdate(vps.id, project) } finally { setUpdatingProject(null) } try { await onUpdate(vps.id, project) } finally { setUpdatingProject(null) }
} }
const handleUpdateAgent = async () => {
setUpdatingAgent(true)
try { await onUpdateAgent(vps.id) } finally { setUpdatingAgent(false) }
}
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
@@ -171,6 +177,40 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
</div> </div>
)} )}
{/* Version de l'agent + bouton mise à jour */}
{!collapsed && vps.online && (
<div className="px-4 py-2 border-t border-gray-800/60 flex items-center gap-3">
<span className="text-xs text-gray-500">Agent&nbsp;:</span>
{vps.agent_version ? (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
vps.agent_up_to_date
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
: 'bg-orange-500/10 border-orange-500/30 text-orange-400'
}`}
title={vps.agent_up_to_date ? 'Agent à jour' : `Mise à jour disponible (attendu : ${vps.expected_agent_version})`}
>
{vps.agent_up_to_date ? '✓' : '⚠'} v{vps.agent_version}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border bg-gray-700/30 border-gray-600/30 text-gray-500">
inconnu
</span>
)}
{(!vps.agent_up_to_date || vps.agent_version === 'unknown') && (
<button
onClick={handleUpdateAgent}
disabled={updatingAgent}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-orange-500/10 border border-orange-500/30 text-orange-300 hover:bg-orange-500/30 hover:text-orange-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`Mettre à jour l'agent vers v${vps.expected_agent_version}`}
>
<CloudDownload size={11} className={updatingAgent ? 'animate-bounce' : ''} />
{updatingAgent ? 'Mise à jour...' : 'Mettre à jour l\'agent'}
</button>
)}
</div>
)}
{/* Footer stats */} {/* Footer stats */}
{!collapsed && vps.online && total > 0 && ( {!collapsed && vps.online && total > 0 && (
<div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600"> <div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600">