Feat : Notifications system, State log
All checks were successful
Build and Push Docker Images / docker (push) Successful in 38s

This commit is contained in:
jeanotx32
2026-06-24 00:46:16 -04:00
parent 38bc430348
commit 022fbabe5d
3 changed files with 521 additions and 14 deletions

View File

@@ -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")