Feat : Lot of stuff
All checks were successful
Build and Push Docker Images / docker (push) Successful in 51s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 51s
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
8423
|
80001
|
||||||
8473
|
80063
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
Binary file not shown.
@@ -26,7 +26,8 @@ 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,27 +368,40 @@ 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"],
|
||||||
"host": vps["host"],
|
"host": vps["host"],
|
||||||
"description": vps.get("description", ""),
|
"description": vps.get("description", ""),
|
||||||
"online": True,
|
"online": True,
|
||||||
"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 {
|
||||||
"id": vps["id"],
|
"id": vps["id"],
|
||||||
"name": vps["name"],
|
"name": vps["name"],
|
||||||
"host": vps["host"],
|
"host": vps["host"],
|
||||||
"description": vps.get("description", ""),
|
"description": vps.get("description", ""),
|
||||||
"online": False,
|
"online": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,152 +185,345 @@ 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">
|
||||||
<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>
|
{ 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>
|
||||||
|
|
||||||
{settingsError && (
|
{/* ── Tab: Paramètres ── */}
|
||||||
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
{activeTab === 'settings' && (
|
||||||
{settingsError}
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
{settingsLoading
|
{settingsError && (
|
||||||
? <p className="text-xs text-gray-500">Chargement…</p>
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
||||||
: (
|
{settingsError}
|
||||||
<div className="divide-y divide-gray-800">
|
|
||||||
<ToggleRow
|
|
||||||
label="Inscriptions ouvertes"
|
|
||||||
description="Permet à de nouveaux utilisateurs de créer un compte."
|
|
||||||
enabled={settings?.registration_open === 'true'}
|
|
||||||
onChange={toggleRegistration}
|
|
||||||
loading={toggleLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Section Logs de connexion ── */}
|
{settingsLoading
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
? <p className="text-xs text-gray-500">Chargement…</p>
|
||||||
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
|
: (
|
||||||
<div>
|
<div className="divide-y divide-gray-800">
|
||||||
<h2 className="text-sm font-semibold text-gray-300">Tentatives de connexion</h2>
|
<ToggleRow
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p>
|
label="Inscriptions ouvertes"
|
||||||
|
description="Permet à de nouveaux utilisateurs de créer un compte."
|
||||||
|
enabled={settings?.registration_open === 'true'}
|
||||||
|
onChange={toggleRegistration}
|
||||||
|
loading={toggleLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Connexions ── */}
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<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>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-300">Tentatives de connexion</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filtrer par utilisateur…"
|
||||||
|
value={filterUser}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterSuccess}
|
||||||
|
onChange={(e) => setFilterSuccess(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"
|
||||||
|
>
|
||||||
|
<option value="all">Tous</option>
|
||||||
|
<option value="true">Succès</option>
|
||||||
|
<option value="false">Échecs</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => loadLogs(logsPage)}
|
||||||
|
disabled={logsLoading}
|
||||||
|
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={logsLoading ? 'animate-spin' : ''} />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{/* Filtre utilisateur */}
|
{logsError && (
|
||||||
<input
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
||||||
type="text"
|
{logsError}
|
||||||
placeholder="Filtrer par utilisateur…"
|
</div>
|
||||||
value={filterUser}
|
)}
|
||||||
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"
|
{logsLoading && logs.length === 0
|
||||||
/>
|
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
||||||
{/* Filtre succès */}
|
: filteredLogs.length === 0
|
||||||
<select
|
? <p className="text-xs text-gray-500 py-8 text-center">Aucune entrée.</p>
|
||||||
value={filterSuccess}
|
: (
|
||||||
onChange={(e) => setFilterSuccess(e.target.value)}
|
<div className="overflow-x-auto -mx-2">
|
||||||
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"
|
<table className="w-full text-xs">
|
||||||
>
|
<thead>
|
||||||
<option value="all">Tous</option>
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
||||||
<option value="true">Succès</option>
|
<th className="pb-2 px-2 font-medium">Date / Heure</th>
|
||||||
<option value="false">Échecs</option>
|
<th className="pb-2 px-2 font-medium">Utilisateur</th>
|
||||||
</select>
|
<th className="pb-2 px-2 font-medium">Adresse IP</th>
|
||||||
|
<th className="pb-2 px-2 font-medium">Résultat</th>
|
||||||
|
<th className="pb-2 px-2 font-medium">Détail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/60">
|
||||||
|
{filteredLogs.map(log => (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="py-2 px-2 text-gray-400 whitespace-nowrap font-mono">
|
||||||
|
{new Date(log.ts).toLocaleString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-gray-200 font-mono">{log.username}</td>
|
||||||
|
<td className="py-2 px-2 text-gray-400 font-mono">{log.ip}</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
{log.success
|
||||||
|
? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-400">
|
||||||
|
<Check size={11} /> Succès
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-red-400">
|
||||||
|
<X size={11} /> Échec
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-gray-500">{log.reason || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => loadLogs(logsPage - 1)}
|
||||||
|
disabled={logsPage === 0 || logsLoading}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
← Précédent
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Page {logsPage + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => loadLogs(logsPage + 1)}
|
||||||
|
disabled={logsPage >= totalPages - 1 || logsLoading}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Suivant →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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
|
<button
|
||||||
onClick={() => loadLogs(logsPage)}
|
onClick={loadDbInfo}
|
||||||
disabled={logsLoading}
|
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"
|
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={logsLoading ? 'animate-spin' : ''} />
|
<RefreshCw size={12} className={dbInfoLoading ? 'animate-spin' : ''} />
|
||||||
Actualiser
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{logsError && (
|
{dbInfoError && (
|
||||||
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
||||||
{logsError}
|
{dbInfoError}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{logsLoading && logs.length === 0
|
{/* Cards par table */}
|
||||||
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
{[
|
||||||
: filteredLogs.length === 0
|
{ key: 'vps_stats', label: 'Statistiques VPS', icon: <Database size={15} className="text-indigo-400" /> },
|
||||||
? <p className="text-xs text-gray-500 py-8 text-center">Aucune entrée.</p>
|
{ key: 'login_logs', label: 'Logs de connexion', icon: <Database size={15} className="text-violet-400" /> },
|
||||||
: (
|
].map(({ key, label, icon }) => {
|
||||||
<div className="overflow-x-auto -mx-2">
|
const info = dbInfo?.[key]
|
||||||
<table className="w-full text-xs">
|
return (
|
||||||
<thead>
|
<section key={key} className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
<tr className="text-left text-gray-500 border-b border-gray-800">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<th className="pb-2 px-2 font-medium">Date / Heure</th>
|
{icon}
|
||||||
<th className="pb-2 px-2 font-medium">Utilisateur</th>
|
<h3 className="text-sm font-semibold text-gray-300">{label}</h3>
|
||||||
<th className="pb-2 px-2 font-medium">Adresse IP</th>
|
{info && (
|
||||||
<th className="pb-2 px-2 font-medium">Résultat</th>
|
<span className="ml-auto text-xs text-gray-500">
|
||||||
<th className="pb-2 px-2 font-medium">Détail</th>
|
{fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''}
|
||||||
</tr>
|
{info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`}
|
||||||
</thead>
|
</span>
|
||||||
<tbody className="divide-y divide-gray-800/60">
|
)}
|
||||||
{filteredLogs.map(log => (
|
</div>
|
||||||
<tr key={log.id} className="hover:bg-gray-800/30 transition-colors">
|
|
||||||
<td className="py-2 px-2 text-gray-400 whitespace-nowrap font-mono">
|
<div className="flex flex-wrap gap-2">
|
||||||
{new Date(log.ts).toLocaleString('fr-FR')}
|
{[
|
||||||
</td>
|
{ period: 'last_24h', label: '24 dernières heures' },
|
||||||
<td className="py-2 px-2 text-gray-200 font-mono">{log.username}</td>
|
{ period: 'last_7d', label: '7 derniers jours' },
|
||||||
<td className="py-2 px-2 text-gray-400 font-mono">{log.ip}</td>
|
{ period: 'last_30d', label: '30 derniers jours' },
|
||||||
<td className="py-2 px-2">
|
{ period: 'all', label: 'Tout effacer', danger: true },
|
||||||
{log.success
|
].map(({ period, label: btnLabel, danger }) => (
|
||||||
? (
|
<button
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-400">
|
key={period}
|
||||||
<Check size={11} /> Succès
|
onClick={() => requestPurge(key, period)}
|
||||||
</span>
|
disabled={purgeLoading || dbInfoLoading}
|
||||||
) : (
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50 ${
|
||||||
<span className="inline-flex items-center gap-1 text-red-400">
|
danger
|
||||||
<X size={11} /> Échec
|
? 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50'
|
||||||
</span>
|
: 'bg-gray-800 hover:bg-gray-700 text-gray-300'
|
||||||
)
|
}`}
|
||||||
}
|
>
|
||||||
</td>
|
<Trash2 size={11} />
|
||||||
<td className="py-2 px-2 text-gray-500">{log.reason || '—'}</td>
|
{btnLabel}
|
||||||
</tr>
|
</button>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</section>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Période personnalisée */}
|
||||||
{totalPages > 1 && (
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
|
<h3 className="text-sm font-semibold text-gray-300 mb-1">Période personnalisée</h3>
|
||||||
<button
|
<p className="text-xs text-gray-500 mb-4">Supprimez les données comprises entre deux dates précises.</p>
|
||||||
onClick={() => loadLogs(logsPage - 1)}
|
|
||||||
disabled={logsPage === 0 || logsLoading}
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
<div>
|
||||||
>
|
<label className="block text-xs text-gray-500 mb-1">Du</label>
|
||||||
← Précédent
|
<input
|
||||||
</button>
|
type="datetime-local"
|
||||||
<span className="text-xs text-gray-500">
|
value={customFrom}
|
||||||
Page {logsPage + 1} / {totalPages}
|
onChange={e => setCustomFrom(e.target.value)}
|
||||||
</span>
|
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"
|
||||||
<button
|
/>
|
||||||
onClick={() => loadLogs(logsPage + 1)}
|
</div>
|
||||||
disabled={logsPage >= totalPages - 1 || logsLoading}
|
<div>
|
||||||
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
<label className="block text-xs text-gray-500 mb-1">Au</label>
|
||||||
>
|
<input
|
||||||
Suivant →
|
type="datetime-local"
|
||||||
</button>
|
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>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 :</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user