280 lines
11 KiB
Python
280 lines
11 KiB
Python
|
|
"""
|
|||
|
|
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
|