mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
À vide, l'app chauffait le CPU en continu : trois sondages navigateur séparés (5/8/10 s) tournaient en permanence, y compris onglet caché, et chaque tick relançait un sous-processus nvidia-smi (cache TTL 5 s = la cadence, donc jamais efficace) plus deux connexions vers Ollama. - Nouveau /api/system/pulse : statut + ressources + modèles chargés en UNE requête (appels Ollama en parallèle, dégradation propre si muet), avec un cache court qui absorbe les rafales multi-onglets. - Front : un seul sondage, SUSPENDU quand l'onglet est caché, cadence adaptative (4 s pendant un flux, 20 s au repos) et rafraîchissement immédiat au retour sur l'onglet. - Panneau Matériel : 5 s -> 15 s et rien onglet caché. - Caches serveur alignés sur la cadence : nvidia-smi mis en cache 12 s, et _all_local_gpus (vue Matériel) n'était pas caché du tout. Effet au repos : ~25 requêtes/min et ~12 nvidia-smi/min -> ~3 et ~3 onglet visible, et ZÉRO onglet caché. Le serveur n'a aucune boucle de fond : sans navigateur ouvert, il ne consomme plus rien. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
233 lines
8.3 KiB
Python
233 lines
8.3 KiB
Python
"""Statistiques système temps réel : CPU, RAM, GPU/VRAM (barre supérieure)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
import time
|
|
|
|
import httpx
|
|
import psutil
|
|
from fastapi import APIRouter
|
|
|
|
from ..config import settings
|
|
from ..ollama_client import ollama
|
|
|
|
router = APIRouter(prefix="/api/system", tags=["system"])
|
|
|
|
_NVIDIA_SMI = shutil.which("nvidia-smi")
|
|
|
|
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
|
|
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
|
|
# TTL volontairement > à la cadence de sondage, sinon le cache n'absorbe rien.
|
|
_GPU_CACHE_TTL = 12.0
|
|
_gpu_cache: dict = {"at": 0.0, "value": None}
|
|
# Même logique pour la vue matériel (sous-processus nvidia-smi plus lourd).
|
|
_HW_CACHE_TTL = 12.0
|
|
_hw_cache: dict = {"at": 0.0, "value": None}
|
|
|
|
|
|
async def _gpu_stats() -> dict | None:
|
|
"""Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).
|
|
|
|
Résultat mis en cache ~5 s pour limiter les sous-processus.
|
|
"""
|
|
if not _NVIDIA_SMI:
|
|
return None
|
|
if time.monotonic() - _gpu_cache["at"] < _GPU_CACHE_TTL:
|
|
return _gpu_cache["value"]
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
_NVIDIA_SMI,
|
|
"--query-gpu=utilization.gpu,memory.used,memory.total,name",
|
|
"--format=csv,noheader,nounits",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
|
|
line = out.decode().strip().splitlines()[0]
|
|
util, used, total, name = (p.strip() for p in line.split(","))
|
|
value = {
|
|
"name": name,
|
|
"util_pct": float(util),
|
|
"vram_used_mb": float(used),
|
|
"vram_total_mb": float(total),
|
|
}
|
|
except Exception:
|
|
value = None
|
|
_gpu_cache.update(at=time.monotonic(), value=value)
|
|
return value
|
|
|
|
|
|
@router.get("/stats")
|
|
async def stats() -> dict:
|
|
"""CPU %, RAM et GPU/VRAM courants."""
|
|
cpu_pct = psutil.cpu_percent(interval=None)
|
|
mem = psutil.virtual_memory()
|
|
gpu = await _gpu_stats()
|
|
return {
|
|
"cpu_pct": cpu_pct,
|
|
"ram_used_go": round(mem.used / 1_000_000_000, 1),
|
|
"ram_total_go": round(mem.total / 1_000_000_000, 1),
|
|
"ram_pct": mem.percent,
|
|
"gpu": gpu,
|
|
}
|
|
|
|
|
|
# Battement groupé : absorbe les rafales (plusieurs onglets, reconnexions)
|
|
# sans multiplier les sous-processus ni les connexions vers Ollama.
|
|
_PULSE_TTL = 2.5
|
|
_pulse_cache: dict = {"at": 0.0, "value": None}
|
|
|
|
|
|
async def _loaded_models() -> list[dict]:
|
|
"""Modèles chargés en mémoire + placement GPU (vide si Ollama muet)."""
|
|
try:
|
|
loaded = await ollama.ps()
|
|
except (httpx.HTTPError, OSError):
|
|
return []
|
|
out = []
|
|
for m in loaded:
|
|
size = m.get("size", 0) or 0
|
|
vram = m.get("size_vram", 0) or 0
|
|
out.append({
|
|
"name": m.get("name") or m.get("model"),
|
|
"on_gpu": bool(size and vram >= size * 0.99),
|
|
"gpu_percent": int(vram / size * 100) if size else 0,
|
|
})
|
|
return out
|
|
|
|
|
|
@router.get("/pulse")
|
|
async def pulse() -> dict:
|
|
"""Battement unique : statut Ollama + ressources + modèles chargés.
|
|
|
|
Remplace trois sondages séparés (statut, stats, modèles chargés) par UNE
|
|
requête : moins de réveils, moins de sous-processus et moins de connexions
|
|
vers Ollama quand l'application est simplement ouverte sans être utilisée.
|
|
"""
|
|
if time.monotonic() - _pulse_cache["at"] < _PULSE_TTL and _pulse_cache["value"]:
|
|
return _pulse_cache["value"]
|
|
|
|
# Les deux appels Ollama partent ensemble : latence = le plus lent, pas la
|
|
# somme (important quand Ollama est sur une autre machine).
|
|
version, loaded = await asyncio.gather(
|
|
ollama.ping(), _loaded_models(), return_exceptions=True
|
|
)
|
|
connected = not isinstance(version, BaseException)
|
|
|
|
mem = psutil.virtual_memory()
|
|
value = {
|
|
"status": {
|
|
"connected": connected,
|
|
"host": ollama.host,
|
|
"version": version.get("version") if connected else None,
|
|
"default_model": settings.default_model,
|
|
**({} if connected else {"error": str(version)[:200]}),
|
|
},
|
|
"stats": {
|
|
"cpu_pct": psutil.cpu_percent(interval=None),
|
|
"ram_used_go": round(mem.used / 1_000_000_000, 1),
|
|
"ram_total_go": round(mem.total / 1_000_000_000, 1),
|
|
"ram_pct": mem.percent,
|
|
"gpu": await _gpu_stats(),
|
|
},
|
|
"loaded": [] if isinstance(loaded, BaseException) else loaded,
|
|
}
|
|
_pulse_cache.update(at=time.monotonic(), value=value)
|
|
return value
|
|
|
|
|
|
async def _all_local_gpus() -> list[dict]:
|
|
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide).
|
|
|
|
Mis en cache : le panneau Matériel se rafraîchit en continu tant qu'il est
|
|
ouvert, et chaque appel lançait un sous-processus nvidia-smi.
|
|
"""
|
|
if not _NVIDIA_SMI:
|
|
return []
|
|
if time.monotonic() - _hw_cache["at"] < _HW_CACHE_TTL:
|
|
return _hw_cache["value"] or []
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
_NVIDIA_SMI,
|
|
"--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
|
|
"--format=csv,noheader,nounits",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
|
|
gpus = []
|
|
for line in out.decode().strip().splitlines():
|
|
idx, name, total, used, util = (p.strip() for p in line.split(","))
|
|
gpus.append({
|
|
"index": int(idx), "name": name,
|
|
"vram_total_mb": float(total), "vram_used_mb": float(used),
|
|
"util_pct": float(util),
|
|
})
|
|
_hw_cache.update(at=time.monotonic(), value=gpus)
|
|
return gpus
|
|
except Exception:
|
|
_hw_cache.update(at=time.monotonic(), value=[])
|
|
return []
|
|
|
|
|
|
@router.get("/hardware")
|
|
async def hardware() -> dict:
|
|
"""Vue matériel : GPU vu par Loki vs GPU réellement utilisé par Ollama."""
|
|
# 1) Ce que voit le conteneur Loki (nvidia-smi) + override éventuel.
|
|
local_gpus = await _all_local_gpus()
|
|
override = None
|
|
if settings.gpu_vram_mb > 0:
|
|
override = {
|
|
"name": settings.gpu_name or "GPU déclaré (GPU_VRAM_MB)",
|
|
"vram_total_mb": settings.gpu_vram_mb,
|
|
}
|
|
|
|
# 2) Ce qu'Ollama utilise réellement (modèles chargés + placement VRAM/CPU).
|
|
ollama_info: dict = {"host": ollama.host, "connected": False, "running": []}
|
|
try:
|
|
version = await ollama.ping()
|
|
ollama_info["connected"] = True
|
|
ollama_info["version"] = version.get("version")
|
|
except (httpx.HTTPError, OSError) as exc:
|
|
ollama_info["error"] = str(exc)[:200]
|
|
|
|
if ollama_info["connected"]:
|
|
try:
|
|
for m in await ollama.ps():
|
|
size = m.get("size", 0) or 0
|
|
vram = m.get("size_vram", 0) or 0
|
|
if size <= 0:
|
|
where = "inconnu"
|
|
elif vram >= size * 0.99:
|
|
where = "GPU"
|
|
elif vram <= size * 0.01:
|
|
where = "CPU"
|
|
else:
|
|
where = "mixte"
|
|
ollama_info["running"].append({
|
|
"name": m.get("name") or m.get("model"),
|
|
"processor": where,
|
|
"gpu_percent": int(vram / size * 100) if size else 0,
|
|
"size_mb": round(size / 1_000_000),
|
|
"vram_mb": round(vram / 1_000_000),
|
|
})
|
|
except (httpx.HTTPError, OSError):
|
|
pass
|
|
|
|
# Ollama est-il sur la même machine que Loki ? (heuristique sur l'hôte)
|
|
host = ollama.host.lower()
|
|
is_local = any(h in host for h in ("localhost", "127.0.0.1", "host.docker.internal"))
|
|
|
|
return {
|
|
"loki_gpus": local_gpus,
|
|
"gpu_override": override,
|
|
"ollama": ollama_info,
|
|
"ollama_is_local": is_local,
|
|
"note": (
|
|
"Loki ne voit pas directement le GPU d'un Ollama distant : il déduit "
|
|
"le placement (GPU/CPU) depuis les modèles chargés. Déclare GPU_VRAM_MB "
|
|
"pour l'auto-réglage si Ollama tourne sur une autre machine."
|
|
),
|
|
}
|