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
393
frontend/src/views/Dashboard.vue
Normal file
393
frontend/src/views/Dashboard.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue