diff --git a/README.md b/README.md index 56d231d..c495e31 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,13 @@ utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage (`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information — rien n'est perdu ni altéré. Pour l'éviter : -- **recommandé** — passe le partage `appdata` (et celui des modèles) en - *Exclusive access* (Unraid 6.12+, partage sur un seul pool) : le chemin - `/mnt/user/…` ne change pas, FUSE est court-circuité ; +- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est + pas une case mais un état : *Global Share Settings* → *Permit exclusive + shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement + sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* = + None — jusqu'à lire *Exclusive access : Yes* sur la page du partage. + Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est + court-circuité ; - **avancé** — mappe `/mnt//appdata/loki/…` (ex. `/mnt/cache` si ton pool s'appelle `cache`), avec une migration manuelle : `Compose Down`, `rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index b60a6f9..28ccbb7 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -45,9 +45,14 @@ services: # modèle relue depuis le disque (modèle plus gros que la RAM) traverse un # démon en espace utilisateur. Loki le signale au démarrage (encart # d'information, rien n'est perdu). Deux remèdes, au choix : - # - Recommandé : Shares -> appdata (et le partage des modèles) -> - # « Exclusive access » = Yes (Unraid 6.12+, partage sur un seul - # pool). Le chemin /mnt/user reste le même, FUSE est court-circuité. + # - Recommandé (Unraid 6.12+) : partages en « Exclusive access ». + # Ce n'est pas une case mais un état : Settings -> Global Share + # Settings -> « Permit exclusive shares » = Yes, puis le partage + # appdata (et celui des modèles) entièrement sur UN pool — mover + # d'abord vers le pool, ensuite Secondary storage = None. La page du + # partage affiche alors « Exclusive access : Yes ». Redémarre le + # conteneur : le chemin /mnt/user reste le même, FUSE est + # court-circuité. # - Avancé : mapper /mnt//appdata/loki/… (ex. /mnt/cache si # ton pool s'appelle « cache »). Migration MANUELLE obligatoire : # Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis diff --git a/internal/loki/sys_storagefuse.go b/internal/loki/sys_storagefuse.go index 0930d7c..39108f3 100644 --- a/internal/loki/sys_storagefuse.go +++ b/internal/loki/sys_storagefuse.go @@ -14,8 +14,11 @@ package loki // donnerait un /data vide et ferait croire à une perte de données (voir le // README). Il se contente de le VOIR et de le DIRE, sur un ton d'information — // pas le bandeau rouge de sys_datavolume.go, réservé à la perte de données. Le -// remède sans changement de chemin : passer les partages en « Exclusive -// access » (Unraid 6.12+), qui court-circuite shfs. +// remède sans changement de chemin : des partages en « Exclusive access » +// (Unraid 6.12+). Ce n'est pas une case à cocher mais un état : « Permit +// exclusive shares » activé, partage tout entier sur un pool, sans stockage +// secondaire — /mnt/user/ devient alors un lien symbolique vers le +// pool, que Docker résout au démarrage du conteneur. import ( "bufio" @@ -93,22 +96,30 @@ func unescapeMountinfo(s string) string { // "" quand aucun montage ne couvre le chemin. Chemins Linux : package path, et // non filepath, pour que les tests lisent la même chose sous Windows. func mountFSType(mounts []mountEntry, target string) string { + m, _ := mountOf(mounts, target) + return m.fsType +} + +// mountOf renvoie le montage qui porte target (règle de mountFSType) ; false +// quand aucun ne le couvre. +func mountOf(mounts []mountEntry, target string) (mountEntry, bool) { p := path.Clean(target) - best, fs := -1, "" + best, found := -1, mountEntry{} for _, m := range mounts { mp := m.point if mp != "/" && p != mp && !strings.HasPrefix(p, mp+"/") { continue } if len(mp) >= best { - best, fs = len(mp), m.fsType + best, found = len(mp), m } } - return fs + return found, best >= 0 } -// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs (dédoublonnés, -// dans l'ordre reçu). +// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs, dans l'ordre +// reçu et un seul par montage : /data/models, qui vit sur le montage de /data, +// n'apprendrait rien de plus à l'utilisateur et allongerait le conseil. func shfsPaths(mounts []mountEntry, paths []string) []string { var out []string seen := map[string]bool{} @@ -116,14 +127,12 @@ func shfsPaths(mounts []mountEntry, paths []string) []string { if strings.TrimSpace(p) == "" { continue } - p = path.Clean(p) - if seen[p] { + m, ok := mountOf(mounts, p) + if !ok || m.fsType != "fuse.shfs" || seen[m.point] { continue } - seen[p] = true - if mountFSType(mounts, p) == "fuse.shfs" { - out = append(out, p) - } + seen[m.point] = true + out = append(out, path.Clean(p)) } return out } @@ -137,8 +146,9 @@ func storageHintText(paths []string) string { return "ℹ️ Stockage via la couche FUSE des partages Unraid (fuse.shfs) : " + strings.Join(paths, ", ") + ". " + "Les écritures de la base et les pages du modèle relues depuis le disque (gros modèle qui ne tient pas en RAM) " + "traversent un démon en espace utilisateur, ce qui ralentit le décodage. Rien n'est perdu. " + - "Remède sans changer de chemin : partages appdata/modèles en « Exclusive access » (Unraid 6.12+). " + - "Voir docker-compose.unraid.yml." + "Remède sans changer de chemin (Unraid 6.12+) : Global Share Settings → « Permit exclusive shares » = Yes, " + + "partages appdata/modèles entièrement sur un pool sans stockage secondaire, jusqu'à lire « Exclusive access : Yes », " + + "puis redémarrer le conteneur. Voir docker-compose.unraid.yml." } // storageHint mis en cache : /api/status est interrogé en boucle, et la liste diff --git a/internal/loki/sys_storagefuse_test.go b/internal/loki/sys_storagefuse_test.go index f63bf62..c86c1cd 100644 --- a/internal/loki/sys_storagefuse_test.go +++ b/internal/loki/sys_storagefuse_test.go @@ -64,6 +64,13 @@ func TestShfsPaths(t *testing.T) { if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("shfsPaths = %v, attendu %v", got, want) } + // Un sous-dossier du même montage (LOKI_MODEL_DIRS=/data/gguf) n'est pas + // répété : un seul chemin par montage fuse.shfs. + got = shfsPaths(m, []string{"/data/gguf", "/data", "/mnt/mes modeles/a"}) + want = []string{"/data/gguf", "/mnt/mes modeles/a"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("shfsPaths (même montage) = %v, attendu %v", got, want) + } // Partages en « Exclusive access » : plus de fuse.shfs, plus de conseil. excl := `1 0 0:1 / / rw - overlay overlay rw 2 1 0:2 /appdata/loki/data /data rw - btrfs /dev/nvme0n1p1 rw @@ -79,7 +86,7 @@ func TestStorageHintText(t *testing.T) { t.Fatalf("rien à signaler : %q", s) } s := storageHintText([]string{"/data", "/models"}) - for _, w := range []string{"/data, /models", "fuse.shfs", "Exclusive access"} { + for _, w := range []string{"/data, /models", "fuse.shfs", "Permit exclusive shares", "Exclusive access", "Rien n'est perdu"} { if !strings.Contains(s, w) { t.Errorf("conseil sans %q : %s", w, s) } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 151a818..aeaf199 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2301,7 +2301,9 @@ html[data-files="1"] #files-btn{color:var(--accent)} - + + - + +