diff --git a/vps-monitor/.pids b/vps-monitor/.pids index 4e28a64..c191dd7 100644 --- a/vps-monitor/.pids +++ b/vps-monitor/.pids @@ -1,2 +1,2 @@ -8423 -8473 +80001 +80063 diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index 1662abd..0a3cbc9 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -6,6 +6,7 @@ Expose une API REST utilisée par le backend central pour interroger les contene import os import subprocess +import threading import time from datetime import datetime, timezone @@ -18,6 +19,11 @@ from fastapi.security import APIKeyHeader # ─── 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") AGENT_PORT = int(os.getenv("AGENT_PORT", "8001")) @@ -55,6 +61,45 @@ def health(): 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") def list_containers(_: None = Depends(require_api_key)): """Retourne tous les conteneurs (actifs et arrêtés).""" diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index 3273101..81e3f2e 100644 Binary files a/vps-monitor/backend/__pycache__/main.cpython-313.pyc and b/vps-monitor/backend/__pycache__/main.cpython-313.pyc differ diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index 486f91c..5ab6511 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -26,7 +26,8 @@ from pydantic import BaseModel # ─── 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) ─────────────────────────────────────── _STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle @@ -102,6 +103,13 @@ class SettingUpdateRequest(BaseModel): 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 ─────────────────────────────────────────────────────────────────── @contextmanager @@ -360,27 +368,40 @@ async def fetch_vps_status(vps: dict) -> dict: except Exception: 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 { - "id": vps["id"], - "name": vps["name"], - "host": vps["host"], - "description": vps.get("description", ""), - "online": True, - "containers": containers_res, - "system": system, - "tags": vps.get("tags", []), + "id": vps["id"], + "name": vps["name"], + "host": vps["host"], + "description": vps.get("description", ""), + "online": True, + "containers": containers_res, + "system": system, + "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: return { - "id": vps["id"], - "name": vps["name"], - "host": vps["host"], - "description": vps.get("description", ""), - "online": False, - "error": str(e), - "containers": [], - "system": None, - "tags": vps.get("tags", []), + "id": vps["id"], + "name": vps["name"], + "host": vps["host"], + "description": vps.get("description", ""), + "online": False, + "error": str(e), + "containers": [], + "system": None, + "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 ─────────────────────────────────────────────────────────────── @app.get("/api/vps") @@ -756,3 +845,18 @@ async def compose_update( return await r.json() except Exception as 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)) diff --git a/vps-monitor/docker-compose.yml b/vps-monitor/docker-compose.yml index f65a791..cf05434 100644 --- a/vps-monitor/docker-compose.yml +++ b/vps-monitor/docker-compose.yml @@ -1,6 +1,6 @@ services: backend: - build: ./backend + image: git.jeanbonapp.com/jeanbon/scriptvps/backend:latest ports: - "8000:8000" volumes: @@ -9,9 +9,10 @@ services: restart: unless-stopped frontend: - build: ./frontend + image: git.jeanbonapp.com/jeanbon/scriptvps/frontend:latest ports: - "3000:80" depends_on: - backend restart: unless-stopped + diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index 9b49d8a..ff9c040 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -1,5 +1,5 @@ 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 VpsCard from './components/VpsCard' 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) => { await addVps(formData) setShowAddVps(false) @@ -289,6 +304,7 @@ export default function App() { onUpdate={handleUpdate} onEdit={setEditVps} onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} + onUpdateAgent={handleUpdateAgent} /> ))} diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index 0dacf45..310114b 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -100,6 +100,14 @@ export async function composeUpdate(vpsId, project) { 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) { const res = await fetch(`${BASE}/vps/${vpsId}`, { method: 'PUT', @@ -152,3 +160,22 @@ export async function getAdminUsers() { const res = await fetch(`${BASE}/admin/users`, { headers: authHeaders() }) 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) +} diff --git a/vps-monitor/frontend/src/components/AdminPage.jsx b/vps-monitor/frontend/src/components/AdminPage.jsx index c2c0cc0..6a32765 100644 --- a/vps-monitor/frontend/src/components/AdminPage.jsx +++ b/vps-monitor/frontend/src/components/AdminPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' -import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X } from 'lucide-react' -import { getAdminSettings, setAdminSetting, getLoginLogs } from '../api/client' +import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react' +import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client' const PAGE_SIZE = 50 @@ -27,6 +27,8 @@ function ToggleRow({ label, description, enabled, onChange, loading }) { } export default function AdminPage({ onBack }) { + const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database' + // ─── Settings ──────────────────────────────────────────────────────────── const [settings, setSettings] = useState(null) const [settingsLoading, setSettingsLoading] = useState(true) @@ -96,6 +98,82 @@ export default function AdminPage({ onBack }) { 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 (
Configuration globale de l'application.
+ {/* ── Tabs ── */} +Configuration globale de l'application.
- {settingsLoading - ?Chargement…
- : ( -{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total
+ {settingsLoading + ?Chargement…
+ : ( +{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total
+Chargement…
+ : filteredLogs.length === 0 + ?Aucune entrée.
+ : ( +| Date / Heure | +Utilisateur | +Adresse IP | +Résultat | +Détail | +
|---|---|---|---|---|
| + {new Date(log.ts).toLocaleString('fr-FR')} + | +{log.username} | +{log.ip} | +
+ {log.success
+ ? (
+
+ |
+ {log.reason || '—'} | +
Supprimez les données historiques par table et par période.
+Chargement…
- : filteredLogs.length === 0 - ?Aucune entrée.
- : ( -| Date / Heure | -Utilisateur | -Adresse IP | -Résultat | -Détail | -
|---|---|---|---|---|
| - {new Date(log.ts).toLocaleString('fr-FR')} - | -{log.username} | -{log.ip} | -
- {log.success
- ? (
-
- |
- {log.reason || '—'} | -
Supprimez les données comprises entre deux dates précises.
+ ++ Vous êtes sur le point de supprimer{' '} + {periodLabel[confirmState.period]}{' '} + dans{' '} + {tableLabel[confirmState.table]}. + Cette action est irréversible. +
+