From 22a5fed8cc701379156d3d893abf93c2aff977c4 Mon Sep 17 00:00:00 2001 From: enzo Date: Sun, 22 Mar 2026 17:03:42 +0100 Subject: [PATCH] fix: store 502, refresh button, rebuild UX pour modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetRegistryModules: endpoint /api/v1/orgs/{org}/repos + timeout 10s répond toujours HTTP 200 avec {modules, error} au lieu de 502 - InstallRegistryModule: essaie branche master puis main pour module.json - Ajouter FORGEJO_URL / FORGEJO_ORG dans docker-compose.yml - Frontend: bouton rafraîchir store + affichage erreur - Frontend: bannière rebuild en cours + bannière rebuild terminé - Frontend: polling /api/health toutes les 3s après rebuild/restart Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/api/settings.go | 102 +++++++++++++++++++++++-------- docker-compose.yml | 3 + frontend/js/app.js | 59 ++++++++++++++++-- frontend/modules.html | 32 ++++++++-- 4 files changed, 157 insertions(+), 39 deletions(-) diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 6ce2478..2dab5d8 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -2,11 +2,14 @@ package api import ( + "context" "encoding/json" "fmt" "io" "net/http" + "os" "strconv" + "time" "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" @@ -316,13 +319,24 @@ type RegistryModule struct { Installed bool `json:"installed"` } -// GetRegistryModules liste les repos de l'organisation proxmoxPanel sur Forgejo. +// registryResp est la réponse unifiée de /api/registry/modules. +type registryResp struct { + Modules []RegistryModule `json:"modules"` + Error string `json:"error,omitempty"` +} + +// forgejoClient est un client HTTP avec timeout pour l'API Forgejo. +var forgejoClient = &http.Client{Timeout: 10 * time.Second} + +// GetRegistryModules liste les repos de l'organisation sur Forgejo. +// Utilise GET /api/v1/orgs/{org}/repos (retourne un tableau JSON direct). +// Répond toujours 200 — l'erreur éventuelle est dans le champ "error". // GET /api/registry/modules func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) { // Récupérer les modules déjà installés en DB rows, err := h.db.Query(`SELECT id FROM modules`) if err != nil { - JSONError(w, "Erreur lecture modules", http.StatusInternalServerError) + JSONResponse(w, http.StatusOK, registryResp{Modules: []RegistryModule{}, Error: "Erreur lecture DB : " + err.Error()}) return } installed := make(map[string]bool) @@ -333,37 +347,53 @@ func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Requ } rows.Close() - // Appel à l'API Forgejo - resp, err := http.Get("https://git.geronzi.fr/api/v1/repos/search?q=&owner=proxmoxPanel&limit=50") + // Base URL et organisation configurables via variables d'environnement + forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr") + forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel") + + // Appel à l'API Forgejo : /api/v1/orgs/{org}/repos retourne un tableau JSON direct + apiURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", forgejoURL, forgejoOrg) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + resp, err := forgejoClient.Do(req) if err != nil { - JSONError(w, fmt.Sprintf("Erreur accès store : %v", err), http.StatusBadGateway) + JSONResponse(w, http.StatusOK, registryResp{ + Modules: []RegistryModule{}, + Error: fmt.Sprintf("Impossible de joindre le store (%s) : %v", forgejoURL, err), + }) return } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - JSONError(w, "Erreur lecture réponse store", http.StatusBadGateway) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + JSONResponse(w, http.StatusOK, registryResp{ + Modules: []RegistryModule{}, + Error: fmt.Sprintf("Store a répondu HTTP %d : %s", resp.StatusCode, string(body)), + }) return } - // Structure de réponse Gitea/Forgejo - var forgejoResp struct { - Data []struct { - Name string `json:"name"` - FullName string `json:"full_name"` - Description string `json:"description"` - HTMLURL string `json:"html_url"` - } `json:"data"` + body, _ := io.ReadAll(resp.Body) + + // /api/v1/orgs/{org}/repos retourne directement un tableau []repo + var repos []struct { + Name string `json:"name"` + Description string `json:"description"` + HTMLURL string `json:"html_url"` } - if err := json.Unmarshal(body, &forgejoResp); err != nil { - JSONError(w, "Erreur parsing réponse store", http.StatusBadGateway) + if err := json.Unmarshal(body, &repos); err != nil { + JSONResponse(w, http.StatusOK, registryResp{ + Modules: []RegistryModule{}, + Error: fmt.Sprintf("Réponse store invalide : %v", err), + }) return } - var modules []RegistryModule - for _, repo := range forgejoResp.Data { - // Exclure le repo "core" + modules := make([]RegistryModule, 0, len(repos)) + for _, repo := range repos { if repo.Name == "core" { continue } @@ -376,10 +406,15 @@ func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Requ }) } - if modules == nil { - modules = []RegistryModule{} + JSONResponse(w, http.StatusOK, registryResp{Modules: modules}) +} + +// envOr retourne la valeur de la variable d'environnement ou la valeur par défaut. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v } - JSONResponse(w, http.StatusOK, modules) + return def } // moduleJSON représente le fichier module.json d'un module. @@ -403,8 +438,21 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R id := chi.URLParam(r, "id") // Récupérer module.json depuis Forgejo - url := fmt.Sprintf("https://git.geronzi.fr/api/v1/repos/proxmoxPanel/%s/raw/module.json?ref=main", id) - resp, err := http.Get(url) + forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr") + forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel") + // Essayer d'abord la branche master puis main + moduleJSONURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=master", forgejoURL, forgejoOrg, id) + reqCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil) + resp, err := forgejoClient.Do(req) + if err == nil && resp.StatusCode == http.StatusNotFound { + // Retenter sur la branche main + resp.Body.Close() + moduleJSONURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=main", forgejoURL, forgejoOrg, id) + req2, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil) + resp, err = forgejoClient.Do(req2) + } if err != nil { JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway) return @@ -441,7 +489,7 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R } // URL du repo - repoURL := fmt.Sprintf("https://git.geronzi.fr/proxmoxPanel/%s", id) + repoURL := fmt.Sprintf("%s/%s/%s", envOr("FORGEJO_URL", "https://git.geronzi.fr"), envOr("FORGEJO_ORG", "proxmoxPanel"), id) hasBackend := 0 if mod.HasBackend { diff --git a/docker-compose.yml b/docker-compose.yml index 02275c2..cec5a0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: - GIT_BRANCH=frontend/alpine # Tag de l'image Docker construite - IMAGE_TAG=proxmoxpanel-backend:latest + # Registry Forgejo — surcharger si auto-hébergé ailleurs + - FORGEJO_URL=https://git.geronzi.fr + - FORGEJO_ORG=proxmoxPanel # Pas de réseau host — le container reste isolé # Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker networks: diff --git a/frontend/js/app.js b/frontend/js/app.js index 9ae35c1..ca82bb0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1080,13 +1080,20 @@ document.addEventListener('alpine:init', () => { toggling: {}, storeModules: [], storeLoading: true, + storeError: '', installing: {}, - rebuildRequired: false, + rebuilding: false, + rebuildDone: false, + _pollTimer: null, async init() { await Promise.all([this.load(), this.loadStore()]) }, + destroy() { + if (this._pollTimer) clearInterval(this._pollTimer) + }, + async load() { this.loading = true try { @@ -1099,10 +1106,20 @@ document.addEventListener('alpine:init', () => { async loadStore() { this.storeLoading = true + this.storeError = '' try { const res = await apiFetch('/api/registry/modules') - if (res.ok) this.storeModules = await res.json() || [] + if (res.ok) { + // L'API retourne { modules: [...], error: "..." } + const data = await res.json() + this.storeModules = data.modules || [] + if (data.error) this.storeError = data.error + } else { + this.storeError = `Erreur serveur (HTTP ${res.status})` + this.storeModules = [] + } } catch(e) { + this.storeError = 'Impossible de joindre le store : ' + e.message this.storeModules = [] } finally { this.storeLoading = false @@ -1115,12 +1132,17 @@ document.addEventListener('alpine:init', () => { const action = mod.is_enabled ? 'disable' : 'enable' const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' }) if (res.ok) { + const data = await res.json().catch(() => ({})) mod.is_enabled = !mod.is_enabled + if (data.restarting) { + Alpine.store('toasts').info('Redémarrage du container en cours…') + this._startRebuildPoll() + } // Rafraîchir la sidebar const sb = document.querySelector('[x-data="sidebar()"]') - if (sb && sb._x_dataStack) { + if (sb) { const sidebarData = Alpine.$data(sb) - if (sidebarData && sidebarData.refreshNav) await sidebarData.refreshNav() + if (sidebarData?.refreshNav) await sidebarData.refreshNav() } } } catch(e) { @@ -1135,9 +1157,15 @@ document.addEventListener('alpine:init', () => { try { const res = await apiFetch(`/api/registry/modules/${mod.id}/install`, { method: 'POST' }) if (res.ok) { + const data = await res.json().catch(() => ({})) mod.installed = true - this.rebuildRequired = true - Alpine.store('toasts').success(`Module ${mod.id} installé — rebuild requis`) + if (data.rebuilding) { + this.rebuilding = true + Alpine.store('toasts').info(`Module ${mod.id} installé — rebuild en cours (~2 min)`) + this._startRebuildPoll() + } else { + Alpine.store('toasts').success(`Module ${mod.id} installé`) + } await this.load() } else { const b = await res.json().catch(() => ({})) @@ -1150,6 +1178,25 @@ document.addEventListener('alpine:init', () => { } }, + // Poll /api/health toutes les 3s pour détecter le retour du container après rebuild/restart. + _startRebuildPoll() { + if (this._pollTimer) return + // Attendre 5s avant de commencer à poller (le container est encore en train de s'arrêter) + setTimeout(() => { + this._pollTimer = setInterval(async () => { + try { + const res = await fetch('/api/health') + if (res.ok) { + clearInterval(this._pollTimer) + this._pollTimer = null + this.rebuilding = false + this.rebuildDone = true + } + } catch(_) { /* container encore hors ligne, on attend */ } + }, 3000) + }, 5000) + }, + t(key) { return Alpine.store('i18n').t(key) }, })) diff --git a/frontend/modules.html b/frontend/modules.html index 12696bf..cbcd080 100644 --- a/frontend/modules.html +++ b/frontend/modules.html @@ -90,9 +90,21 @@ -

Store

+
+

Store

+ +

Modules disponibles depuis git.geronzi.fr/proxmoxPanel

+ +
+ + +
+
Chargement du store…
@@ -121,15 +133,23 @@ -

+

Aucun module disponible dans le store

- -
- - Un ou plusieurs modules ont été installés. Un rebuild du container est nécessaire pour les activer. + +
+
+ Rebuild du container en cours (~1-2 min) — l'interface redémarrera automatiquement. +
+ + +
+ + Rebuild terminé.