fix: store 502, refresh button, rebuild UX pour modules
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
a61f805cd0
commit
22a5fed8cc
4 changed files with 157 additions and 39 deletions
|
|
@ -2,11 +2,14 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||||
|
|
@ -316,13 +319,24 @@ type RegistryModule struct {
|
||||||
Installed bool `json:"installed"`
|
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
|
// GET /api/registry/modules
|
||||||
func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) {
|
func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) {
|
||||||
// Récupérer les modules déjà installés en DB
|
// Récupérer les modules déjà installés en DB
|
||||||
rows, err := h.db.Query(`SELECT id FROM modules`)
|
rows, err := h.db.Query(`SELECT id FROM modules`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
|
JSONResponse(w, http.StatusOK, registryResp{Modules: []RegistryModule{}, Error: "Erreur lecture DB : " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
installed := make(map[string]bool)
|
installed := make(map[string]bool)
|
||||||
|
|
@ -333,37 +347,53 @@ func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
// Appel à l'API Forgejo
|
// Base URL et organisation configurables via variables d'environnement
|
||||||
resp, err := http.Get("https://git.geronzi.fr/api/v1/repos/search?q=&owner=proxmoxPanel&limit=50")
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
if resp.StatusCode != http.StatusOK {
|
||||||
if err != nil {
|
body, _ := io.ReadAll(resp.Body)
|
||||||
JSONError(w, "Erreur lecture réponse store", http.StatusBadGateway)
|
JSONResponse(w, http.StatusOK, registryResp{
|
||||||
|
Modules: []RegistryModule{},
|
||||||
|
Error: fmt.Sprintf("Store a répondu HTTP %d : %s", resp.StatusCode, string(body)),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structure de réponse Gitea/Forgejo
|
body, _ := io.ReadAll(resp.Body)
|
||||||
var forgejoResp struct {
|
|
||||||
Data []struct {
|
// /api/v1/orgs/{org}/repos retourne directement un tableau []repo
|
||||||
|
var repos []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &forgejoResp); err != nil {
|
if err := json.Unmarshal(body, &repos); err != nil {
|
||||||
JSONError(w, "Erreur parsing réponse store", http.StatusBadGateway)
|
JSONResponse(w, http.StatusOK, registryResp{
|
||||||
|
Modules: []RegistryModule{},
|
||||||
|
Error: fmt.Sprintf("Réponse store invalide : %v", err),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var modules []RegistryModule
|
modules := make([]RegistryModule, 0, len(repos))
|
||||||
for _, repo := range forgejoResp.Data {
|
for _, repo := range repos {
|
||||||
// Exclure le repo "core"
|
|
||||||
if repo.Name == "core" {
|
if repo.Name == "core" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -376,10 +406,15 @@ func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Requ
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if modules == nil {
|
JSONResponse(w, http.StatusOK, registryResp{Modules: modules})
|
||||||
modules = []RegistryModule{}
|
|
||||||
}
|
}
|
||||||
JSONResponse(w, http.StatusOK, 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
|
||||||
|
}
|
||||||
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// moduleJSON représente le fichier module.json d'un module.
|
// 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")
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
// Récupérer module.json depuis Forgejo
|
// 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)
|
forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr")
|
||||||
resp, err := http.Get(url)
|
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 {
|
if err != nil {
|
||||||
JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway)
|
JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
@ -441,7 +489,7 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL du repo
|
// 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
|
hasBackend := 0
|
||||||
if mod.HasBackend {
|
if mod.HasBackend {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ services:
|
||||||
- GIT_BRANCH=frontend/alpine
|
- GIT_BRANCH=frontend/alpine
|
||||||
# Tag de l'image Docker construite
|
# Tag de l'image Docker construite
|
||||||
- IMAGE_TAG=proxmoxpanel-backend:latest
|
- 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é
|
# 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
|
# Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -1080,13 +1080,20 @@ document.addEventListener('alpine:init', () => {
|
||||||
toggling: {},
|
toggling: {},
|
||||||
storeModules: [],
|
storeModules: [],
|
||||||
storeLoading: true,
|
storeLoading: true,
|
||||||
|
storeError: '',
|
||||||
installing: {},
|
installing: {},
|
||||||
rebuildRequired: false,
|
rebuilding: false,
|
||||||
|
rebuildDone: false,
|
||||||
|
_pollTimer: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await Promise.all([this.load(), this.loadStore()])
|
await Promise.all([this.load(), this.loadStore()])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._pollTimer) clearInterval(this._pollTimer)
|
||||||
|
},
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -1099,10 +1106,20 @@ document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
async loadStore() {
|
async loadStore() {
|
||||||
this.storeLoading = true
|
this.storeLoading = true
|
||||||
|
this.storeError = ''
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/registry/modules')
|
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) {
|
} catch(e) {
|
||||||
|
this.storeError = 'Impossible de joindre le store : ' + e.message
|
||||||
this.storeModules = []
|
this.storeModules = []
|
||||||
} finally {
|
} finally {
|
||||||
this.storeLoading = false
|
this.storeLoading = false
|
||||||
|
|
@ -1115,12 +1132,17 @@ document.addEventListener('alpine:init', () => {
|
||||||
const action = mod.is_enabled ? 'disable' : 'enable'
|
const action = mod.is_enabled ? 'disable' : 'enable'
|
||||||
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
|
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
mod.is_enabled = !mod.is_enabled
|
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
|
// Rafraîchir la sidebar
|
||||||
const sb = document.querySelector('[x-data="sidebar()"]')
|
const sb = document.querySelector('[x-data="sidebar()"]')
|
||||||
if (sb && sb._x_dataStack) {
|
if (sb) {
|
||||||
const sidebarData = Alpine.$data(sb)
|
const sidebarData = Alpine.$data(sb)
|
||||||
if (sidebarData && sidebarData.refreshNav) await sidebarData.refreshNav()
|
if (sidebarData?.refreshNav) await sidebarData.refreshNav()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
|
@ -1135,9 +1157,15 @@ document.addEventListener('alpine:init', () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/registry/modules/${mod.id}/install`, { method: 'POST' })
|
const res = await apiFetch(`/api/registry/modules/${mod.id}/install`, { method: 'POST' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
mod.installed = true
|
mod.installed = true
|
||||||
this.rebuildRequired = true
|
if (data.rebuilding) {
|
||||||
Alpine.store('toasts').success(`Module ${mod.id} installé — rebuild requis`)
|
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()
|
await this.load()
|
||||||
} else {
|
} else {
|
||||||
const b = await res.json().catch(() => ({}))
|
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) },
|
t(key) { return Alpine.store('i18n').t(key) },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,21 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Store : modules disponibles -->
|
<!-- Store : modules disponibles -->
|
||||||
<h3 class="section-title" style="margin-top:2rem"><i class="lnid-download-2"></i> Store</h3>
|
<div style="display:flex;align-items:center;gap:.75rem;margin-top:2rem">
|
||||||
|
<h3 class="section-title" style="margin:0"><i class="lnid-download-2"></i> Store</h3>
|
||||||
|
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="loadStore()" :disabled="storeLoading" title="Rafraîchir le store">
|
||||||
|
<i class="lnid-refresh-2" :class="{ 'spin': storeLoading }"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="section-desc">Modules disponibles depuis <a href="https://git.geronzi.fr/proxmoxPanel" target="_blank">git.geronzi.fr/proxmoxPanel</a></p>
|
<p class="section-desc">Modules disponibles depuis <a href="https://git.geronzi.fr/proxmoxPanel" target="_blank">git.geronzi.fr/proxmoxPanel</a></p>
|
||||||
|
|
||||||
|
<!-- Erreur store -->
|
||||||
|
<div class="neu-card" x-show="storeError && !storeLoading"
|
||||||
|
style="margin-bottom:1rem;border-left:3px solid var(--neu-danger);display:flex;align-items:center;gap:.75rem">
|
||||||
|
<i class="lnid-warning-circle-1" style="color:var(--neu-danger)"></i>
|
||||||
|
<span x-text="storeError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="loading-state" x-show="storeLoading">
|
<div class="loading-state" x-show="storeLoading">
|
||||||
<div class="spinner-lg"></div><span>Chargement du store…</span>
|
<div class="spinner-lg"></div><span>Chargement du store…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,15 +133,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p class="empty-state" x-show="!storeLoading && storeModules.length === 0">
|
<p class="empty-state" x-show="!storeLoading && !storeError && storeModules.length === 0">
|
||||||
Aucun module disponible dans le store
|
Aucun module disponible dans le store
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message rebuild -->
|
<!-- Bannière rebuild en cours -->
|
||||||
<div class="neu-card rebuild-notice" x-show="rebuildRequired" style="margin-top:1rem;border-left:3px solid var(--neu-warning)">
|
<div class="neu-card rebuild-notice" x-show="rebuilding"
|
||||||
<i class="lnid-warning-circle-1" style="color:var(--neu-warning)"></i>
|
style="margin-top:1rem;border-left:3px solid var(--neu-accent);display:flex;align-items:center;gap:.75rem">
|
||||||
<span>Un ou plusieurs modules ont été installés. <strong>Un rebuild du container est nécessaire pour les activer.</strong></span>
|
<div class="spinner-sm" style="flex-shrink:0"></div>
|
||||||
|
<span>Rebuild du container en cours (~1-2 min) — l'interface redémarrera automatiquement.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rebuild terminé (backend revenu) -->
|
||||||
|
<div class="neu-card rebuild-notice" x-show="rebuildDone"
|
||||||
|
style="margin-top:1rem;border-left:3px solid var(--neu-success);display:flex;align-items:center;gap:.75rem">
|
||||||
|
<i class="lnid-circle-check-1" style="color:var(--neu-success)"></i>
|
||||||
|
<span>Rebuild terminé. <button class="neu-btn neu-btn--sm" @click="window.location.reload()">Recharger la page</button></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue