From 9077bd2422ea30cf64cf64cdac5b078442ed595e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 05:40:22 +0000 Subject: [PATCH] =?UTF-8?q?Frugalit=C3=A9=20:=20supprime=20le=20CPU=20cons?= =?UTF-8?q?omm=C3=A9=20=C3=A0=20vide=20(sondage=20group=C3=A9=20et=20adapt?= =?UTF-8?q?atif)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À 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 Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N --- backend/app/routes/system.py | 80 +++++++++++++++++++++++++++- frontend/src/App.tsx | 59 ++++++++++++-------- frontend/src/api/client.ts | 13 +++++ frontend/src/panels/SettingsView.tsx | 6 ++- frontend/src/store/useStore.ts | 17 ++++++ 5 files changed, 150 insertions(+), 25 deletions(-) diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py index 4b566b0..e932b75 100644 --- a/backend/app/routes/system.py +++ b/backend/app/routes/system.py @@ -18,8 +18,12 @@ _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. -_GPU_CACHE_TTL = 5.0 +# 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: @@ -69,10 +73,80 @@ async def stats() -> dict: } +# 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).""" + """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, @@ -90,8 +164,10 @@ async def _all_local_gpus() -> list[dict]: "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 [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b51d79b..eba6e00 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,33 +10,48 @@ import { useStore } from "./store/useStore"; export default function App() { const [view, setView] = useState("chat"); - const { - refreshStatus, - refreshSystemStats, - refreshModels, - refreshLoadedModels, - refreshSessions, - } = useStore(); + const { refreshPulse, refreshModels, refreshSessions } = useStore(); - // Au démarrage : statut Ollama + modèles + sessions. La config est chargée - // par setSelectedModel (via refreshModels) — pas de double fetch. Poll du - // statut, des stats système (5 s, apparié au cache GPU serveur) et des - // modèles chargés en VRAM (indicateur de préchargement). + // Sondage frugal. Trois principes, contre l'app qui chauffait le CPU à vide : + // - UNE seule requête groupée (/api/system/pulse) au lieu de trois ; + // - RIEN quand l'onglet est caché (l'utilisateur ne regarde pas) ; + // - cadence adaptative : réactif pendant le travail, lent au repos. + // Le serveur, lui, ne tourne jamais en boucle : sans navigateur, zéro CPU. useEffect(() => { - refreshStatus(); - refreshSystemStats(); + refreshPulse(); refreshModels(); - refreshLoadedModels(); refreshSessions(); - const statusId = setInterval(refreshStatus, 10000); - const statsId = setInterval(refreshSystemStats, 5000); - const loadedId = setInterval(refreshLoadedModels, 8000); - return () => { - clearInterval(statusId); - clearInterval(statsId); - clearInterval(loadedId); + + let timer: number | undefined; + const tick = () => { + // Onglet caché : on ne sonde pas du tout, on repassera au retour. + if (document.hidden) return schedule(); + refreshPulse().finally(schedule); }; - }, [refreshStatus, refreshSystemStats, refreshModels, refreshLoadedModels, refreshSessions]); + const schedule = () => { + if (document.hidden) { + timer = window.setTimeout(schedule, 30000); + return; + } + // Pendant un flux, les indicateurs (VRAM, GPU) doivent suivre. + const busy = useStore.getState().streaming; + timer = window.setTimeout(tick, busy ? 4000 : 20000); + }; + schedule(); + + // Retour sur l'onglet : rafraîchit tout de suite plutôt que d'attendre. + const onVisible = () => { + if (!document.hidden) { + window.clearTimeout(timer); + tick(); + } + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [refreshPulse, refreshModels, refreshSessions]); return (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f874458..ec02c3a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -41,6 +41,19 @@ export async function getSystemStats(): Promise { return res.json(); } +/** Battement groupé : statut + ressources + modèles chargés en UNE requête. */ +export interface Pulse { + status: OllamaStatus; + stats: SystemStats; + loaded: LoadedModel[]; +} + +export async function getPulse(): Promise { + const res = await fetch("/api/system/pulse"); + if (!res.ok) throw new Error(`pulse ${res.status}`); + return res.json(); +} + // ── Git du workspace ───────────────────────────────────────────────────── export interface GitCommit { hash: string; diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index 9c41443..5967968 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -627,9 +627,13 @@ function HardwareCard() { const [hw, setHw] = useState(null); const refresh = () => getHardware().then(setHw).catch(() => {}); + // 15 s et rien quand l'onglet est caché : chaque appel lance nvidia-smi côté + // serveur. Le bouton ↻ reste là pour un rafraîchissement immédiat. useEffect(() => { refresh(); - const id = setInterval(refresh, 5000); + const id = setInterval(() => { + if (!document.hidden) refresh(); + }, 15000); return () => clearInterval(id); }, []); diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 4e2106b..90048d3 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -11,6 +11,7 @@ import { getStatus, getSystemStats, getLoadedModels, + getPulse, warmModel, fileContent, listFiles, @@ -71,6 +72,7 @@ interface LokiState { refreshStatus: () => Promise; refreshSystemStats: () => Promise; refreshLoadedModels: () => Promise; + refreshPulse: () => Promise; refreshModels: () => Promise; refreshFiles: () => Promise; @@ -261,6 +263,21 @@ export const useStore = create((set, get) => ({ set({ loadedModels: await getLoadedModels() }); }, + // Sondage unique (statut + ressources + modèles chargés). Une seule requête + // au lieu de trois, et une seule mise à jour du store : moins de réveils + // navigateur et moins de rendus React quand l'app est ouverte à vide. + refreshPulse: async () => { + try { + const { status, stats, loaded } = await getPulse(); + set({ status, systemStats: stats, loadedModels: loaded }); + } catch { + set({ + status: { connected: false, host: "", default_model: "" }, + systemStats: null, + }); + } + }, + refreshModels: async () => { set({ loadingModels: true }); try {