Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
5.8 KiB
Vue
187 lines
5.8 KiB
Vue
<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) }}
|
|
</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">
|
|
<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}`) }}
|
|
</span>
|
|
</div>
|
|
<div class="output-terminal neu-inset" ref="terminalEl">
|
|
<pre class="output-text">{{ outputText }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Historique -->
|
|
<div class="neu-card">
|
|
<h3>{{ t('updates.history') }}</h3>
|
|
<div v-if="history.length === 0" class="empty-state">
|
|
<p class="text-muted">{{ t('updates.noHistory') }}</p>
|
|
</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">
|
|
<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>
|
|
<span class="history-target">{{ entry.target }}</span>
|
|
<span class="text-muted history-date">{{ formatDate(entry.started_at) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, 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')
|
|
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' },
|
|
]
|
|
|
|
onMounted(loadHistory)
|
|
onUnmounted(() => wsConnection?.close())
|
|
|
|
async function loadHistory() {
|
|
const res = await fetch('/api/updates/history', {
|
|
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
|
})
|
|
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); }
|
|
|
|
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
|
.text-muted { color: var(--neu-text-muted); }
|
|
|
|
.target-card h3, .output-card h3 { margin-bottom: var(--neu-space-md); color: var(--neu-text); }
|
|
|
|
.targets { display: flex; flex-wrap: wrap; gap: var(--neu-space-sm); }
|
|
|
|
.output-header { margin-bottom: var(--neu-space-sm); }
|
|
|
|
.output-terminal {
|
|
height: 400px;
|
|
overflow-y: auto;
|
|
padding: var(--neu-space-md);
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.output-text {
|
|
color: var(--neu-success);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
margin: 0;
|
|
}
|
|
|
|
.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-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); }
|
|
</style>
|