mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 04:34:41 +02:00
La page détail d'un service (panneau « Services » du HUD) passe en PLEINE
PAGE : le drawer prend tout l'écran, le titre devient le nom du service, et
le contenu s'organise en 2 colonnes sur grand écran (données à gauche, état
+ actions à droite).
Nouvelle section « Métriques » par service, config-driven (comme `components`) :
- `[[services.metrics]]` → {label, prom, unit, hide_if_empty} évalué par le
StatusPoller (dédup + parallèle avec les composants).
- Défauts via `config._pod_metrics(ns, pod_re)` pour les 7 services : redémarrages
+ uptime (kube-state-metrics, toujours dispo) ; mémoire + CPU (cAdvisor,
`hide_if_empty` → carte masquée si non scrappé).
- Formatage HUD par unité : duration (8 j 9 h), bytes (497 Mo), cpu (30 mcœur),
count.
Client 0.19.0. PromQL vérifié live (les 4 métriques renvoient des valeurs réelles
sur les 7 services). Rendu pleine page + formats validés via Playwright.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
8.8 KiB
Python
221 lines
8.8 KiB
Python
"""Santé live des services du portail — sondes HTTP + Prometheus + Alertmanager.
|
|
|
|
Tout tourne côté backend Python (pas de CORS) dans un thread dédié : à intervalle
|
|
régulier, on sonde chaque service et on pousse l'état au HUD via `emit` (le même
|
|
pont thread-safe que le moteur vocal). Trois sources :
|
|
|
|
• probe HTTP de `service.probe_url` → en ligne / hors ligne + latence
|
|
• Prometheus `/api/v1/query` (composants) → état de chaque brique (PromQL → 1/0)
|
|
• Alertmanager `/api/v2/alerts` (alertes) → alertes actives, filtrées par service
|
|
|
|
Aucune dépendance nouvelle : `requests` (déjà utilisé) + `concurrent.futures`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any, Callable
|
|
|
|
from .registry import Service
|
|
|
|
# L'alerte « Watchdog » est volontairement toujours active (test du pipeline) :
|
|
# on l'exclut pour ne pas polluer chaque page.
|
|
_ALWAYS_ON_ALERTS = {"Watchdog"}
|
|
|
|
|
|
def probe_http(url: str, timeout: float) -> dict[str, Any]:
|
|
"""GET `url` → {state: up|down, latency_ms, code}. Toute erreur = down."""
|
|
import requests
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
r = requests.get(url, timeout=timeout, allow_redirects=True)
|
|
latency = int((time.monotonic() - t0) * 1000)
|
|
ok = r.status_code < 400
|
|
return {"state": "up" if ok else "down", "latency_ms": latency, "code": r.status_code}
|
|
except requests.RequestException:
|
|
return {"state": "down", "latency_ms": None, "code": None}
|
|
|
|
|
|
def query_prometheus(base: str, expr: str, timeout: float) -> list[float] | None:
|
|
"""Évalue une requête instantanée → liste des valeurs, ou None si la requête échoue.
|
|
|
|
[] = requête OK mais aucune série (cible inconnue → état « indéterminé »).
|
|
"""
|
|
import requests
|
|
|
|
try:
|
|
r = requests.get(
|
|
f"{base.rstrip('/')}/api/v1/query",
|
|
params={"query": expr}, timeout=timeout,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
if data.get("status") != "success":
|
|
return None
|
|
out: list[float] = []
|
|
for s in data.get("data", {}).get("result", []):
|
|
try:
|
|
out.append(float(s["value"][1]))
|
|
except (KeyError, IndexError, ValueError, TypeError):
|
|
pass
|
|
return out
|
|
except (requests.RequestException, ValueError):
|
|
return None
|
|
|
|
|
|
def _component_state(values: list[float] | None) -> str:
|
|
"""Interprète les valeurs PromQL d'un composant : up / down / unknown."""
|
|
if values is None:
|
|
return "unknown" # Prometheus injoignable
|
|
if not values:
|
|
return "unknown" # aucune série (cible absente)
|
|
if any(v == 0 for v in values):
|
|
return "down"
|
|
return "up" # toutes les séries non nulles
|
|
|
|
|
|
def fetch_alerts(base: str, timeout: float) -> list[dict[str, Any]] | None:
|
|
"""Alertes actives d'Alertmanager (hors Watchdog), normalisées. None si injoignable."""
|
|
import requests
|
|
|
|
try:
|
|
r = requests.get(f"{base.rstrip('/')}/api/v2/alerts",
|
|
params={"active": "true", "silenced": "false"}, timeout=timeout)
|
|
r.raise_for_status()
|
|
raw = r.json()
|
|
except (requests.RequestException, ValueError):
|
|
return None
|
|
out: list[dict[str, Any]] = []
|
|
for a in raw if isinstance(raw, list) else []:
|
|
labels = a.get("labels", {}) or {}
|
|
if labels.get("alertname") in _ALWAYS_ON_ALERTS:
|
|
continue
|
|
if (a.get("status", {}) or {}).get("state") not in (None, "active"):
|
|
continue
|
|
ann = a.get("annotations", {}) or {}
|
|
out.append({
|
|
"name": labels.get("alertname", "alerte"),
|
|
"severity": labels.get("severity", "none"),
|
|
"summary": ann.get("summary") or ann.get("description") or "",
|
|
"labels": labels,
|
|
})
|
|
return out
|
|
|
|
|
|
def _alert_matches(alert: dict[str, Any], selectors: tuple[tuple[str, str], ...]) -> bool:
|
|
"""Vrai si TOUS les (label, regex) du service matchent les labels de l'alerte."""
|
|
if not selectors:
|
|
return False
|
|
labels = alert.get("labels", {}) or {}
|
|
for key, pattern in selectors:
|
|
val = str(labels.get(key, ""))
|
|
try:
|
|
if not re.search(pattern, val):
|
|
return False
|
|
except re.error:
|
|
if pattern != val:
|
|
return False
|
|
return True
|
|
|
|
|
|
class StatusPoller:
|
|
"""Boucle de sondage en thread : construit l'état de tous les services et l'émet."""
|
|
|
|
def __init__(self, services: list[Service], cfg: dict, emit: Callable[[dict], None]):
|
|
self.services = services
|
|
self.emit = emit
|
|
self.prom_url = cfg.get("prometheus_url", "http://prometheus.lab.local")
|
|
self.alert_url = cfg.get("alertmanager_url", "http://alertmanager.lab.local")
|
|
self.interval = float(cfg.get("status_interval_sec", 15) or 15)
|
|
self.timeout = float(cfg.get("probe_timeout_sec", 4) or 4)
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
|
|
# ── cycle de mesure ──────────────────────────────────────────────────
|
|
def build_status(self) -> dict[str, Any]:
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
# 1) probes HTTP (un par service, en parallèle)
|
|
probes = {
|
|
s.id: pool.submit(probe_http, s.probe_url, self.timeout)
|
|
for s in self.services
|
|
}
|
|
# 2) requêtes Prometheus distinctes (déduplique les exprs identiques) —
|
|
# composants (1/0) ET métriques chiffrées de page détail.
|
|
exprs = {c.prom for s in self.services for c in s.components if c.prom}
|
|
exprs |= {m.prom for s in self.services for m in s.metrics if m.prom}
|
|
prom_futs = {
|
|
e: pool.submit(query_prometheus, self.prom_url, e, self.timeout)
|
|
for e in exprs
|
|
}
|
|
# 3) alertes (une seule requête)
|
|
alerts_fut = pool.submit(fetch_alerts, self.alert_url, self.timeout)
|
|
|
|
prom = {e: f.result() for e, f in prom_futs.items()}
|
|
alerts = alerts_fut.result()
|
|
|
|
services_out: dict[str, Any] = {}
|
|
for s in self.services:
|
|
probe = probes[s.id].result()
|
|
comps = [
|
|
{"name": c.name,
|
|
"state": _component_state(prom.get(c.prom)) if c.prom else "info"}
|
|
for c in s.components
|
|
]
|
|
# Métriques chiffrées : 1re valeur de la série agrégée (None si absente).
|
|
metrics_out = []
|
|
for m in s.metrics:
|
|
vals = prom.get(m.prom)
|
|
value = vals[0] if vals else None
|
|
if value is None and m.hide_if_empty:
|
|
continue # ex. cAdvisor non scrappé → on n'affiche pas la carte
|
|
metrics_out.append({"label": m.label, "value": value, "unit": m.unit})
|
|
svc_alerts = [a for a in (alerts or []) if _alert_matches(a, s.alerts_match)]
|
|
degraded = probe["state"] == "up" and any(c["state"] == "down" for c in comps)
|
|
if probe["state"] == "down":
|
|
health = "down"
|
|
elif degraded:
|
|
health = "degraded"
|
|
elif probe["state"] == "unknown":
|
|
health = "unknown"
|
|
else:
|
|
health = "ok"
|
|
services_out[s.id] = {
|
|
"state": probe["state"],
|
|
"latency_ms": probe["latency_ms"],
|
|
"health": health,
|
|
"components": comps,
|
|
"metrics": metrics_out,
|
|
"alerts": svc_alerts,
|
|
}
|
|
|
|
total = None if alerts is None else len(alerts)
|
|
# `all_alerts` : liste complète (déjà récupérée) → alimente la page détail
|
|
# d'Alertmanager (aperçu de TOUTES les alertes, pas seulement celles d'un service).
|
|
return {
|
|
"type": "portal-status",
|
|
"services": services_out,
|
|
"alerts_total": total,
|
|
"all_alerts": alerts or [],
|
|
}
|
|
|
|
# ── thread ───────────────────────────────────────────────────────────
|
|
def _run(self) -> None:
|
|
while not self._stop.is_set():
|
|
try:
|
|
self.emit(self.build_status())
|
|
except Exception: # noqa: BLE001 — un cycle raté ne tue pas le poller
|
|
pass
|
|
self._stop.wait(self.interval)
|
|
|
|
def start(self) -> None:
|
|
if self._thread is not None:
|
|
return
|
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|