""" status_engine.py — Analyse les métriques reçues de l'agent. Détermine le statut global, la cause des ralentissements et une ETA. """ from collections import deque from dataclasses import dataclass, field from typing import Optional import time # ─── Constantes ─────────────────────────────────────────────────────────────── STATUS_OK = "ok" STATUS_WARNING = "warning" STATUS_CRITICAL = "critical" THRESHOLDS = { "cpu": {"warning": 70, "critical": 85}, "ram": {"warning": 80, "critical": 90}, "swap": {"warning": 40, "critical": 70}, } # Noms affichables pour chaque service SERVICE_DISPLAY = { "minecraft": "⛏️ Minecraft", "hytale": "🎮 Hytale", "satisfactory": "🏭 Satisfactory", "valheim": "🪓 Valheim", "terraria": "🌳 Terraria", "ark": "🦕 ARK", "ollama": "🤖 Ollama (IA)", "discord_bots": "🤖 Bots Discord", } HISTORY_MAXLEN = 24 # 24 × 15s = 6 minutes de mémoire glissante PUSH_INTERVAL = 5 # secondes (doit correspondre à l'agent) # ─── Data classes ───────────────────────────────────────────────────────────── @dataclass class ServiceStatus: key: str name: str running: bool cpu: float = 0.0 ram_mb: float = 0.0 @property def ram_gb(self) -> float: return round(self.ram_mb / 1024, 2) @dataclass class AnalysisResult: status: str # ok / warning / critical status_emoji: str cpu: float ram_pct: float ram_used_gb: float ram_total_gb: float swap_pct: float services: list[ServiceStatus] # triés par impact décroissant cause_lines: list[str] # explication humaine eta_text: str # estimation retour à la normale load_avg: tuple[float, float, float] cpu_temp: Optional[float] # ─── Moteur ─────────────────────────────────────────────────────────────────── class StatusEngine: def __init__(self): self._history: deque[dict] = deque(maxlen=HISTORY_MAXLEN) self.last_metrics: Optional[dict] = None self.seen_services: set[str] = set() # ── Ingest ──────────────────────────────────────────────────────────────── def push(self, metrics: dict): """Enregistre un snapshot de métriques.""" self._history.append(metrics) self.last_metrics = metrics # On mémorise quels services ont été vus "actifs" au moins une fois raw_services = metrics.get("services", {}) for key, data in raw_services.items(): if data.get("running"): self.seen_services.add(key) # ── Analyse ─────────────────────────────────────────────────────────────── def analyse(self) -> Optional[AnalysisResult]: if not self.last_metrics: return None m = self.last_metrics sys = m.get("system", {}) cpu = sys.get("cpu_percent", 0.0) ram_pct = sys.get("ram_percent", 0.0) ram_used = sys.get("ram_used_gb", 0.0) ram_total = sys.get("ram_total_gb", 1.0) swap_pct = sys.get("swap_percent", 0.0) load = (sys.get("load_avg_1m", 0.0), sys.get("load_avg_5m", 0.0), sys.get("load_avg_15m", 0.0)) cpu_temp = sys.get("cpu_c", None) status = self._compute_status(cpu, ram_pct, swap_pct) services = self._parse_services(m.get("services", {})) causes = self._build_causes(cpu, ram_pct, ram_used, ram_total, swap_pct, services) eta = self._estimate_eta(status) emoji_map = { STATUS_OK: "🟢", STATUS_WARNING: "🟡", STATUS_CRITICAL: "🔴", } return AnalysisResult( status=status, status_emoji=emoji_map[status], cpu=cpu, ram_pct=ram_pct, ram_used_gb=ram_used, ram_total_gb=ram_total, swap_pct=swap_pct, services=services, cause_lines=causes, eta_text=eta, load_avg=load, cpu_temp=cpu_temp, ) # ── Méthodes internes ───────────────────────────────────────────────────── def _compute_status(self, cpu: float, ram: float, swap: float) -> str: if (cpu >= THRESHOLDS["cpu"]["critical"] or ram >= THRESHOLDS["ram"]["critical"] or swap >= THRESHOLDS["swap"]["critical"]): return STATUS_CRITICAL if (cpu >= THRESHOLDS["cpu"]["warning"] or ram >= THRESHOLDS["ram"]["warning"] or swap >= THRESHOLDS["swap"]["warning"]): return STATUS_WARNING return STATUS_OK def _parse_services(self, raw: dict) -> list[ServiceStatus]: result = [] for key, data in raw.items(): running = data.get("running", False) # On ignore les services qui n'ont jamais été vus actifs sur ce serveur if not running and key not in self.seen_services: continue svc = ServiceStatus( key=key, name=SERVICE_DISPLAY.get(key, key.capitalize()), running=running, cpu=round(data.get("cpu", 0.0), 1), ram_mb=round(data.get("ram_mb", 0.0), 1), ) result.append(svc) # Tri : services actifs en premier, puis par impact (CPU + RAM normalisée) result.sort(key=lambda s: ( not s.running, -(s.cpu + s.ram_mb / 100) )) return result def _build_causes( self, cpu: float, ram_pct: float, ram_used: float, ram_total: float, swap_pct: float, services: list[ServiceStatus] ) -> list[str]: lines = [] running = [s for s in services if s.running] # ── Problème CPU ── if cpu >= THRESHOLDS["cpu"]["warning"]: top = max(running, key=lambda s: s.cpu, default=None) if top and top.cpu > 5: lines.append( f"CPU à **{cpu:.0f}%** — principal consommateur : " f"**{top.name}** ({top.cpu:.0f}%)" ) else: lines.append(f"CPU à **{cpu:.0f}%** — charge élevée non attribuée à un service connu") # ── Problème RAM ── if ram_pct >= THRESHOLDS["ram"]["warning"]: top = max(running, key=lambda s: s.ram_mb, default=None) if top and top.ram_mb > 200: lines.append( f"RAM à **{ram_pct:.0f}%** ({ram_used:.1f}/{ram_total:.1f} GB) — " f"**{top.name}** occupe {top.ram_gb:.1f} GB" ) else: lines.append( f"RAM à **{ram_pct:.0f}%** ({ram_used:.1f}/{ram_total:.1f} GB)" ) # ── Swap ── if swap_pct >= THRESHOLDS["swap"]["warning"]: lines.append( f"Swap à **{swap_pct:.0f}%** — le serveur manque de RAM " f"et utilise le disque comme mémoire d'appoint (ralentissement fort)" ) # ── Combinaison Ollama + jeux ── ollama = next((s for s in running if s.key == "ollama"), None) games = [s for s in running if s.key not in ("ollama", "discord_bots")] if ollama and games and (cpu >= THRESHOLDS["cpu"]["critical"] or ram_pct >= THRESHOLDS["ram"]["critical"]): game_names = ", ".join(s.name for s in games) lines.append( f"💡 Conflit potentiel : **{ollama.name}** (IA) et **{game_names}** " f"semblent saturer les ressources" ) return lines if lines else ["✅ Aucune anomalie détectée"] def _estimate_eta(self, current_status: str) -> str: if current_status == STATUS_OK: return "✅ Tout est normal" if len(self._history) < 4: return "⏳ Collecte en cours, ETA disponible dans quelques mesures…" scores = [self._pressure_score(m) for m in self._history] # Tendance : on compare la moyenne des 4 premières mesures vs les 4 dernières n = min(4, len(scores) // 2) old_avg = sum(scores[:n]) / n new_avg = sum(scores[-n:]) / n delta = old_avg - new_avg # positif = amélioration if delta > 0.08: # Le serveur s'améliore : on extrapole current = scores[-1] if current <= 1.0: return "⏱️ Retour à la normale **imminent**" rate_per_step = delta / max(len(scores) - n, 1) if rate_per_step <= 0: return "📊 Situation stable — ETA indéterminée" steps = (current - 1.0) / rate_per_step secs = int(steps * PUSH_INTERVAL) if secs < 60: return "⏱️ Retour à la normale dans **moins d'1 minute**" elif secs < 3600: return f"⏱️ Retour à la normale dans **~{secs // 60} min**" else: h, m2 = divmod(secs // 60, 60) return f"⏱️ Retour à la normale dans **~{h}h{m2:02d}**" elif delta < -0.08: return "⚠️ La charge **augmente** — ETA indéterminée, surveillez l'évolution" else: return "📊 Charge **stable** — ETA indéterminée (activité constante)" def _pressure_score(self, m: dict) -> float: """Score de pression normalisé — 1.0 = seuil critique.""" sys = m.get("system", {}) cpu = sys.get("cpu_percent", 0) / THRESHOLDS["cpu"]["critical"] ram = sys.get("ram_percent", 0) / THRESHOLDS["ram"]["critical"] swap = sys.get("swap_percent", 0) / THRESHOLDS["swap"]["critical"] return round(max(cpu, ram, swap), 3) @property def has_data(self) -> bool: return self.last_metrics is not None