feat: initialisation complète du CORE ProxmoxPanel
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>
This commit is contained in:
commit
5dbcb1df07
66 changed files with 10370 additions and 0 deletions
187
frontend/src/views/Updates.vue
Normal file
187
frontend/src/views/Updates.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue