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:
parent
c6028b6951
commit
82e3b850d0
5 changed files with 493 additions and 102 deletions
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
"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.
|
// RunUpdate lance une mise à jour apt sur la cible spécifiée.
|
||||||
// POST /api/updates/run
|
// POST /api/updates/run
|
||||||
// Body: { "target": "host" | "lxc:100" | "all" }
|
// Body: { "target": "host" | "lxc:100" | "all" }
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ func main() {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(api.RequireAdmin)
|
r.Use(api.RequireAdmin)
|
||||||
r.Post("/api/updates/run", updatesHandler.RunUpdate)
|
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)
|
r.Get("/api/updates/history", updatesHandler.GetHistory)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
"disconnected": "Disconnected"
|
"disconnected": "Disconnected"
|
||||||
},
|
},
|
||||||
"updates": {
|
"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",
|
"selectTarget": "Select target",
|
||||||
"targetHost": "Proxmox Host",
|
"targetHost": "Proxmox Host",
|
||||||
"targetAll": "All LXC",
|
"targetAll": "All LXC",
|
||||||
|
|
@ -87,6 +87,16 @@
|
||||||
"output": "Output",
|
"output": "Output",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"noHistory": "No updates performed",
|
"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": {
|
"status": {
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
"disconnected": "Déconnecté"
|
"disconnected": "Déconnecté"
|
||||||
},
|
},
|
||||||
"updates": {
|
"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",
|
"selectTarget": "Sélectionner la cible",
|
||||||
"targetHost": "Host Proxmox",
|
"targetHost": "Host Proxmox",
|
||||||
"targetAll": "Tous les LXC",
|
"targetAll": "Tous les LXC",
|
||||||
|
|
@ -87,6 +87,16 @@
|
||||||
"output": "Sortie",
|
"output": "Sortie",
|
||||||
"history": "Historique",
|
"history": "Historique",
|
||||||
"noHistory": "Aucune mise à jour effectuée",
|
"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": {
|
"status": {
|
||||||
"running": "En cours",
|
"running": "En cours",
|
||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,109 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="updates-page">
|
<div class="updates-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h2>{{ t('nav.updates') }}</h2>
|
<h2>{{ t('nav.updates') }}</h2>
|
||||||
<p class="text-muted">{{ t('updates.desc') }}</p>
|
<p class="text-muted">{{ t('updates.desc') }}</p>
|
||||||
</div>
|
</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 -->
|
<!-- Loading -->
|
||||||
<div class="neu-card target-card">
|
<div v-if="loadingTargets" class="neu-card loading-card">
|
||||||
<h3>{{ t('updates.selectTarget') }}</h3>
|
<p class="text-muted">⟳ {{ t('updates.loadingTargets') }}</p>
|
||||||
<div class="targets">
|
</div>
|
||||||
<button
|
|
||||||
|
<!-- Target cards -->
|
||||||
|
<div v-else class="targets-grid">
|
||||||
|
<div
|
||||||
v-for="target in targets"
|
v-for="target in targets"
|
||||||
:key="target.value"
|
:key="target.id"
|
||||||
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
|
class="target-card neu-card"
|
||||||
@click="selectedTarget = target.value"
|
|
||||||
>
|
>
|
||||||
{{ t(target.label) }}
|
<!-- Header -->
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<button
|
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
|
||||||
class="neu-btn neu-btn--success"
|
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
|
||||||
:disabled="!selectedTarget || running"
|
</span>
|
||||||
@click="startUpdate"
|
|
||||||
style="margin-top: var(--neu-space-md);"
|
|
||||||
>
|
|
||||||
{{ running ? t('updates.running') : t('updates.start') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal de sortie -->
|
<!-- Package status -->
|
||||||
<div v-if="currentJob" class="neu-card output-card">
|
<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">
|
<div class="output-header flex items-center justify-between">
|
||||||
<h3>{{ t('updates.output') }}</h3>
|
<h3>{{ t('updates.output') }} — {{ activeJob.target }}</h3>
|
||||||
<span :class="['neu-badge', jobStatus === 'success' ? 'neu-badge--success' : jobStatus === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
<span :class="['neu-badge', activeJob.status === 'success' ? 'neu-badge--success' : activeJob.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||||
{{ t(`updates.status.${jobStatus}`) }}
|
{{ t(`updates.status.${activeJob.status}`) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="output-terminal neu-inset" ref="terminalEl">
|
<div class="output-terminal neu-inset" ref="terminalEl">
|
||||||
<pre class="output-text">{{ outputText }}</pre>
|
<pre class="output-text">{{ activeJob.output }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Historique -->
|
<!-- History -->
|
||||||
<div class="neu-card">
|
<div class="neu-card">
|
||||||
<h3>{{ t('updates.history') }}</h3>
|
<h3>{{ t('updates.history') }}</h3>
|
||||||
<div v-if="history.length === 0" class="empty-state">
|
<div v-if="history.length === 0" class="empty-state">
|
||||||
|
|
@ -49,7 +111,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="history-list">
|
<div v-else class="history-list">
|
||||||
<div v-for="entry in history" :key="entry.job_id" class="history-item">
|
<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']">
|
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||||
{{ t(`updates.status.${entry.status}`) }}
|
{{ t(`updates.status.${entry.status}`) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -63,31 +125,182 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const selectedTarget = ref('host')
|
interface Package {
|
||||||
const running = ref(false)
|
name: string
|
||||||
const currentJob = ref<string | null>(null)
|
version: string
|
||||||
const outputText = ref('')
|
old_version: string
|
||||||
const jobStatus = ref('running')
|
}
|
||||||
|
|
||||||
|
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 history = ref<any[]>([])
|
||||||
const terminalEl = ref<HTMLElement | null>(null)
|
const terminalEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
let wsConnection: WebSocket | null = null
|
let wsConnection: WebSocket | null = null
|
||||||
|
|
||||||
const targets = [
|
const checkingAll = computed(() => targets.value.some(t => t.checking))
|
||||||
{ value: 'host', label: 'updates.targetHost' },
|
const anyRunning = computed(() => targets.value.some(t => t.updating))
|
||||||
{ value: 'all', label: 'updates.targetAll' },
|
|
||||||
]
|
onMounted(async () => {
|
||||||
|
await loadTargets()
|
||||||
|
await loadHistory()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(loadHistory)
|
|
||||||
onUnmounted(() => wsConnection?.close())
|
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() {
|
async function loadHistory() {
|
||||||
const res = await fetch('/api/updates/history', {
|
const res = await fetch('/api/updates/history', {
|
||||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||||
|
|
@ -95,71 +308,86 @@ async function loadHistory() {
|
||||||
if (res.ok) history.value = await res.json() || []
|
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 {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleString()
|
return new Date(iso).toLocaleString()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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); }
|
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||||
.text-muted { color: var(--neu-text-muted); }
|
.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-header { margin-bottom: var(--neu-space-sm); }
|
||||||
|
|
||||||
.output-terminal {
|
.output-terminal {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -167,7 +395,6 @@ function formatDate(iso: string): string {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-text {
|
.output-text {
|
||||||
color: var(--neu-success);
|
color: var(--neu-success);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
@ -177,11 +404,14 @@ function formatDate(iso: string): string {
|
||||||
|
|
||||||
.empty-state { padding: var(--neu-space-md) 0; }
|
.empty-state { padding: var(--neu-space-md) 0; }
|
||||||
|
|
||||||
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
|
.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 { padding: var(--neu-space-sm) 0; border-bottom: 1px solid var(--neu-border); }
|
|
||||||
.history-item:last-child { border-bottom: none; }
|
.history-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue