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)

View file

@ -78,7 +78,7 @@
"disconnected": "Disconnected"
},
"updates": {
"desc": "Run apt updates on the host or LXC containers.",
"desc": "Check and run apt updates on the host or LXC containers.",
"selectTarget": "Select target",
"targetHost": "Proxmox Host",
"targetAll": "All LXC",
@ -87,6 +87,16 @@
"output": "Output",
"history": "History",
"noHistory": "No updates performed",
"checkUpdates": "Check",
"checkAll": "Check all",
"updateTarget": "Update",
"updateAll": "Update all",
"packagesToUpdate": "package(s) to update",
"upToDate": "Up to date",
"notChecked": "Not checked",
"checking": "Checking...",
"loadingTargets": "Loading targets...",
"stopped": "Stopped",
"status": {
"running": "Running",
"success": "Success",

View file

@ -78,7 +78,7 @@
"disconnected": "Déconnecté"
},
"updates": {
"desc": "Lancez des mises à jour apt sur le host ou les LXC.",
"desc": "Vérifiez et lancez des mises à jour apt sur le host ou les LXC.",
"selectTarget": "Sélectionner la cible",
"targetHost": "Host Proxmox",
"targetAll": "Tous les LXC",
@ -87,6 +87,16 @@
"output": "Sortie",
"history": "Historique",
"noHistory": "Aucune mise à jour effectuée",
"checkUpdates": "Vérifier",
"checkAll": "Tout vérifier",
"updateTarget": "Mettre à jour",
"updateAll": "Tout mettre à jour",
"packagesToUpdate": "paquet(s) à mettre à jour",
"upToDate": "À jour",
"notChecked": "Non vérifié",
"checking": "Vérification...",
"loadingTargets": "Chargement des cibles...",
"stopped": "Arrêté",
"status": {
"running": "En cours",
"success": "Succès",

View file

@ -1,47 +1,109 @@
<template>
<div class="updates-page">
<div class="page-header">
<div>
<h2>{{ t('nav.updates') }}</h2>
<p class="text-muted">{{ t('updates.desc') }}</p>
</div>
<div class="header-actions">
<button class="neu-btn neu-btn--sm" :disabled="checkingAll || loadingTargets" @click="checkAll">
<span v-if="checkingAll" class="neu-loading"></span>
{{ t('updates.checkAll') }}
</button>
<button class="neu-btn neu-btn--success" :disabled="anyRunning" @click="updateAllTargets">
{{ anyRunning ? t('updates.running') : t('updates.updateAll') }}
</button>
</div>
</div>
<!-- Sélection de la cible -->
<div class="neu-card target-card">
<h3>{{ t('updates.selectTarget') }}</h3>
<div class="targets">
<button
<!-- Loading -->
<div v-if="loadingTargets" class="neu-card loading-card">
<p class="text-muted"> {{ t('updates.loadingTargets') }}</p>
</div>
<!-- Target cards -->
<div v-else class="targets-grid">
<div
v-for="target in targets"
:key="target.value"
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
@click="selectedTarget = target.value"
:key="target.id"
class="target-card neu-card"
>
{{ t(target.label) }}
</button>
<!-- Header -->
<div class="target-header">
<div class="target-title">
<span class="target-name">{{ target.name }}</span>
<span v-if="target.vmid" class="target-vmid">LXC {{ target.vmid }}</span>
</div>
<button
class="neu-btn neu-btn--success"
:disabled="!selectedTarget || running"
@click="startUpdate"
style="margin-top: var(--neu-space-md);"
>
{{ running ? t('updates.running') : t('updates.start') }}
</button>
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
</span>
</div>
<!-- Terminal de sortie -->
<div v-if="currentJob" class="neu-card output-card">
<!-- Package status -->
<div class="target-status">
<template v-if="target.checking">
<span class="text-muted text-xs"> {{ t('updates.checking') }}</span>
</template>
<template v-else-if="target.error">
<span class="neu-badge neu-badge--danger" :title="target.error"> Erreur</span>
</template>
<template v-else-if="target.packages === null">
<span class="not-checked text-muted">{{ t('updates.notChecked') }}</span>
</template>
<template v-else-if="target.packages.length === 0">
<span class="neu-badge neu-badge--success"> {{ t('updates.upToDate') }}</span>
</template>
<template v-else>
<button class="pkg-count-btn" @click="target.showPackages = !target.showPackages">
<span class="neu-badge neu-badge--warning">{{ target.packages.length }} {{ t('updates.packagesToUpdate') }}</span>
<span class="pkg-toggle">{{ target.showPackages ? '▲' : '▼' }}</span>
</button>
</template>
</div>
<!-- Package list (expandable) -->
<div v-if="target.showPackages && target.packages?.length" class="package-list neu-inset">
<div v-for="pkg in target.packages" :key="pkg.name" class="package-item">
<span class="pkg-name">{{ pkg.name }}</span>
<span class="pkg-versions text-muted">{{ pkg.old_version }} <strong>{{ pkg.version }}</strong></span>
</div>
</div>
<!-- Actions -->
<div class="target-actions">
<button
class="neu-btn neu-btn--sm"
:disabled="target.checking || target.status !== 'running'"
@click="checkTarget(target)"
>
{{ t('updates.checkUpdates') }}
</button>
<button
class="neu-btn neu-btn--sm neu-btn--success"
:disabled="target.updating || target.status !== 'running'"
@click="updateTarget(target)"
>
<span v-if="target.updating" class="neu-loading"></span>
{{ target.updating ? t('updates.running') : t('updates.updateTarget') }}
</button>
</div>
</div>
</div>
<!-- Output terminal -->
<div v-if="activeJob" class="neu-card output-card">
<div class="output-header flex items-center justify-between">
<h3>{{ t('updates.output') }}</h3>
<span :class="['neu-badge', jobStatus === 'success' ? 'neu-badge--success' : jobStatus === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${jobStatus}`) }}
<h3>{{ t('updates.output') }} {{ activeJob.target }}</h3>
<span :class="['neu-badge', activeJob.status === 'success' ? 'neu-badge--success' : activeJob.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${activeJob.status}`) }}
</span>
</div>
<div class="output-terminal neu-inset" ref="terminalEl">
<pre class="output-text">{{ outputText }}</pre>
<pre class="output-text">{{ activeJob.output }}</pre>
</div>
</div>
<!-- Historique -->
<!-- History -->
<div class="neu-card">
<h3>{{ t('updates.history') }}</h3>
<div v-if="history.length === 0" class="empty-state">
@ -49,7 +111,7 @@
</div>
<div v-else class="history-list">
<div v-for="entry in history" :key="entry.job_id" class="history-item">
<div class="history-meta flex items-center gap-sm">
<div class="flex items-center gap-sm">
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${entry.status}`) }}
</span>
@ -63,31 +125,182 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
const selectedTarget = ref('host')
const running = ref(false)
const currentJob = ref<string | null>(null)
const outputText = ref('')
const jobStatus = ref('running')
interface Package {
name: string
version: string
old_version: string
}
interface Target {
id: string
name: string
status: string
vmid?: number
packages: Package[] | null
checking: boolean
updating: boolean
showPackages: boolean
error: string | null
}
interface ActiveJob {
jobId: string
target: string
output: string
status: 'running' | 'success' | 'error'
}
const targets = ref<Target[]>([])
const loadingTargets = ref(true)
const activeJob = ref<ActiveJob | null>(null)
const history = ref<any[]>([])
const terminalEl = ref<HTMLElement | null>(null)
let wsConnection: WebSocket | null = null
const targets = [
{ value: 'host', label: 'updates.targetHost' },
{ value: 'all', label: 'updates.targetAll' },
]
const checkingAll = computed(() => targets.value.some(t => t.checking))
const anyRunning = computed(() => targets.value.some(t => t.updating))
onMounted(async () => {
await loadTargets()
await loadHistory()
})
onMounted(loadHistory)
onUnmounted(() => wsConnection?.close())
async function loadTargets() {
loadingTargets.value = true
try {
const res = await fetch('/api/updates/targets', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
const data: any[] = await res.json() || []
targets.value = data.map(t => ({
id: t.id,
name: t.name,
status: t.status,
vmid: t.vmid,
packages: null,
checking: false,
updating: false,
showPackages: false,
error: null,
}))
}
} finally {
loadingTargets.value = false
}
}
async function checkTarget(target: Target) {
target.checking = true
target.error = null
try {
const res = await fetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`, {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
target.packages = await res.json() || []
} else {
target.error = 'Erreur lors de la vérification'
target.packages = null
}
} catch {
target.error = 'Erreur réseau'
target.packages = null
} finally {
target.checking = false
}
}
async function checkAll() {
// Séquentiel pour ne pas saturer le SSH pool
for (const target of targets.value) {
if (target.status === 'running') {
await checkTarget(target)
}
}
}
async function updateTarget(target: Target) {
target.updating = true
target.error = null
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: target.id }),
})
if (!res.ok) {
target.updating = false
return
}
const data = await res.json()
connectJobWS(data.job_id, target.name, () => {
target.updating = false
checkTarget(target)
loadHistory()
})
}
async function updateAllTargets() {
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: 'all' }),
})
if (!res.ok) return
const data = await res.json()
// Marquer tous les LXC running comme "updating"
targets.value.forEach(t => { if (t.status === 'running') t.updating = true })
connectJobWS(data.job_id, 'all LXC', () => {
targets.value.forEach(t => { t.updating = false })
loadHistory()
checkAll()
})
}
function connectJobWS(jobId: string, targetName: string, onDone: () => void) {
wsConnection?.close()
activeJob.value = {
jobId,
target: targetName,
output: '',
status: 'running',
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${jobId}?token=${authStore.accessToken}`)
wsConnection.onmessage = async (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'update_output' && msg.payload?.chunk && activeJob.value) {
activeJob.value.output += msg.payload.chunk
await nextTick()
if (terminalEl.value) terminalEl.value.scrollTop = terminalEl.value.scrollHeight
} else if (msg.type === 'update_done') {
if (activeJob.value) activeJob.value.status = 'success'
wsConnection?.close()
onDone()
} else if (msg.type === 'update_error') {
if (activeJob.value) activeJob.value.status = 'error'
wsConnection?.close()
onDone()
}
}
}
async function loadHistory() {
const res = await fetch('/api/updates/history', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
@ -95,71 +308,86 @@ async function loadHistory() {
if (res.ok) history.value = await res.json() || []
}
async function startUpdate() {
running.value = true
outputText.value = ''
jobStatus.value = 'running'
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: selectedTarget.value }),
})
if (!res.ok) {
running.value = false
return
}
const data = await res.json()
currentJob.value = data.job_id
// Connecter le WebSocket de streaming
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${data.job_id}?token=${authStore.accessToken}`)
wsConnection.onmessage = async (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'update_output' && msg.payload?.chunk) {
outputText.value += msg.payload.chunk
await nextTick()
if (terminalEl.value) {
terminalEl.value.scrollTop = terminalEl.value.scrollHeight
}
} else if (msg.type === 'update_done') {
jobStatus.value = 'success'
running.value = false
wsConnection?.close()
loadHistory()
} else if (msg.type === 'update_error') {
jobStatus.value = 'error'
running.value = false
wsConnection?.close()
}
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.updates-page { max-width: 900px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
.updates-page { max-width: 1200px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-md); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.text-xs { font-size: var(--neu-font-xs); }
.target-card h3, .output-card h3 { margin-bottom: var(--neu-space-md); color: var(--neu-text); }
.header-actions { display: flex; gap: var(--neu-space-sm); flex-shrink: 0; align-items: flex-start; }
.targets { display: flex; flex-wrap: wrap; gap: var(--neu-space-sm); }
.loading-card { text-align: center; padding: var(--neu-space-xl); }
.targets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--neu-space-md);
}
.target-card { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-sm); }
.target-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.target-name { font-weight: 600; color: var(--neu-text); font-size: var(--neu-font-md); }
.target-vmid { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
.target-status { min-height: 26px; display: flex; align-items: center; gap: var(--neu-space-xs); }
.not-checked { font-size: var(--neu-font-xs); }
.pkg-count-btn {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.pkg-toggle { font-size: 10px; color: var(--neu-text-muted); }
.package-list {
max-height: 200px;
overflow-y: auto;
padding: var(--neu-space-xs) var(--neu-space-sm);
display: flex;
flex-direction: column;
gap: 2px;
}
.package-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 0;
font-size: 11px;
border-bottom: 1px solid var(--neu-border);
gap: var(--neu-space-sm);
}
.package-item:last-child { border-bottom: none; }
.pkg-name { font-family: monospace; color: var(--neu-text); font-weight: 500; flex-shrink: 0; }
.pkg-versions { font-family: monospace; text-align: right; }
.pkg-versions strong { color: var(--neu-text); }
.target-actions {
display: flex;
gap: var(--neu-space-xs);
margin-top: auto;
padding-top: var(--neu-space-sm);
border-top: 1px solid var(--neu-border);
}
.target-actions .neu-btn { flex: 1; font-size: var(--neu-font-xs); }
.output-card { }
.output-header { margin-bottom: var(--neu-space-sm); }
.output-terminal {
height: 400px;
overflow-y: auto;
@ -167,7 +395,6 @@ function formatDate(iso: string): string {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.output-text {
color: var(--neu-success);
white-space: pre-wrap;
@ -177,11 +404,14 @@ function formatDate(iso: string): string {
.empty-state { padding: var(--neu-space-md) 0; }
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
.history-item { padding: var(--neu-space-sm) 0; border-bottom: 1px solid var(--neu-border); }
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
.history-item { padding: var(--neu-space-xs) 0; border-bottom: 1px solid var(--neu-border); }
.history-item:last-child { border-bottom: none; }
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
.history-date { font-size: var(--neu-font-xs); }
.history-date { font-size: var(--neu-font-xs); margin-left: auto; }
@media (max-width: 640px) {
.page-header { flex-direction: column; }
.targets-grid { grid-template-columns: 1fr; }
}
</style>