diff --git a/backend/app/main.py b/backend/app/main.py index 010d803..20a97ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from . import db from .config import settings -from .routes import chat, config, files, models, sessions, shell +from .routes import chat, config, files, models, sessions, shell, system @asynccontextmanager @@ -42,6 +42,7 @@ app.include_router(chat.router) app.include_router(files.router) app.include_router(config.router) app.include_router(shell.router) +app.include_router(system.router) @app.get("/api/health") diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py new file mode 100644 index 0000000..24153cf --- /dev/null +++ b/backend/app/routes/system.py @@ -0,0 +1,52 @@ +"""Statistiques système temps réel : CPU, RAM, GPU/VRAM (barre supérieure).""" +from __future__ import annotations + +import asyncio +import shutil + +import psutil +from fastapi import APIRouter + +router = APIRouter(prefix="/api/system", tags=["system"]) + +_NVIDIA_SMI = shutil.which("nvidia-smi") + + +async def _gpu_stats() -> dict | None: + """Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).""" + if not _NVIDIA_SMI: + return None + 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(",")) + return { + "name": name, + "util_pct": float(util), + "vram_used_mb": float(used), + "vram_total_mb": float(total), + } + except Exception: + return None + + +@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, + } diff --git a/backend/requirements.txt b/backend/requirements.txt index 90a4b18..f2980fe 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0 httpx==0.28.1 pydantic==2.10.4 pydantic-settings==2.7.1 +psutil==6.1.1 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index b34fc64..c5a5555 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,6 +1,6 @@ + - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e795dd4..5c4a233 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,16 +10,22 @@ import { useStore } from "./store/useStore"; export default function App() { const [view, setView] = useState("chat"); - const { refreshStatus, refreshModels, refreshConfig } = useStore(); + const { refreshStatus, refreshSystemStats, refreshModels, refreshConfig } = + useStore(); - // Au démarrage : statut Ollama + modèles + config. Poll du statut. + // Au démarrage : statut Ollama + modèles + config. Poll du statut et des stats système. useEffect(() => { refreshStatus(); + refreshSystemStats(); refreshModels(); refreshConfig(); - const id = setInterval(refreshStatus, 10000); - return () => clearInterval(id); - }, [refreshStatus, refreshModels, refreshConfig]); + const statusId = setInterval(refreshStatus, 10000); + const statsId = setInterval(refreshSystemStats, 2000); + return () => { + clearInterval(statusId); + clearInterval(statsId); + }; + }, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig]); return (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b262d9e..7ff3759 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,26 @@ export async function getStatus(): Promise { return res.json(); } +export interface GpuStats { + name: string; + util_pct: number; + vram_used_mb: number; + vram_total_mb: number; +} + +export interface SystemStats { + cpu_pct: number; + ram_used_go: number; + ram_total_go: number; + ram_pct: number; + gpu: GpuStats | null; +} + +export async function getSystemStats(): Promise { + const res = await fetch("/api/system/stats"); + return res.json(); +} + export async function getModels(): Promise<{ models: OllamaModel[]; default: string; diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 3830b69..80b59eb 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -5,6 +5,7 @@ import { ModelSelector } from "./ModelSelector"; /** Barre supérieure sombre : logo, fil d'Ariane, statut Ollama, sélecteur. */ export function TopBar() { const status = useStore((s) => s.status); + const stats = useStore((s) => s.systemStats); const connected = status?.connected ?? false; return ( @@ -24,6 +25,25 @@ export function TopBar() {
+ {/* Stats système temps réel : CPU, RAM, GPU/VRAM */} + {stats && ( +
+ CPU {stats.cpu_pct.toFixed(0)}% + · + RAM {stats.ram_pct.toFixed(0)}% + {stats.gpu && ( + <> + · + GPU {stats.gpu.util_pct.toFixed(0)}% + + VRAM {(stats.gpu.vram_used_mb / 1024).toFixed(1)}/ + {(stats.gpu.vram_total_mb / 1024).toFixed(1)}G + + + )} +
+ )} + {/* Statut Ollama */}
Promise; setSelectedModel: (name: string) => void; refreshStatus: () => Promise; + refreshSystemStats: () => Promise; refreshModels: () => Promise; refreshFiles: () => Promise; @@ -69,6 +73,7 @@ let activeStreamController: AbortController | null = null; export const useStore = create((set, get) => ({ status: null, + systemStats: null, models: [], selectedModel: "", loadingModels: false, @@ -154,6 +159,14 @@ export const useStore = create((set, get) => ({ } }, + refreshSystemStats: async () => { + try { + set({ systemStats: await getSystemStats() }); + } catch { + set({ systemStats: null }); + } + }, + refreshModels: async () => { set({ loadingModels: true }); try {