diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index deb7adf..beb31c1 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -7942,9 +7942,17 @@ async function send(){
fail('échec de l\'envoi — réessaie');
}
loadAll();
-setInterval(loadStatus, 5000);
-setInterval(loadVram, 3000);
-setInterval(loadRam, 3000);
+// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
+// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
+// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de
+// l'onglet, une lecture immédiate remet tout à jour.
+const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); };
+setInterval(pollVisible(loadStatus), 5000);
+setInterval(pollVisible(loadVram), 3000);
+setInterval(pollVisible(loadRam), 3000);
+document.addEventListener('visibilitychange', ()=>{
+ if(!document.hidden){ loadStatus(); loadVram(); loadRam(); }
+});
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// « llama.cpp précompilé » = binaires officiels, aucune compilation
// « llama.cpp compilé » = compilé ici, pour cette machine
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index 99d82bd..d4bb0cd 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -770,6 +770,14 @@ async function send(){
fail('échec de l\'envoi — réessaie');
}
loadAll();
-setInterval(loadStatus, 5000);
-setInterval(loadVram, 3000);
-setInterval(loadRam, 3000);
+// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
+// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
+// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de
+// l'onglet, une lecture immédiate remet tout à jour.
+const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); };
+setInterval(pollVisible(loadStatus), 5000);
+setInterval(pollVisible(loadVram), 3000);
+setInterval(pollVisible(loadRam), 3000);
+document.addEventListener('visibilitychange', ()=>{
+ if(!document.hidden){ loadStatus(); loadVram(); loadRam(); }
+});
diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go
index 8255b64..05ebb28 100644
--- a/internal/loki/web_api.go
+++ b/internal/loki/web_api.go
@@ -189,10 +189,11 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) {
// handleVram expose l'état des cartes à l'interface (jauges du moniteur).
// La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement
-// de la VRAM qui s'en sert pour chiffrer la mémoire rendue.
+// de la VRAM qui s'en sert pour chiffrer la mémoire rendue. Lecture partagée
+// (gpuStatsCached) : tous les onglets se contentent d'un seul nvidia-smi.
func handleVram(w http.ResponseWriter, r *http.Request) {
gpus := []map[string]any{}
- for _, g := range gpuStats() {
+ for _, g := range gpuStatsCached() {
gpus = append(gpus, map[string]any{
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp,
})
diff --git a/internal/loki/web_vram.go b/internal/loki/web_vram.go
index 2455faa..41d42ba 100644
--- a/internal/loki/web_vram.go
+++ b/internal/loki/web_vram.go
@@ -14,11 +14,13 @@ package loki
import (
"encoding/json"
+ "errors"
"net/http"
"os/exec"
"regexp"
"strconv"
"strings"
+ "sync"
"time"
)
@@ -32,15 +34,27 @@ type gpuStat struct {
Temp int
}
-// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans
-// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle
-// prive juste le déchargement de son bilan chiffré.
-func gpuStats() []gpuStat {
- out, err := hideCmd(exec.Command("nvidia-smi",
+// nvidiaSmiQuery lance la requête nvidia-smi. Variable pour que les tests
+// substituent un faux exécutable et comptent les lancements.
+var nvidiaSmiQuery = func() ([]byte, error) {
+ return hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits")).Output()
+}
+
+// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans
+// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle
+// prive juste le déchargement de son bilan chiffré. Toujours une lecture
+// FRAÎCHE : le bilan du déchargement (gpuSettle) en dépend, voir gpuStatsCached.
+func gpuStats() []gpuStat {
+ gpus, _ := gpuStatsErr()
+ return gpus
+}
+
+func gpuStatsErr() ([]gpuStat, error) {
+ out, err := nvidiaSmiQuery()
if err != nil {
- return nil
+ return nil, err
}
var gpus []gpuStat
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
@@ -58,7 +72,65 @@ func gpuStats() []gpuStat {
g.Temp, _ = strconv.Atoi(parts[4])
gpus = append(gpus, g)
}
- return gpus
+ return gpus, nil
+}
+
+// gpuCache : la lecture des jauges, partagée. Chaque onglet ouvert (et chaque
+// téléphone) interroge /api/vram toutes les 3 s, et chacun lançait SON
+// nvidia-smi — un processus qui réveille le pilote et vole quelques
+// millisecondes de CPU aux threads de llama-server pendant une génération en
+// partie sur CPU. Une lecture vieille de deux secondes suffit largement à une
+// jauge ; le verrou tenu pendant la requête fait que des appels simultanés
+// attendent la même lecture au lieu d'en lancer chacun une.
+//
+// Un nvidia-smi INTROUVABLE (Mac, CPU seul) est mémorisé une minute : inutile de
+// rechercher le binaire dans le PATH toutes les deux secondes. Une autre erreur
+// (carte en réinitialisation) se retente au bout du délai normal.
+type gpuCache struct {
+ mu sync.Mutex
+ exp time.Time
+ gpus []gpuStat
+}
+
+const (
+ gpuCacheTTL = 2 * time.Second
+ gpuCacheAbsent = time.Minute
+)
+
+// get : now est l'horloge (time.Now hors tests).
+func (c *gpuCache) get(now func() time.Time, query func() ([]gpuStat, error)) []gpuStat {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if now().Before(c.exp) {
+ return c.gpus
+ }
+ gpus, err := query()
+ ttl := gpuCacheTTL
+ if errors.Is(err, exec.ErrNotFound) {
+ ttl = gpuCacheAbsent
+ }
+ // L'échéance part de la FIN de la requête : un nvidia-smi lent ne doit pas
+ // laisser une lecture déjà vieille passer pour neuve.
+ c.gpus, c.exp = gpus, now().Add(ttl)
+ return c.gpus
+}
+
+// invalidate : la prochaine lecture repart de nvidia-smi.
+func (c *gpuCache) invalidate() {
+ c.mu.Lock()
+ c.exp = time.Time{}
+ c.mu.Unlock()
+}
+
+var gpuMonitor gpuCache
+
+// gpuStatsCached : la lecture des jauges du moniteur (handleVram), SEULEMENT.
+// Le déchargement garde gpuStats : gpuSettle conclut sur deux lectures égales
+// de suite, et une valeur servie deux fois par le cache l'y ferait conclure à
+// tort — exactement le faux bilan qu'il est écrit pour éviter. La tranche
+// rendue est partagée : lecture seule.
+func gpuStatsCached() []gpuStat {
+ return gpuMonitor.get(time.Now, gpuStatsErr)
}
// gpuUsedMB : VRAM occupée, toutes cartes confondues.
@@ -113,10 +185,13 @@ func gpuSettle(before int, sample func() (int, bool), pause time.Duration) int {
}
func gpuUsedSettled(before int) int {
- return gpuSettle(before, func() (int, bool) {
- g := gpuStats()
- return gpuUsedMB(g), g != nil
- }, 500*time.Millisecond)
+ return gpuSettle(before, gpuSettleSample, 500*time.Millisecond)
+}
+
+// gpuSettleSample : une lecture fraîche pour gpuSettle — jamais le cache.
+func gpuSettleSample() (int, bool) {
+ g := gpuStats()
+ return gpuUsedMB(g), g != nil
}
// engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous
@@ -204,6 +279,9 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
if gpus != nil {
after = gpuUsedSettled(before)
}
+ // Les jauges ne doivent pas montrer, deux secondes encore, une carte pleine
+ // qu'on vient d'annoncer libérée.
+ gpuMonitor.invalidate()
freed := before - after
if freed < 0 {
freed = 0
diff --git a/internal/loki/web_vram_test.go b/internal/loki/web_vram_test.go
index 50cf614..c211cd7 100644
--- a/internal/loki/web_vram_test.go
+++ b/internal/loki/web_vram_test.go
@@ -1,10 +1,16 @@
package loki
import (
+ "errors"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os/exec"
"strings"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
)
// gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas
@@ -70,3 +76,97 @@ func TestVramRoutesRefusentGET(t *testing.T) {
}
}
}
+
+// gpuCache : les jauges de tous les onglets se partagent une lecture par
+// fenêtre de deux secondes, même quand les requêtes arrivent en même temps.
+func TestGpuCache(t *testing.T) {
+ type step struct {
+ at time.Duration // horloge depuis le départ
+ err error // erreur rendue si la requête part
+ calls int32 // requêtes cumulées attendues après l'appel
+ }
+ absent := &exec.Error{Name: "nvidia-smi", Err: exec.ErrNotFound}
+ for _, c := range []struct {
+ name string
+ steps []step
+ }{
+ {"lecture réutilisée puis rafraîchie", []step{
+ {0, nil, 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
+ }},
+ // Carte en réinitialisation : on retente au délai normal, pas plus tard.
+ {"erreur retentée au délai normal", []step{
+ {0, errors.New("exit status 9"), 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
+ }},
+ // Pas de nvidia-smi du tout : une recherche par minute suffit.
+ {"binaire absent mémorisé", []step{
+ {0, absent, 1}, {30 * time.Second, absent, 1}, {gpuCacheAbsent, absent, 2},
+ }},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ var cache gpuCache
+ var calls int32
+ t0 := time.Unix(1000, 0)
+ for i, st := range c.steps {
+ now := func() time.Time { return t0.Add(st.at) }
+ cache.get(now, func() ([]gpuStat, error) {
+ atomic.AddInt32(&calls, 1)
+ if st.err != nil {
+ return nil, st.err
+ }
+ return []gpuStat{{Name: "fake", Used: 1000}}, nil
+ })
+ if got := atomic.LoadInt32(&calls); got != st.calls {
+ t.Fatalf("étape %d : %d requêtes, attendu %d", i, got, st.calls)
+ }
+ }
+ })
+ }
+
+ t.Run("appels simultanés", func(t *testing.T) {
+ var cache gpuCache
+ var calls int32
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ g := cache.get(time.Now, func() ([]gpuStat, error) {
+ atomic.AddInt32(&calls, 1)
+ time.Sleep(20 * time.Millisecond) // nvidia-smi n'est pas instantané
+ return []gpuStat{{Name: "fake", Used: 1000}}, nil
+ })
+ if len(g) != 1 || g[0].Used != 1000 {
+ t.Errorf("lecture partagée %+v", g)
+ }
+ }()
+ }
+ wg.Wait()
+ if calls != 1 {
+ t.Errorf("%d nvidia-smi lancés, attendu 1", calls)
+ }
+ })
+}
+
+// Le bilan du déchargement doit voir la mémoire PARTIR : son échantillonneur
+// ne passe jamais par le cache des jauges, même tout juste rempli.
+func TestGpuSettleSampleFrais(t *testing.T) {
+ orig := nvidiaSmiQuery
+ t.Cleanup(func() { nvidiaSmiQuery = orig; gpuMonitor = gpuCache{} })
+ var calls int32
+ nvidiaSmiQuery = func() ([]byte, error) {
+ n := atomic.AddInt32(&calls, 1)
+ return []byte(fmt.Sprintf("Fake GPU, %d, 24000, 0, 40\n", 20000-int(n)*1000)), nil
+ }
+ gpuMonitor = gpuCache{}
+ if g := gpuStatsCached(); len(g) != 1 || g[0].Used != 19000 {
+ t.Fatalf("jauges : %+v", g)
+ }
+ for want := 18000; want >= 17000; want -= 1000 {
+ if used, ok := gpuSettleSample(); !ok || used != want {
+ t.Errorf("échantillon = %d (%v), attendu %d frais", used, ok, want)
+ }
+ }
+ if g := gpuStatsCached(); g[0].Used != 19000 {
+ t.Errorf("jauges : %d, attendu la lecture en cache 19000", g[0].Used)
+ }
+}