feat: page mises à jour avec liste des paquets par cible

- Backend: GET /api/updates/targets (pct list via SSH)
- Backend: GET /api/updates/packages?target= (apt list --upgradable)
- Frontend: grille de cards par cible (host + chaque LXC)
- Bouton Check/Update par card, liste paquets dépliable (version actuelle → nouvelle)
- Boutons globaux "Tout vérifier" et "Tout mettre à jour"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 01:10:47 +01:00
parent c6028b6951
commit 82e3b850d0
5 changed files with 493 additions and 102 deletions

View file

@ -8,6 +8,8 @@ import (
"log"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
@ -38,6 +40,143 @@ func NewUpdatesHandler(database *db.DB, sshPool *ssh.Pool, hub *websocket.Hub, a
}
}
// sshCredentials récupère les credentials SSH depuis la configuration SQLite.
func (h *UpdatesHandler) sshCredentials() (host, user, pass string, err error) {
host, _, _ = h.db.GetSetting("ssh_host")
user, _, _ = h.db.GetSetting("ssh_username")
encPass, _, _ := h.db.GetSetting("ssh_password")
if encPass != "" {
pass, err = h.encryptor.Decrypt(encPass)
if err != nil {
return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH")
}
}
if host == "" || user == "" || pass == "" {
return "", "", "", fmt.Errorf("SSH non configuré")
}
return host, user, pass, nil
}
// GetTargets retourne la liste des cibles disponibles : host Proxmox + tous les LXC.
// GET /api/updates/targets
func (h *UpdatesHandler) GetTargets(w http.ResponseWriter, r *http.Request) {
sshHost, sshUser, sshPass, err := h.sshCredentials()
if err != nil {
JSONError(w, err.Error(), http.StatusServiceUnavailable)
return
}
type Target struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
VMID int `json:"vmid,omitempty"`
}
targets := []Target{
{ID: "host", Name: "Proxmox Host", Status: "running"},
}
output, err := h.sshPool.RunCommand(sshHost, sshUser, sshPass, "pct list 2>/dev/null")
if err == nil {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] == "VMID" {
continue
}
vmid, parseErr := strconv.Atoi(fields[0])
if parseErr != nil {
continue
}
status := fields[1]
name := fields[0] // fallback VMID si pas de nom
if len(fields) >= 3 {
name = fields[len(fields)-1]
}
targets = append(targets, Target{
ID: fmt.Sprintf("lxc:%d", vmid),
Name: name,
Status: status,
VMID: vmid,
})
}
}
JSONResponse(w, http.StatusOK, targets)
}
// GetPackages retourne la liste des paquets pouvant être mis à jour pour une cible.
// GET /api/updates/packages?target=host|lxc:100
func (h *UpdatesHandler) GetPackages(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
if target == "" {
JSONError(w, "Paramètre 'target' requis", http.StatusBadRequest)
return
}
sshHost, sshUser, sshPass, err := h.sshCredentials()
if err != nil {
JSONError(w, err.Error(), http.StatusServiceUnavailable)
return
}
var command string
switch {
case target == "host":
command = "apt list --upgradable 2>/dev/null"
case len(target) > 4 && target[:4] == "lxc:":
lxcID := target[4:]
command = fmt.Sprintf("pct exec %s -- apt list --upgradable 2>/dev/null", lxcID)
default:
JSONError(w, "Cible invalide", http.StatusBadRequest)
return
}
output, err := h.sshPool.RunCommand(sshHost, sshUser, sshPass, command)
if err != nil {
log.Printf("[updates/packages] Erreur SSH pour %s : %v", target, err)
JSONError(w, "Erreur SSH : "+err.Error(), http.StatusBadGateway)
return
}
JSONResponse(w, http.StatusOK, parseAptPackages(output))
}
// parseAptPackages analyse la sortie de `apt list --upgradable`.
func parseAptPackages(output string) []map[string]string {
var packages []map[string]string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "[upgradable from:") {
continue
}
// Format : name/repo version arch [upgradable from: old_version]
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
version := parts[1]
oldVersion := ""
if idx := strings.Index(line, "upgradable from: "); idx >= 0 {
oldVersion = strings.TrimRight(line[idx+len("upgradable from: "):], "] ")
}
packages = append(packages, map[string]string{
"name": name,
"version": version,
"old_version": oldVersion,
})
}
if packages == nil {
packages = []map[string]string{}
}
return packages
}
// RunUpdate lance une mise à jour apt sur la cible spécifiée.
// POST /api/updates/run
// Body: { "target": "host" | "lxc:100" | "all" }

View file

@ -146,6 +146,8 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Post("/api/updates/run", updatesHandler.RunUpdate)
r.Get("/api/updates/targets", updatesHandler.GetTargets)
r.Get("/api/updates/packages", updatesHandler.GetPackages)
})
r.Get("/api/updates/history", updatesHandler.GetHistory)