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,393 @@
<template>
<div class="dashboard">
<!-- En-tête avec bouton d'ajout de widget -->
<div class="dashboard__header flex items-center justify-between">
<div>
<h2>{{ t('nav.dashboard') }}</h2>
<p class="text-muted">{{ t('dashboard.welcome', { name: authStore.user?.username }) }}</p>
</div>
<button class="neu-btn neu-btn--primary" @click="showAddWidget = true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('dashboard.addWidget') }}
</button>
</div>
<!-- Grille de widgets drag-and-drop -->
<VueDraggable
v-model="widgets"
class="dashboard__grid"
item-key="id"
handle=".widget-drag-handle"
@end="saveLayout"
>
<div
v-for="widget in widgets"
:key="widget.id"
class="widget-wrapper"
:style="{ gridColumn: `span ${widget.width}`, gridRow: `span ${widget.height}` }"
>
<!-- Widget raccourci service -->
<div v-if="widget.type === 'shortcut'" class="neu-card widget widget--shortcut">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ widget.title }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<a :href="widget.config.url" target="_blank" rel="noopener" class="shortcut-link">
<div class="shortcut-icon">{{ widget.config.icon || '🔗' }}</div>
<div class="shortcut-url">{{ widget.config.url }}</div>
</a>
</div>
<!-- Widget statut LXC -->
<div v-else-if="widget.type === 'lxc_status'" class="neu-card widget">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ t('dashboard.lxcStatus') }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="lxc-list">
<div v-for="lxc in proxmoxResources.filter(r => r.type === 'lxc').slice(0, 6)" :key="lxc.vmid" class="lxc-item">
<span :class="['neu-badge', lxc.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
{{ lxc.status === 'running' ? '●' : '○' }}
</span>
<span class="lxc-name">{{ lxc.name || `LXC ${lxc.vmid}` }}</span>
<span class="lxc-id text-muted">{{ lxc.vmid }}</span>
</div>
<p v-if="proxmoxResources.length === 0" class="text-muted text-sm">
{{ t('dashboard.noData') }}
</p>
</div>
</div>
<!-- Widget métriques système -->
<div v-else-if="widget.type === 'metrics'" class="neu-card widget">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ t('dashboard.metrics') }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="metrics-grid">
<div class="metric-item">
<div class="metric-label">{{ t('dashboard.lxcCount') }}</div>
<div class="metric-value">{{ proxmoxResources.filter(r => r.type === 'lxc').length }}</div>
</div>
<div class="metric-item">
<div class="metric-label">{{ t('dashboard.running') }}</div>
<div class="metric-value text-success">{{ proxmoxResources.filter(r => r.status === 'running').length }}</div>
</div>
</div>
</div>
</div>
</VueDraggable>
<!-- Modal ajout de widget -->
<div v-if="showAddWidget" class="modal-overlay" @click.self="showAddWidget = false">
<div class="neu-card modal">
<h3>{{ t('dashboard.addWidget') }}</h3>
<div class="widget-types">
<button
v-for="type in availableWidgetTypes"
:key="type.id"
class="neu-btn widget-type-btn"
@click="addWidget(type)"
>
<span class="widget-type-icon">{{ type.icon }}</span>
<span>{{ t(type.label) }}</span>
</button>
</div>
<button class="neu-btn w-full" @click="showAddWidget = false">{{ t('common.cancel') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueDraggable } from 'vue-draggable-plus'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Widget {
id: number
type: string
title: string
config: Record<string, string>
width: number
height: number
}
const widgets = ref<Widget[]>([
{ id: 1, type: 'lxc_status', title: 'LXC Status', config: {}, width: 2, height: 2 },
{ id: 2, type: 'metrics', title: 'Métriques', config: {}, width: 1, height: 1 },
{ id: 3, type: 'shortcut', title: 'Proxmox', config: { url: 'https://proxmox.geronzi.fr', icon: '🖥️' }, width: 1, height: 1 },
{ id: 4, type: 'shortcut', title: 'Grafana', config: { url: 'https://grafana.geronzi.fr', icon: '📊' }, width: 1, height: 1 },
])
const proxmoxResources = ref<any[]>([])
const showAddWidget = ref(false)
const availableWidgetTypes = [
{ id: 'shortcut', icon: '🔗', label: 'dashboard.widgetShortcut' },
{ id: 'lxc_status', icon: '🖥️', label: 'dashboard.widgetLXC' },
{ id: 'metrics', icon: '📊', label: 'dashboard.widgetMetrics' },
]
let wsConnection: WebSocket | null = null
onMounted(async () => {
// Charger les données Proxmox
await loadProxmoxData()
// Connecter le WebSocket pour les mises à jour temps réel
connectWebSocket()
})
onUnmounted(() => {
wsConnection?.close()
})
async function loadProxmoxData() {
try {
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
proxmoxResources.value = await res.json() || []
}
} catch { /* Silencieux — affiché via le widget */ }
}
function connectWebSocket() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const token = authStore.accessToken
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${token}`)
wsConnection.onopen = () => {
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
}
wsConnection.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'resources_update' && msg.payload) {
proxmoxResources.value = msg.payload
}
} catch { /* Ignorer les messages invalides */ }
}
wsConnection.onerror = () => {
setTimeout(() => connectWebSocket(), 5000) // Reconnexion après 5s
}
}
function addWidget(type: { id: string; icon: string; label: string }) {
const newId = Date.now()
widgets.value.push({
id: newId,
type: type.id,
title: t(type.label),
config: type.id === 'shortcut' ? { url: 'https://example.com', icon: type.icon } : {},
width: 1,
height: 1,
})
showAddWidget.value = false
saveLayout()
}
function removeWidget(id: number) {
widgets.value = widgets.value.filter(w => w.id !== id)
saveLayout()
}
function saveLayout() {
// Sauvegarder via API (implémentation future avec endpoint dédié)
localStorage.setItem('pxp_dashboard_layout', JSON.stringify(widgets.value))
}
</script>
<style scoped>
.dashboard {
max-width: 1400px;
}
.dashboard__header {
margin-bottom: var(--neu-space-xl);
}
.dashboard__header h2 {
font-size: var(--neu-font-xl);
color: var(--neu-text);
}
.text-muted { color: var(--neu-text-muted); }
.text-success { color: var(--neu-success); }
.text-sm { font-size: var(--neu-font-sm); }
.dashboard__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--neu-space-md);
align-items: start;
}
.widget {
height: 100%;
}
.widget__header {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
margin-bottom: var(--neu-space-sm);
}
.widget-drag-handle {
cursor: grab;
color: var(--neu-text-muted);
font-size: 16px;
padding: 4px;
}
.widget-drag-handle:active { cursor: grabbing; }
.widget__title {
flex: 1;
font-weight: 600;
font-size: var(--neu-font-sm);
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.widget__remove {
opacity: 0;
width: 24px;
height: 24px;
transition: opacity 0.15s;
}
.widget:hover .widget__remove { opacity: 1; }
.shortcut-link {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
text-decoration: none;
color: var(--neu-text);
padding: var(--neu-space-sm);
border-radius: var(--neu-radius-md);
transition: var(--neu-transition);
}
.shortcut-link:hover {
background: var(--neu-bg);
}
.shortcut-icon { font-size: 24px; }
.shortcut-url {
font-size: var(--neu-font-sm);
color: var(--neu-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lxc-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.lxc-item {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
font-size: var(--neu-font-sm);
}
.lxc-name { flex: 1; color: var(--neu-text); }
.lxc-id { color: var(--neu-text-muted); font-family: monospace; }
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--neu-space-sm);
}
.metric-item {
text-align: center;
padding: var(--neu-space-sm);
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
}
.metric-label {
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.metric-value {
font-size: var(--neu-font-xl);
font-weight: 700;
color: var(--neu-text);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--neu-space-lg);
}
.modal {
width: 100%;
max-width: 360px;
}
.modal h3 {
margin-bottom: var(--neu-space-md);
color: var(--neu-text);
}
.widget-types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--neu-space-sm);
margin-bottom: var(--neu-space-md);
}
.widget-type-btn {
flex-direction: column;
gap: 4px;
height: 70px;
font-size: var(--neu-font-sm);
}
.widget-type-icon { font-size: 24px; }
</style>