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 (
@@ -107,152 +185,345 @@ export default function AdminPage({ onBack }) { Retour au tableau de bord -
+

Administration

- {/* ── Section Paramètres ── */} -
-

Paramètres

-

Configuration globale de l'application.

+ {/* ── Tabs ── */} +
+ {[ + { key: 'settings', label: 'Paramètres' }, + { key: 'logs', label: 'Connexions' }, + { key: 'database', label: 'Base de données' }, + ].map(tab => ( + + ))} +
- {settingsError && ( -
- {settingsError} -
- )} + {/* ── Tab: Paramètres ── */} + {activeTab === 'settings' && ( +
+

Paramètres

+

Configuration globale de l'application.

- {settingsLoading - ?

Chargement…

- : ( -
- + {settingsError && ( +
+ {settingsError}
- ) - } -
+ )} - {/* ── Section Logs de connexion ── */} -
-
-
-

Tentatives de connexion

-

{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total

+ {settingsLoading + ?

Chargement…

+ : ( +
+ +
+ ) + } +
+ )} + + {/* ── Tab: Connexions ── */} + {activeTab === 'logs' && ( +
+
+
+

Tentatives de connexion

+

{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total

+
+
+ 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" + /> + + +
-
- {/* Filtre utilisateur */} - 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" - /> - {/* Filtre succès */} - + + {logsError && ( +
+ {logsError} +
+ )} + + {logsLoading && logs.length === 0 + ?

Chargement…

+ : filteredLogs.length === 0 + ?

Aucune entrée.

+ : ( +
+ + + + + + + + + + + + {filteredLogs.map(log => ( + + + + + + + + ))} + +
Date / HeureUtilisateurAdresse IPRésultatDétail
+ {new Date(log.ts).toLocaleString('fr-FR')} + {log.username}{log.ip} + {log.success + ? ( + + Succès + + ) : ( + + Échec + + ) + } + {log.reason || '—'}
+
+ ) + } + + {totalPages > 1 && ( +
+ + + Page {logsPage + 1} / {totalPages} + + +
+ )} +
+ )} + + {/* ── Tab: Base de données ── */} + {activeTab === 'database' && ( +
+ {/* En-tête */} +
+
+

Gestion de la base de données

+

Supprimez les données historiques par table et par période.

+
-
- {logsError && ( -
- {logsError} -
- )} + {dbInfoError && ( +
+ {dbInfoError} +
+ )} + {purgeResult && ( +
+ {purgeResult} +
+ )} + {purgeError && ( +
+ {purgeError} +
+ )} - {logsLoading && logs.length === 0 - ?

Chargement…

- : filteredLogs.length === 0 - ?

Aucune entrée.

- : ( -
- - - - - - - - - - - - {filteredLogs.map(log => ( - - - - - - - - ))} - -
Date / HeureUtilisateurAdresse IPRésultatDétail
- {new Date(log.ts).toLocaleString('fr-FR')} - {log.username}{log.ip} - {log.success - ? ( - - Succès - - ) : ( - - Échec - - ) - } - {log.reason || '—'}
-
+ {/* Cards par table */} + {[ + { key: 'vps_stats', label: 'Statistiques VPS', icon: }, + { key: 'login_logs', label: 'Logs de connexion', icon: }, + ].map(({ key, label, icon }) => { + const info = dbInfo?.[key] + return ( +
+
+ {icon} +

{label}

+ {info && ( + + {fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''} + {info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`} + + )} +
+ +
+ {[ + { 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 }) => ( + + ))} +
+
) - } + })} - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {logsPage + 1} / {totalPages} - - + {/* Période personnalisée */} +
+

Période personnalisée

+

Supprimez les données comprises entre deux dates précises.

+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+ +
+
+
+ )} + + {/* ── Modale de confirmation de purge ── */} + {confirmState && ( +
+
+
+
+ +
+

Confirmer la suppression

+
+

+ Vous êtes sur le point de supprimer{' '} + {periodLabel[confirmState.period]}{' '} + dans{' '} + {tableLabel[confirmState.table]}. + Cette action est irréversible. +

+
+ + +
- )} -
+
+ )} +
) diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index 60d49c8..b181a97 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -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 ContainerRow from './ContainerRow' import { tagColor } from './TagInput' @@ -14,9 +14,10 @@ function formatRam(bytes) { 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 [updatingProject, setUpdatingProject] = useState(null) + const [updatingAgent, setUpdatingAgent] = useState(false) const running = vps.containers.filter(c => c.status === 'running').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) } } + const handleUpdateAgent = async () => { + setUpdatingAgent(true) + try { await onUpdateAgent(vps.id) } finally { setUpdatingAgent(false) } + } + return (
{/* Header */} @@ -171,6 +177,40 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
)} + {/* Version de l'agent + bouton mise à jour */} + {!collapsed && vps.online && ( +
+ Agent : + {vps.agent_version ? ( + + {vps.agent_up_to_date ? '✓' : '⚠'} v{vps.agent_version} + + ) : ( + + inconnu + + )} + {(!vps.agent_up_to_date || vps.agent_version === 'unknown') && ( + + )} +
+ )} + {/* Footer stats */} {!collapsed && vps.online && total > 0 && (