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

@ -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">
<h2>{{ t('nav.updates') }}</h2>
<p class="text-muted">{{ t('updates.desc') }}</p>
</div>
<!-- Sélection de la cible -->
<div class="neu-card target-card">
<h3>{{ t('updates.selectTarget') }}</h3>
<div class="targets">
<button
v-for="target in targets"
:key="target.value"
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
@click="selectedTarget = target.value"
>
{{ t(target.label) }}
<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>
<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>
</div>
<!-- Terminal de sortie -->
<div v-if="currentJob" class="neu-card output-card">
<!-- 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.id"
class="target-card neu-card"
>
<!-- 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>
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
</span>
</div>
<!-- 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>