diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index ffb0c6a..5b16f03 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -64,6 +64,10 @@ def _get_expected_version() -> str: # ─── Ring buffer de stats (en mémoire) ─────────────────────────────────────── _STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle _stats_history: dict[str, deque] = {} + +# État courant des conteneurs : vps_id → {container_name: effective_state} +_container_states: dict[str, dict[str, str]] = {} + SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret")) AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5")) JWT_ALGORITHM = "HS256" @@ -252,6 +256,31 @@ def init_db() -> None: conn.execute(""" INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true') """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_enabled', 'false') + """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_app_token', '') + """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '') + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS container_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + vps_id TEXT NOT NULL, + vps_name TEXT NOT NULL, + container TEXT NOT NULL, + image TEXT NOT NULL DEFAULT '', + old_state TEXT NOT NULL DEFAULT '', + new_state TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_container_events_ts + ON container_events(ts DESC) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS passkeys ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -327,6 +356,32 @@ def _get_setting(key: str) -> str: return row["value"] if row else "" +# ─── Pushover notifications ─────────────────────────────────────────────────── + +def _effective_state(status: str, health: str) -> str: + if health and health not in ("none", ""): + return f"{status}:{health}" + return status + + +async def _send_pushover(title: str, message: str) -> bool: + token = _get_setting("pushover_app_token") + user = _get_setting("pushover_user_key") + if not token or not user: + return False + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.pushover.net/1/messages.json", + data={"token": token, "user": user, "title": title, "message": message}, + timeout=timeout, + ) as r: + return r.status == 200 + except Exception: + return False + + # ─── WebAuthn / Passkey helpers ─────────────────────────────────────────────── def _b64url_encode(b: bytes) -> str: @@ -626,6 +681,76 @@ async def _stats_collector() -> None: await asyncio.sleep(5) +# ─── Collecteur d'états des conteneurs ─────────────────────────────────────── + +async def _check_container_states(vps: dict) -> None: + try: + containers = await agent_get(vps, "/containers") + except Exception: + return + + vps_id = vps["id"] + vps_name = vps["name"] + current = { + c["name"]: _effective_state(c["status"], c.get("health", "none")) + for c in containers + } + + prev = _container_states.get(vps_id) + if prev is None: + _container_states[vps_id] = current + return + + now = int(time.time()) + notify = _get_setting("pushover_enabled") == "true" + events: list[dict] = [] + + for name, state in current.items(): + prev_state = prev.get(name, "") + if state != prev_state: + image = next((c["image"] for c in containers if c["name"] == name), "") + events.append({ + "ts": now, "vps_id": vps_id, "vps_name": vps_name, + "container": name, "image": image, + "old_state": prev_state, "new_state": state, + }) + + for name, state in prev.items(): + if name not in current: + events.append({ + "ts": now, "vps_id": vps_id, "vps_name": vps_name, + "container": name, "image": "", + "old_state": state, "new_state": "removed", + }) + + if events: + with get_db() as conn: + conn.executemany(""" + INSERT INTO container_events + (ts, vps_id, vps_name, container, image, old_state, new_state) + VALUES (:ts, :vps_id, :vps_name, :container, :image, :old_state, :new_state) + """, events) + if notify: + for ev in events: + title = f"[{ev['vps_name']}] {ev['container']}" + msg = f"Etat : {ev['old_state'] or 'nouveau'} -> {ev['new_state']}" + asyncio.create_task(_send_pushover(title, msg)) + + _container_states[vps_id] = current + + +async def _container_state_collector() -> None: + """Tache de fond : verifie les etats des conteneurs toutes les 15 secondes.""" + await asyncio.sleep(5) + while True: + vps_list = load_vps() + await asyncio.gather( + *[_check_container_states(v) for v in vps_list], + return_exceptions=True, + ) + await asyncio.sleep(15) + + async def _refresh_latest_agent_version() -> None: """Récupère AGENT_VERSION depuis le dépôt Gitea toutes les heures. Si EXPECTED_AGENT_VERSION est défini en env var, cette tâche n'écrase pas la valeur forcée. @@ -655,17 +780,19 @@ async def _refresh_latest_agent_version() -> None: @app.on_event("startup") async def startup_event() -> None: asyncio.create_task(_stats_collector()) + asyncio.create_task(_container_state_collector()) asyncio.create_task(_cleanup_old_stats()) asyncio.create_task(_refresh_latest_agent_version()) async def _cleanup_old_stats() -> None: - """Supprime les statistiques de plus de 31 jours (s'exécute toutes les heures).""" + """Supprime les statistiques et evenements de plus de 31 jours (toutes les heures).""" while True: await asyncio.sleep(3600) cutoff = int(time.time()) - 31 * 24 * 3600 with get_db() as conn: conn.execute("DELETE FROM vps_stats WHERE ts < ?", (cutoff,)) + conn.execute("DELETE FROM container_events WHERE ts < ?", (cutoff,)) # ─── Routes Auth ────────────────────────────────────────────────────────────── @@ -760,7 +887,7 @@ def admin_update_setting( _: Annotated[dict, Depends(require_admin)], ): """Met à jour un paramètre d'administration.""" - allowed_keys = {"registration_open", "passkey_enabled"} + allowed_keys = {"registration_open", "passkey_enabled", "pushover_enabled", "pushover_app_token", "pushover_user_key"} if key not in allowed_keys: raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") with get_db() as conn: @@ -825,12 +952,13 @@ def admin_db_info(_: Annotated[dict, Depends(require_admin)]): "newest_ts": row["newest"], } return { - "vps_stats": _table_info("vps_stats"), - "login_logs": _table_info("login_logs"), + "vps_stats": _table_info("vps_stats"), + "login_logs": _table_info("login_logs"), + "container_events": _table_info("container_events"), } -_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs"}) +_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs", "container_events"}) _ALLOWED_PERIODS = frozenset({"last_24h", "last_7d", "last_30d", "all", "custom"}) @@ -879,6 +1007,52 @@ def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin) return {"deleted": deleted, "status": "ok"} +@app.get("/api/admin/container-events") +def admin_container_events( + limit: int = 100, + offset: int = 0, + _: Annotated[dict, Depends(require_admin)] = None, +): + """Retourne l'historique des changements d'etat des conteneurs.""" + limit = max(1, min(limit, 500)) + offset = max(0, offset) + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM container_events ORDER BY ts DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + total = conn.execute("SELECT COUNT(*) FROM container_events").fetchone()[0] + return { + "total": total, + "events": [ + { + "id": row["id"], + "ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(), + "vps_name": row["vps_name"], + "container": row["container"], + "image": row["image"], + "old_state": row["old_state"], + "new_state": row["new_state"], + } + for row in rows + ], + } + + +@app.post("/api/admin/notifications/test") +async def admin_test_notification(_: Annotated[dict, Depends(require_admin)]): + """Envoie une notification Pushover de test.""" + if _get_setting("pushover_enabled") != "true": + raise HTTPException(status_code=400, detail="Les notifications Pushover sont desactivees") + ok = await _send_pushover("VPS Monitor - Test", "La notification de test a bien ete recue.") + if not ok: + raise HTTPException( + status_code=502, + detail="Echec de l'envoi - verifiez le token et la cle utilisateur", + ) + return {"status": "ok"} + + # ─── Routes Passkeys ────────────────────────────────────────────────────────── @app.post("/api/auth/passkey/register/begin") diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index 224d78a..721e236 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -321,3 +321,20 @@ export async function adminDeletePasskey(credentialId) { }) return handleResponse(res) } + +// ─── Notifications / Container events ──────────────────────────────────────── + +export async function getContainerEvents(limit = 100, offset = 0) { + const res = await fetch(`${BASE}/admin/container-events?limit=${limit}&offset=${offset}`, { + headers: authHeaders(), + }) + return handleResponse(res) +} + +export async function testPushoverNotification() { + const res = await fetch(`${BASE}/admin/notifications/test`, { + method: 'POST', + headers: authHeaders(), + }) + return handleResponse(res) +} diff --git a/vps-monitor/frontend/src/components/AdminPage.jsx b/vps-monitor/frontend/src/components/AdminPage.jsx index 53cd507..5d53852 100644 --- a/vps-monitor/frontend/src/components/AdminPage.jsx +++ b/vps-monitor/frontend/src/components/AdminPage.jsx @@ -1,11 +1,10 @@ import { useState, useEffect, useCallback } from 'react' -import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react' -import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client' +import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key, Bell, Send, Eye, EyeOff, Activity } from 'lucide-react' +import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client' const PAGE_SIZE = 50 -function ToggleRow({ label, description, enabled, onChange, loading }) { - return ( +function ToggleRow({ label, description, enabled, onChange, loading }) { return (

{label}

@@ -26,8 +25,28 @@ function ToggleRow({ label, description, enabled, onChange, loading }) { ) } +const STATE_COLORS = { + running: 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50', + 'running:healthy': 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50', + 'running:unhealthy': 'text-orange-400 bg-orange-950/40 border-orange-800/50', + 'running:starting': 'text-yellow-400 bg-yellow-950/40 border-yellow-800/50', + exited: 'text-gray-500 bg-gray-800/40 border-gray-700/50', + stopped: 'text-gray-500 bg-gray-800/40 border-gray-700/50', + removed: 'text-red-400 bg-red-950/40 border-red-800/50', +} + +function StateChip({ state, highlight = false }) { + if (!state) return + const cls = STATE_COLORS[state] ?? 'text-gray-400 bg-gray-800/40 border-gray-700/50' + return ( + + {state} + + ) +} + export default function AdminPage({ onBack }) { - const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'logs' | 'database' + const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'notifications' | 'logs' | 'database' // ─── Settings ──────────────────────────────────────────────────────────── const [settings, setSettings] = useState(null) @@ -78,6 +97,95 @@ export default function AdminPage({ onBack }) { } } + // ─── Notifications (Pushover) ──────────────────────────────────────────── + const [pushoverToken, setPushoverToken] = useState('') + const [pushoverUserKey, setPushoverUserKey] = useState('') + const [showToken, setShowToken] = useState(false) + const [showUserKey, setShowUserKey] = useState(false) + const [pushoverSaving, setPushoverSaving] = useState(false) + const [pushoverMsg, setPushoverMsg] = useState(null) // { ok, text } + const [testingNotif, setTestingNotif] = useState(false) + const [testResult, setTestResult] = useState(null) // { ok, text } + + // Sync local fields from loaded settings + useEffect(() => { + if (!settings) return + setPushoverToken(settings.pushover_app_token ?? '') + setPushoverUserKey(settings.pushover_user_key ?? '') + }, [settings]) + + const togglePushover = async () => { + if (!settings) return + const newValue = settings.pushover_enabled === 'true' ? 'false' : 'true' + setToggleLoading(true) + try { + await setAdminSetting('pushover_enabled', newValue) + setSettings(prev => ({ ...prev, pushover_enabled: newValue })) + } catch (err) { + setSettingsError(err.message) + } finally { + setToggleLoading(false) + } + } + + const savePushoverCredentials = async () => { + setPushoverSaving(true) + setPushoverMsg(null) + try { + await setAdminSetting('pushover_app_token', pushoverToken.trim()) + await setAdminSetting('pushover_user_key', pushoverUserKey.trim()) + setSettings(prev => ({ + ...prev, + pushover_app_token: pushoverToken.trim(), + pushover_user_key: pushoverUserKey.trim(), + })) + setPushoverMsg({ ok: true, text: 'Identifiants sauvegardés.' }) + } catch (err) { + setPushoverMsg({ ok: false, text: err.message }) + } finally { + setPushoverSaving(false) + } + } + + const handleTestNotification = async () => { + setTestingNotif(true) + setTestResult(null) + try { + await testPushoverNotification() + setTestResult({ ok: true, text: 'Notification envoyée avec succès.' }) + } catch (err) { + setTestResult({ ok: false, text: err.message }) + } finally { + setTestingNotif(false) + } + } + + // ─── Container events ──────────────────────────────────────────────────── + const [events, setEvents] = useState([]) + const [eventsTotal, setEventsTotal] = useState(0) + const [eventsPage, setEventsPage] = useState(0) + const [eventsLoading, setEventsLoading] = useState(false) + const [eventsError, setEventsError] = useState(null) + + const loadEvents = useCallback(async (page = 0) => { + setEventsLoading(true) + setEventsError(null) + try { + const data = await getContainerEvents(PAGE_SIZE, page * PAGE_SIZE) + setEvents(data.events) + setEventsTotal(data.total) + setEventsPage(page) + } catch (err) { + setEventsError(err.message) + } finally { + setEventsLoading(false) + } + }, []) + + useEffect(() => { + if (activeTab === 'notifications') loadEvents(0) + }, [activeTab, loadEvents]) + // ─── Passkeys admin ────────────────────────────────────────────────────── const [adminPasskeys, setAdminPasskeys] = useState([]) const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false) @@ -244,10 +352,11 @@ export default function AdminPage({ onBack }) { {/* ── Tabs ── */}
{[ - { key: 'settings', label: 'Paramètres' }, - { key: 'passkeys', label: 'Passkeys' }, - { key: 'logs', label: 'Connexions' }, - { key: 'database', label: 'Base de données' }, + { key: 'settings', label: 'Paramètres' }, + { key: 'notifications', label: 'Notifications' }, + { key: 'passkeys', label: 'Passkeys' }, + { key: 'logs', label: 'Connexions' }, + { key: 'database', label: 'Base de données' }, ].map(tab => ( +
+
+
+ +
+ setPushoverUserKey(e.target.value)} + placeholder="uXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 pr-9 text-xs font-mono focus:outline-none focus:border-indigo-500 transition-colors" + /> + +
+
+ + {pushoverMsg && ( +
+ {pushoverMsg.text} +
+ )} + +
+ + +
+ + {testResult && ( +
+ {testResult.text} +
+ )} +
+ + ) + } + + + {/* Container events */} +
+
+
+ +
+

Historique des événements

+

{eventsTotal} changement{eventsTotal !== 1 ? 's' : ''} enregistré{eventsTotal !== 1 ? 's' : ''}

+
+
+ +
+ + {eventsError && ( +
+ {eventsError} +
+ )} + + {eventsLoading && events.length === 0 + ?

Chargement…

+ : events.length === 0 + ? ( +
+ +

Aucun événement enregistré.

+
+ ) + : ( +
+ + + + + + + + + + + + {events.map(ev => ( + + + + + + + + ))} + +
Date / HeureVPSConteneurAvantAprès
+ {new Date(ev.ts).toLocaleString('fr-FR')} + {ev.vps_name}{ev.container} + + + +
+
+ ) + } + + {Math.ceil(eventsTotal / PAGE_SIZE) > 1 && ( +
+ + + Page {eventsPage + 1} / {Math.ceil(eventsTotal / PAGE_SIZE)} + + +
+ )} +
+ + )} + {/* ── Tab: Passkeys ── */} {activeTab === 'passkeys' && (