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:
enzo 2026-03-20 21:08:53 +01:00
commit 5dbcb1df07
66 changed files with 10370 additions and 0 deletions

View 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>