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

31
frontend/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# ── Étape 1 : Build du frontend Vue 3 + Vite ───────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /build
# Copier les fichiers de dépendances en premier
COPY package.json package-lock.json* ./
RUN npm install --frozen-lockfile
# Copier le code source et compiler
COPY . .
RUN npm run build
# ── Étape 2 : Image Nginx pour servir le frontend ──────────────────────────
FROM nginx:1.27-alpine
# Supprimer la config Nginx par défaut
RUN rm /etc/nginx/conf.d/default.conf
# Copier notre config Nginx personnalisée
COPY nginx.conf /etc/nginx/nginx.conf
# Copier les fichiers compilés par Vite
COPY --from=builder /build/dist /usr/share/nginx/html
# Vérifier la config Nginx au build
RUN nginx -t
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ProxmoxPanel — Interface de gestion d'infrastructure" />
<title>ProxmoxPanel</title>
<!-- Pas de CDN — tous les assets sont locaux -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

73
frontend/nginx.conf Normal file
View file

@ -0,0 +1,73 @@
# Configuration Nginx pour le frontend ProxmoxPanel
# Sert les fichiers statiques et proxy les requêtes API/WebSocket vers le backend Go
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logs au format JSON pour faciliter l'analyse
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# Compression gzip pour les assets statiques
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache agressif pour les assets avec hash dans le nom (Vite)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy des requêtes API vers le backend Go
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # Timeout long pour les mises à jour apt
}
# Proxy des connexions WebSocket
location /ws/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # 1 heure pour les sessions terminal
proxy_send_timeout 3600s;
}
# SPA : toutes les autres routes servent index.html (Vue Router)
location / {
try_files $uri $uri/ /index.html;
}
}
}

1994
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "proxmoxpanel-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"vue-i18n": "^11.0.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-attach": "^0.11.0",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.3",
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.8",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/theme-one-dark": "^6.1.2",
"vue-draggable-plus": "^0.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.3.3",
"vue-tsc": "^2.2.10"
}
}

49
frontend/src/App.vue Normal file
View file

@ -0,0 +1,49 @@
<template>
<!-- Applique le thème (dark/light) et la position de la sidebar via data-attributes -->
<div
:data-theme="uiStore.theme"
:data-sidebar="uiStore.sidebarPosition"
class="app-root"
>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const uiStore = useUiStore()
const authStore = useAuthStore()
onMounted(async () => {
// Restaurer le thème depuis localStorage
uiStore.initTheme()
// Tenter de restaurer la session (refresh token via cookie httpOnly)
await authStore.tryRefresh()
})
</script>
<style>
/* Reset minimal — pas de framework CSS externe */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app, .app-root {
height: 100%;
min-height: 100vh;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
transition: background-color 0.3s ease, color 0.3s ease;
}
</style>

View file

@ -0,0 +1,139 @@
<template>
<!-- Layout principal : sidebar + contenu principal -->
<div class="layout" :class="[`layout--sidebar-${uiStore.sidebarPosition}`, { 'layout--collapsed': uiStore.sidebarCollapsed }]">
<!-- Sidebar -->
<Sidebar class="layout__sidebar" />
<!-- Zone principale -->
<div class="layout__main">
<!-- Navbar supérieure -->
<Navbar class="layout__navbar" />
<!-- Contenu de la page -->
<main class="layout__content">
<RouterView v-slot="{ Component }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</main>
</div>
<!-- Overlay mobile -->
<div
v-if="uiStore.mobileMenuOpen"
class="layout__overlay"
@click="uiStore.mobileMenuOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router'
import { useUiStore } from '@/stores/ui.store'
import Sidebar from './Sidebar.vue'
import Navbar from './Navbar.vue'
const uiStore = useUiStore()
const route = useRoute()
</script>
<style scoped>
.layout {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--neu-bg);
}
/* Sidebar à gauche (défaut) */
.layout--sidebar-left {
flex-direction: row;
}
/* Sidebar à droite */
.layout--sidebar-right {
flex-direction: row-reverse;
}
.layout__sidebar {
flex-shrink: 0;
width: var(--sidebar-width);
transition: width 0.3s ease;
z-index: var(--z-sidebar);
}
.layout--collapsed .layout__sidebar {
width: var(--sidebar-width-collapsed);
}
.layout__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0; /* Empêche l'overflow en flex */
}
.layout__navbar {
flex-shrink: 0;
z-index: var(--z-navbar);
}
.layout__content {
flex: 1;
overflow-y: auto;
padding: var(--neu-space-lg);
}
.layout__overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: calc(var(--z-sidebar) - 1);
}
/* Animations de transition entre pages */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* Responsive mobile */
@media (max-width: 768px) {
.layout__overlay {
display: block;
}
.layout__sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.layout--sidebar-right .layout__sidebar {
left: auto;
right: 0;
transform: translateX(100%);
}
.layout__content {
padding: var(--neu-space-md);
}
}
</style>

View file

@ -0,0 +1,121 @@
<template>
<header class="navbar">
<!-- Bouton hamburger (mobile) -->
<button class="neu-btn neu-btn--icon neu-btn--ghost navbar__mobile-menu" @click="uiStore.mobileMenuOpen = !uiStore.mobileMenuOpen">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<!-- Titre de la page courante -->
<h1 class="navbar__title">{{ currentPageTitle }}</h1>
<!-- Actions navbar -->
<div class="navbar__actions">
<!-- Toggle thème sombre/clair -->
<button
class="neu-btn neu-btn--icon neu-btn--ghost"
:title="uiStore.theme === 'dark' ? t('navbar.lightMode') : t('navbar.darkMode')"
@click="uiStore.toggleTheme()"
>
<!-- Icône soleil (mode clair) / lune (mode sombre) -->
<svg v-if="uiStore.theme === 'dark'" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg v-else viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<!-- Langue -->
<select class="neu-input navbar__lang-select" :value="locale" @change="changeLang(($event.target as HTMLSelectElement).value)">
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
<!-- Déconnexion -->
<button class="neu-btn neu-btn--icon neu-btn--ghost" :title="t('navbar.logout')" @click="handleLogout">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const { t, locale } = useI18n()
const uiStore = useUiStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
// Titre de la page courante selon la route
const currentPageTitle = computed(() => {
const name = route.name as string
return t(`nav.${name}`, name || 'Dashboard')
})
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
function changeLang(lang: string) {
locale.value = lang
localStorage.setItem('pxp_locale', lang)
authStore.updatePreferences({ lang })
}
</script>
<style scoped>
.navbar {
display: flex;
align-items: center;
gap: var(--neu-space-md);
padding: 0 var(--neu-space-lg);
height: 64px;
background: var(--neu-surface);
border-bottom: 1px solid var(--neu-border);
flex-shrink: 0;
}
.navbar__title {
flex: 1;
font-size: var(--neu-font-xl);
font-weight: 600;
color: var(--neu-text);
}
.navbar__actions {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
}
.navbar__lang-select {
width: auto;
padding: 4px 8px;
font-size: var(--neu-font-sm);
cursor: pointer;
}
.navbar__mobile-menu {
display: none;
}
@media (max-width: 768px) {
.navbar__mobile-menu {
display: flex;
}
}
</style>

View file

@ -0,0 +1,310 @@
<template>
<aside class="sidebar">
<!-- En-tête sidebar avec logo + nom instance -->
<div class="sidebar__header">
<div class="sidebar__logo">
<div class="sidebar__logo-icon neu-card">PX</div>
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__logo-text">
{{ instanceName || 'ProxmoxPanel' }}
</span>
</Transition>
</div>
<!-- Bouton réduction sidebar -->
<button class="neu-btn neu-btn--icon neu-btn--ghost sidebar__collapse-btn" @click="uiStore.toggleSidebarCollapse()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="!uiStore.sidebarCollapsed" d="M15 18l-6-6 6-6"/>
<path v-else d="M9 18l6-6-6-6"/>
</svg>
</button>
</div>
<!-- Navigation principale -->
<nav class="sidebar__nav">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="item.path"
class="sidebar__nav-item"
:class="{ 'sidebar__nav-item--active': isActive(item.path) }"
:title="uiStore.sidebarCollapsed ? t(item.label) : undefined"
>
<span class="sidebar__nav-icon" v-html="item.icon" />
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__nav-label">
{{ t(item.label) }}
</span>
</Transition>
</RouterLink>
</nav>
<!-- Bas de la sidebar : infos utilisateur -->
<div class="sidebar__footer">
<div class="sidebar__user">
<div class="sidebar__user-avatar neu-card">
{{ authStore.user?.username?.[0]?.toUpperCase() || '?' }}
</div>
<Transition name="fade">
<div v-if="!uiStore.sidebarCollapsed" class="sidebar__user-info">
<div class="sidebar__user-name">{{ authStore.user?.username }}</div>
<div class="sidebar__user-role">
<span v-if="authStore.user?.is_admin" class="neu-badge neu-badge--primary">Admin</span>
<span v-else class="neu-badge neu-badge--info">User</span>
</div>
</div>
</Transition>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const uiStore = useUiStore()
const authStore = useAuthStore()
const route = useRoute()
// Récupérer le nom de l'instance depuis le localStorage (chargé au démarrage)
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
// Items de navigation les modules désactivés sont filtrés par le router
const navItems = computed(() => {
const items = [
{
name: 'dashboard',
path: '/',
label: 'nav.dashboard',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`,
},
{
name: 'proxmox',
path: '/proxmox',
label: 'nav.proxmox',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
},
{
name: 'updates',
path: '/updates',
label: 'nav.updates',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
},
{
name: 'terminal',
path: '/terminal',
label: 'nav.terminal',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
},
{
name: 'files',
path: '/files',
label: 'nav.files',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
},
{
name: 'logs',
path: '/logs',
label: 'nav.logs',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
},
{
name: 'services',
path: '/services',
label: 'nav.services',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93A10 10 0 1 0 4.93 19.07"/></svg>`,
},
{
name: 'settings',
path: '/settings',
label: 'nav.settings',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
},
]
// Afficher uniquement les routes admin si l'utilisateur est admin
if (authStore.user?.is_admin) {
items.push({
name: 'modules',
path: '/modules',
label: 'nav.modules',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 21h7v-7h-7z"/></svg>`,
})
}
return items
})
function isActive(path: string): boolean {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<style scoped>
.sidebar {
height: 100%;
display: flex;
flex-direction: column;
background: var(--neu-surface);
border-right: 1px solid var(--neu-border);
overflow: hidden;
}
[data-sidebar="right"] .sidebar {
border-right: none;
border-left: 1px solid var(--neu-border);
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--neu-space-md);
height: 64px;
flex-shrink: 0;
border-bottom: 1px solid var(--neu-border);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: var(--neu-radius-sm);
}
.sidebar__logo-text {
font-size: var(--neu-font-md);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__collapse-btn {
flex-shrink: 0;
opacity: 0.6;
}
.sidebar__collapse-btn:hover { opacity: 1; }
.sidebar__nav {
flex: 1;
padding: var(--neu-space-sm);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar__nav-item {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
padding: 10px var(--neu-space-sm);
border-radius: var(--neu-radius-md);
color: var(--neu-text-muted);
text-decoration: none;
transition: var(--neu-transition);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.sidebar__nav-item:hover {
background: var(--neu-bg);
color: var(--neu-text);
box-shadow:
3px 3px 6px var(--neu-shadow-dark),
-2px -2px 4px var(--neu-shadow-light);
}
.sidebar__nav-item--active {
background: var(--neu-bg);
color: var(--neu-primary);
box-shadow:
inset 2px 2px 5px var(--neu-shadow-dark),
inset -1px -1px 3px var(--neu-shadow-light);
}
.sidebar__nav-icon {
flex-shrink: 0;
display: flex;
align-items: center;
width: 18px;
}
.sidebar__nav-label {
font-size: var(--neu-font-md);
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__footer {
padding: var(--neu-space-sm) var(--neu-space-md);
border-top: 1px solid var(--neu-border);
}
.sidebar__user {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__user-avatar {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: 50%;
}
.sidebar__user-info {
overflow: hidden;
min-width: 0;
}
.sidebar__user-name {
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__user-role {
margin-top: 2px;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View file

@ -0,0 +1,140 @@
{
"nav": {
"dashboard": "Dashboard",
"proxmox": "Proxmox",
"updates": "Updates",
"terminal": "Terminal",
"files": "Files",
"logs": "Logs",
"services": "Services",
"settings": "Settings",
"modules": "Modules"
},
"navbar": {
"darkMode": "Dark mode",
"lightMode": "Light mode",
"logout": "Logout"
},
"install": {
"subtitle": "Initial configuration of the management panel",
"step1": { "label": "General", "title": "General configuration", "desc": "Configure the instance name and public URL." },
"step2": { "label": "SSH", "title": "SSH Connection", "desc": "Configure SSH access to the Proxmox server. These credentials will be used for authentication and management." },
"step3": { "label": "Proxmox", "title": "Proxmox API", "desc": "Optional — Configure the API token to access Proxmox metrics." },
"step4": { "label": "Confirm", "title": "Confirm configuration" },
"instanceName": "Instance name",
"instanceNamePlaceholder": "My Proxmox server",
"publicUrl": "Public URL",
"publicUrlHint": "Auto-detected: {url}",
"defaultLang": "Default language",
"sshHost": "SSH host (host:port)",
"sshUsername": "SSH username",
"sshPassword": "SSH password",
"testSSH": "Test SSH connection",
"sshSuccess": "SSH connection successful!",
"sshFailed": "SSH connection failed",
"proxmoxUrl": "Proxmox URL",
"proxmoxToken": "Proxmox API token",
"proxmoxTokenHint": "Format: PVEAPIToken=user@realm!tokenid=secret",
"back": "Back",
"next": "Next",
"finish": "Complete installation",
"error": "Installation error",
"networkError": "Network error"
},
"login": {
"subtitle": "Login with your Linux credentials",
"username": "Username",
"usernamePlaceholder": "Your Linux login",
"password": "Password",
"passwordPlaceholder": "Your password",
"submit": "Login",
"loading": "Logging in...",
"error": "Authentication error",
"hint": "Use your server's Linux credentials"
},
"dashboard": {
"welcome": "Hello, {name}",
"addWidget": "Add widget",
"lxcStatus": "LXC Status",
"metrics": "Metrics",
"noData": "No data available",
"lxcCount": "Total LXC",
"running": "Running",
"widgetShortcut": "Shortcut",
"widgetLXC": "LXC Status",
"widgetMetrics": "Metrics"
},
"proxmox": {
"all": "All",
"lxc": "LXC",
"vm": "VM",
"running": "Running",
"stopped": "Stopped",
"start": "Start",
"stop": "Stop",
"error": "Proxmox API error",
"liveUpdates": "Live updates",
"disconnected": "Disconnected"
},
"updates": {
"desc": "Run apt updates on the host or LXC containers.",
"selectTarget": "Select target",
"targetHost": "Proxmox Host",
"targetAll": "All LXC",
"start": "Start update",
"running": "Updating...",
"output": "Output",
"history": "History",
"noHistory": "No updates performed",
"status": {
"running": "Running",
"success": "Success",
"error": "Error",
"pending": "Pending"
}
},
"terminal": {
"connected": "Connected to {host}",
"disconnected": "Disconnected",
"connect": "Connect",
"reconnect": "Reconnect"
},
"files": {
"desc": "SFTP file browser",
"moduleNotEnabled": "Module not enabled. Go to Settings → Modules to enable it."
},
"settings": {
"general": "General",
"infrastructure": "Infrastructure",
"appearance": "Appearance",
"audit": "Audit log",
"instanceName": "Instance name",
"publicUrl": "Public URL",
"defaultLang": "Default language",
"sshHost": "SSH host",
"sshUsername": "SSH username",
"proxmoxUrl": "Proxmox URL",
"darkMode": "Dark mode",
"sidebarPosition": "Sidebar position",
"left": "Left",
"right": "Right",
"noAuditLog": "No audit log entries"
},
"modules": {
"desc": "Manage installed modules on ProxmoxPanel.",
"enabled": "Enabled",
"disabled": "Disabled",
"enable": "Enable",
"disable": "Disable",
"coreProtected": "CORE module (cannot be disabled)",
"restartNotice": "A server restart is required to apply changes."
},
"common": {
"refresh": "Refresh",
"save": "Save",
"saving": "Saving...",
"saved": "Saved!",
"cancel": "Cancel",
"networkError": "Network error"
}
}

View file

@ -0,0 +1,140 @@
{
"nav": {
"dashboard": "Tableau de bord",
"proxmox": "Proxmox",
"updates": "Mises à jour",
"terminal": "Terminal",
"files": "Fichiers",
"logs": "Journaux",
"services": "Services",
"settings": "Paramètres",
"modules": "Modules"
},
"navbar": {
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"logout": "Se déconnecter"
},
"install": {
"subtitle": "Configuration initiale du panneau de gestion",
"step1": { "label": "Général", "title": "Configuration générale", "desc": "Configurez le nom de l'instance et l'URL publique." },
"step2": { "label": "SSH", "title": "Connexion SSH", "desc": "Configurez l'accès SSH au serveur Proxmox. Ces identifiants seront utilisés pour l'authentification et la gestion." },
"step3": { "label": "Proxmox", "title": "API Proxmox", "desc": "Optionnel — Configurez le token API pour accéder aux métriques Proxmox." },
"step4": { "label": "Confirmation", "title": "Confirmer la configuration" },
"instanceName": "Nom de l'instance",
"instanceNamePlaceholder": "Mon serveur Proxmox",
"publicUrl": "URL publique",
"publicUrlHint": "Détecté automatiquement : {url}",
"defaultLang": "Langue par défaut",
"sshHost": "Hôte SSH (host:port)",
"sshUsername": "Nom d'utilisateur SSH",
"sshPassword": "Mot de passe SSH",
"testSSH": "Tester la connexion SSH",
"sshSuccess": "Connexion SSH réussie !",
"sshFailed": "Connexion SSH échouée",
"proxmoxUrl": "URL Proxmox",
"proxmoxToken": "Token API Proxmox",
"proxmoxTokenHint": "Format : PVEAPIToken=user@realm!tokenid=secret",
"back": "Retour",
"next": "Suivant",
"finish": "Terminer l'installation",
"error": "Erreur lors de l'installation",
"networkError": "Erreur réseau"
},
"login": {
"subtitle": "Connectez-vous avec vos identifiants Linux",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "Votre login Linux",
"password": "Mot de passe",
"passwordPlaceholder": "Votre mot de passe",
"submit": "Se connecter",
"loading": "Connexion...",
"error": "Erreur d'authentification",
"hint": "Utilisez vos identifiants Linux du serveur"
},
"dashboard": {
"welcome": "Bonjour, {name}",
"addWidget": "Ajouter un widget",
"lxcStatus": "Statut LXC",
"metrics": "Métriques",
"noData": "Données non disponibles",
"lxcCount": "LXC Total",
"running": "En cours",
"widgetShortcut": "Raccourci",
"widgetLXC": "Statut LXC",
"widgetMetrics": "Métriques"
},
"proxmox": {
"all": "Tous",
"lxc": "LXC",
"vm": "VM",
"running": "En marche",
"stopped": "Arrêté",
"start": "Démarrer",
"stop": "Arrêter",
"error": "Erreur API Proxmox",
"liveUpdates": "Mises à jour en temps réel",
"disconnected": "Déconnecté"
},
"updates": {
"desc": "Lancez des mises à jour apt sur le host ou les LXC.",
"selectTarget": "Sélectionner la cible",
"targetHost": "Host Proxmox",
"targetAll": "Tous les LXC",
"start": "Lancer la mise à jour",
"running": "Mise à jour en cours...",
"output": "Sortie",
"history": "Historique",
"noHistory": "Aucune mise à jour effectuée",
"status": {
"running": "En cours",
"success": "Succès",
"error": "Erreur",
"pending": "En attente"
}
},
"terminal": {
"connected": "Connecté à {host}",
"disconnected": "Déconnecté",
"connect": "Connecter",
"reconnect": "Reconnecter"
},
"files": {
"desc": "Navigateur de fichiers SFTP",
"moduleNotEnabled": "Module non activé. Rendez-vous dans Paramètres → Modules pour l'activer."
},
"settings": {
"general": "Général",
"infrastructure": "Infrastructure",
"appearance": "Apparence",
"audit": "Journal d'audit",
"instanceName": "Nom de l'instance",
"publicUrl": "URL publique",
"defaultLang": "Langue par défaut",
"sshHost": "Hôte SSH",
"sshUsername": "Utilisateur SSH",
"proxmoxUrl": "URL Proxmox",
"darkMode": "Mode sombre",
"sidebarPosition": "Position de la sidebar",
"left": "Gauche",
"right": "Droite",
"noAuditLog": "Aucune entrée dans le journal"
},
"modules": {
"desc": "Gérez les modules installés sur ProxmoxPanel.",
"enabled": "Actif",
"disabled": "Inactif",
"enable": "Activer",
"disable": "Désactiver",
"coreProtected": "Module CORE (non désactivable)",
"restartNotice": "Un redémarrage du serveur est nécessaire pour appliquer les changements."
},
"common": {
"refresh": "Actualiser",
"save": "Sauvegarder",
"saving": "Sauvegarde...",
"saved": "Sauvegardé !",
"cancel": "Annuler",
"networkError": "Erreur réseau"
}
}

37
frontend/src/main.ts Normal file
View file

@ -0,0 +1,37 @@
// Point d'entrée de l'application ProxmoxPanel Frontend.
// Initialise Vue 3, Pinia, Vue Router et vue-i18n.
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
import router from './router/index'
// Imports des fichiers de traduction (locaux, pas de CDN)
import fr from './locales/fr.json'
import en from './locales/en.json'
// Styles Neumorphism — chargés globalement
import './styles/neu.css'
import './styles/dark.css'
import './styles/light.css'
// Déterminer la locale initiale (localStorage > défaut 'fr')
const savedLocale = localStorage.getItem('pxp_locale') || 'fr'
// Initialisation vue-i18n
const i18n = createI18n({
legacy: false, // Utiliser la Composition API
locale: savedLocale,
fallbackLocale: 'en',
messages: { fr, en },
})
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(i18n)
app.mount('#app')

View file

@ -0,0 +1,128 @@
// Configuration du routeur Vue — gère la navigation et la protection des routes.
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const router = createRouter({
history: createWebHistory(),
routes: [
// Page d'installation (premier lancement)
{
path: '/install',
name: 'install',
component: () => import('@/views/Install.vue'),
meta: { public: true, hideLayout: true },
},
// Authentification
{
path: '/login',
name: 'login',
component: () => import('@/views/Login.vue'),
meta: { public: true, hideLayout: true },
},
// Application principale (protégée)
{
path: '/',
component: () => import('@/components/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'proxmox',
name: 'proxmox',
component: () => import('@/views/Proxmox.vue'),
},
{
path: 'updates',
name: 'updates',
component: () => import('@/views/Updates.vue'),
},
{
path: 'files',
name: 'files',
component: () => import('@/views/Files.vue'),
meta: { module: 'files' },
},
{
path: 'terminal',
name: 'terminal',
component: () => import('@/views/Terminal.vue'),
meta: { module: 'terminal' },
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/Logs.vue'),
meta: { module: 'logs' },
},
{
path: 'services',
name: 'services',
component: () => import('@/views/Services.vue'),
meta: { module: 'services' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/Settings.vue'),
},
{
path: 'modules',
name: 'modules',
component: () => import('@/views/Modules.vue'),
meta: { requiresAdmin: true },
},
],
},
// Redirection 404
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
})
// Guard de navigation : vérification authentification et installation
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// Vérifier si l'application est installée (appel API au premier chargement)
if (!authStore.installChecked) {
await authStore.checkInstallation()
}
// Rediriger vers l'installation si pas encore configuré
if (!authStore.isInstalled && to.name !== 'install') {
return { name: 'install' }
}
// Si installé et route d'install → rediriger vers le dashboard
if (authStore.isInstalled && to.name === 'install') {
return { name: 'dashboard' }
}
// Routes publiques — passer directement
if (to.meta.public) return true
// Routes protégées — vérifier l'authentification
if (to.meta.requiresAuth || to.matched.some(r => r.meta.requiresAuth)) {
if (!authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
// Routes admin uniquement
if (to.meta.requiresAdmin && !authStore.user?.is_admin) {
return { name: 'dashboard' }
}
return true
})
export default router

View file

@ -0,0 +1,180 @@
// Store d'authentification — gère la session JWT, le profil utilisateur et l'état d'installation.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface User {
id: number
username: string
is_admin: boolean
lang: string
theme: string
sidebar_position: string
}
export const useAuthStore = defineStore('auth', () => {
// État
const user = ref<User | null>(null)
const accessToken = ref<string | null>(localStorage.getItem('pxp_token'))
const isInstalled = ref(false)
const installChecked = ref(false)
// Computed
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
// ── Actions ──────────────────────────────────────────────────────────────
/**
* Vérifie si l'application est installée via l'API.
* Appelé une seule fois au démarrage par le router guard.
*/
async function checkInstallation(): Promise<void> {
try {
const res = await fetch('/api/install/check')
if (res.ok) {
const data = await res.json()
isInstalled.value = data.installed
}
} catch {
// En cas d'erreur réseau, on suppose installé pour éviter une boucle
isInstalled.value = true
} finally {
installChecked.value = true
}
}
/**
* Authentifie l'utilisateur avec ses credentials Linux.
*/
async function login(username: string, password: string): Promise<void> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Erreur d\'authentification')
}
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
user.value = data.user
// Planifier le renouvellement automatique avant expiration (14 min)
scheduleRefresh(14 * 60 * 1000)
}
/**
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
* Appelé au démarrage de l'application.
*/
async function tryRefresh(): Promise<void> {
const token = localStorage.getItem('pxp_token')
if (!token) return
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Inclure le cookie httpOnly
})
if (res.ok) {
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
// Charger le profil utilisateur
await fetchMe()
scheduleRefresh(14 * 60 * 1000)
} else {
// Refresh échoué — nettoyer la session
clearSession()
}
} catch {
clearSession()
}
}
/**
* Charge le profil de l'utilisateur connecté.
*/
async function fetchMe(): Promise<void> {
if (!accessToken.value) return
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${accessToken.value}` },
})
if (res.ok) {
user.value = await res.json()
}
}
/**
* Déconnecte l'utilisateur.
*/
async function logout(): Promise<void> {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.value}` },
credentials: 'include',
})
} finally {
clearSession()
}
}
/**
* Met à jour les préférences de l'utilisateur (thème, langue, sidebar).
*/
async function updatePreferences(prefs: Partial<Pick<User, 'lang' | 'theme' | 'sidebar_position'>>): Promise<void> {
if (!accessToken.value) return
await fetch('/api/auth/preferences', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken.value}`,
},
body: JSON.stringify(prefs),
})
// Mettre à jour localement
if (user.value) {
Object.assign(user.value, prefs)
}
}
// ── Helpers privés ────────────────────────────────────────────────────────
let refreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleRefresh(delayMs: number): void {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => tryRefresh(), delayMs)
}
function clearSession(): void {
user.value = null
accessToken.value = null
localStorage.removeItem('pxp_token')
if (refreshTimer) clearTimeout(refreshTimer)
}
return {
user,
accessToken,
isInstalled,
installChecked,
isAuthenticated,
checkInstallation,
login,
logout,
tryRefresh,
fetchMe,
updatePreferences,
}
})

View file

@ -0,0 +1,84 @@
// Store UI — gère le thème (dark/light) et la position de la sidebar.
// Les préférences sont persistées localement et synchronisées avec le serveur.
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type Theme = 'dark' | 'light'
export type SidebarPosition = 'left' | 'right'
export const useUiStore = defineStore('ui', () => {
const theme = ref<Theme>('dark')
const sidebarPosition = ref<SidebarPosition>('left')
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
/**
* Initialise le thème depuis les préférences locales.
* Appelé au montage de App.vue.
*/
function initTheme(): void {
const savedTheme = localStorage.getItem('pxp_theme') as Theme | null
const savedSidebar = localStorage.getItem('pxp_sidebar') as SidebarPosition | null
if (savedTheme === 'dark' || savedTheme === 'light') {
theme.value = savedTheme
}
if (savedSidebar === 'left' || savedSidebar === 'right') {
sidebarPosition.value = savedSidebar
}
applyTheme(theme.value)
}
/**
* Bascule entre thème sombre et clair.
*/
function toggleTheme(): void {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem('pxp_theme', theme.value)
applyTheme(theme.value)
}
/**
* Définit le thème explicitement.
*/
function setTheme(newTheme: Theme): void {
theme.value = newTheme
localStorage.setItem('pxp_theme', newTheme)
applyTheme(newTheme)
}
/**
* Définit la position de la sidebar.
*/
function setSidebarPosition(pos: SidebarPosition): void {
sidebarPosition.value = pos
localStorage.setItem('pxp_sidebar', pos)
}
/**
* Bascule l'état réduit de la sidebar.
*/
function toggleSidebarCollapse(): void {
sidebarCollapsed.value = !sidebarCollapsed.value
}
/**
* Applique le thème sur l'élément <html> via data-theme.
*/
function applyTheme(t: Theme): void {
document.documentElement.setAttribute('data-theme', t)
}
return {
theme,
sidebarPosition,
sidebarCollapsed,
mobileMenuOpen,
initTheme,
toggleTheme,
setTheme,
setSidebarPosition,
toggleSidebarCollapse,
}
})

View file

@ -0,0 +1,46 @@
/* ============================================================
ProxmoxPanel Thème sombre (mode par défaut)
============================================================ */
[data-theme="dark"],
:root {
--neu-bg: #1a1d2e;
--neu-surface: #212438;
--neu-text: #e2e6f6;
--neu-text-muted: #6b7694;
--neu-border: #2a2f4a;
--neu-shadow-dark: #13162280;
--neu-shadow-light: #2d3356;
--neu-primary: #6c8ef4;
--neu-primary-dim: #4a6bd4;
--neu-success: #4cbb8a;
--neu-warning: #f5a623;
--neu-danger: #f05c6b;
--neu-info: #3dbfcf;
color-scheme: dark;
}
/* Corps de page */
[data-theme="dark"] body {
background-color: var(--neu-bg);
color: var(--neu-text);
}
/* Accents spécifiques au mode sombre */
[data-theme="dark"] .neu-card {
background: var(--neu-surface);
}
[data-theme="dark"] .neu-input {
background: var(--neu-bg);
color: var(--neu-text);
}
/* Sélections */
[data-theme="dark"] ::selection {
background: rgba(108, 142, 244, 0.3);
color: var(--neu-text);
}

View file

@ -0,0 +1,70 @@
/* ============================================================
ProxmoxPanel Thème clair
============================================================ */
[data-theme="light"] {
--neu-bg: #e8ecf2;
--neu-surface: #eef1f8;
--neu-text: #2d3561;
--neu-text-muted: #8892b0;
--neu-border: #d4d9e8;
--neu-shadow-dark: #c8cdd8;
--neu-shadow-light: #ffffff;
--neu-primary: #4a6bd4;
--neu-primary-dim: #3558c0;
--neu-success: #2ea87a;
--neu-warning: #d4860e;
--neu-danger: #d43f52;
--neu-info: #1fa8bc;
color-scheme: light;
}
[data-theme="light"] body {
background-color: var(--neu-bg);
color: var(--neu-text);
}
[data-theme="light"] .neu-card {
background: var(--neu-surface);
box-shadow:
5px 5px 10px var(--neu-shadow-dark),
-5px -5px 10px var(--neu-shadow-light);
}
[data-theme="light"] .neu-inset {
background: var(--neu-bg);
box-shadow:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -3px -3px 7px var(--neu-shadow-light);
}
[data-theme="light"] .neu-btn {
box-shadow:
4px 4px 8px var(--neu-shadow-dark),
-4px -4px 8px var(--neu-shadow-light);
}
[data-theme="light"] .neu-input {
background: var(--neu-bg);
color: var(--neu-text);
box-shadow:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -3px -3px 7px var(--neu-shadow-light);
}
[data-theme="light"] ::selection {
background: rgba(74, 107, 212, 0.25);
color: var(--neu-text);
}
/* Scrollbar thème clair */
[data-theme="light"] ::-webkit-scrollbar-track {
background: var(--neu-bg);
}
[data-theme="light"] ::-webkit-scrollbar-thumb {
background: var(--neu-border);
}

372
frontend/src/styles/neu.css Normal file
View file

@ -0,0 +1,372 @@
/* =============================================================================
ProxmoxPanel Système de design Neumorphism
Définit les classes utilitaires et les variables CSS du thème.
Utilisé par tous les composants et les modules.
============================================================================= */
/* ── Variables CSS (surchargées par dark.css et light.css) ─────────────────── */
:root {
/* Couleurs de base */
--neu-bg: #1e2130;
--neu-surface: #252838;
--neu-text: #e0e4f0;
--neu-text-muted: #7f8899;
--neu-border: #2e3348;
/* Ombres Neumorphism */
--neu-shadow-dark: #161823;
--neu-shadow-light: #2a2f48;
/* Couleurs d'accent */
--neu-primary: #6c8ef4;
--neu-primary-dim: #4a6bd4;
--neu-success: #4cbb8a;
--neu-warning: #f5a623;
--neu-danger: #f05c6b;
--neu-info: #3dbfcf;
/* Typographie */
--neu-font-xs: 11px;
--neu-font-sm: 12px;
--neu-font-md: 14px;
--neu-font-lg: 16px;
--neu-font-xl: 20px;
--neu-font-2xl: 24px;
/* Espacements */
--neu-space-xs: 4px;
--neu-space-sm: 8px;
--neu-space-md: 16px;
--neu-space-lg: 24px;
--neu-space-xl: 32px;
/* Rayons */
--neu-radius-sm: 8px;
--neu-radius-md: 12px;
--neu-radius-lg: 16px;
--neu-radius-xl: 24px;
--neu-radius-full: 9999px;
/* Transitions */
--neu-transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
/* Z-index */
--z-sidebar: 100;
--z-navbar: 200;
--z-modal: 300;
--z-toast: 400;
}
/* ── Carte Neumorphism (élévation convexe) ─────────────────────────────────── */
.neu-card {
background: var(--neu-surface);
border-radius: var(--neu-radius-lg);
box-shadow:
6px 6px 12px var(--neu-shadow-dark),
-4px -4px 10px var(--neu-shadow-light);
border: 1px solid var(--neu-border);
padding: var(--neu-space-md);
color: var(--neu-text);
transition: var(--neu-transition);
}
.neu-card--flat {
box-shadow: none;
border: 1px solid var(--neu-border);
}
.neu-card--hover:hover {
box-shadow:
8px 8px 16px var(--neu-shadow-dark),
-6px -6px 14px var(--neu-shadow-light);
transform: translateY(-1px);
}
/* ── Surface enfoncée (inputs, zones de saisie) ────────────────────────────── */
.neu-inset {
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
box-shadow:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -2px -2px 5px var(--neu-shadow-light);
border: 1px solid var(--neu-border);
}
/* ── Boutons ────────────────────────────────────────────────────────────────── */
.neu-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--neu-space-xs);
padding: var(--neu-space-sm) var(--neu-space-md);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-md);
font-weight: 500;
cursor: pointer;
border: none;
outline: none;
transition: var(--neu-transition);
text-decoration: none;
white-space: nowrap;
user-select: none;
color: var(--neu-text);
background: var(--neu-surface);
box-shadow:
4px 4px 8px var(--neu-shadow-dark),
-3px -3px 6px var(--neu-shadow-light);
}
.neu-btn:hover:not(:disabled) {
box-shadow:
6px 6px 12px var(--neu-shadow-dark),
-4px -4px 8px var(--neu-shadow-light);
transform: translateY(-1px);
}
.neu-btn:active:not(:disabled) {
box-shadow:
inset 2px 2px 5px var(--neu-shadow-dark),
inset -1px -1px 3px var(--neu-shadow-light);
transform: translateY(0);
}
.neu-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variantes de boutons */
.neu-btn--primary {
background: var(--neu-primary);
color: #fff;
box-shadow:
4px 4px 8px rgba(0,0,0,0.3),
-2px -2px 6px rgba(255,255,255,0.05);
}
.neu-btn--primary:hover:not(:disabled) {
background: var(--neu-primary-dim);
box-shadow:
6px 6px 12px rgba(0,0,0,0.4),
-3px -3px 8px rgba(255,255,255,0.06);
}
.neu-btn--success {
background: var(--neu-success);
color: #fff;
}
.neu-btn--danger {
background: var(--neu-danger);
color: #fff;
}
.neu-btn--warning {
background: var(--neu-warning);
color: #1a1a2e;
}
.neu-btn--sm {
padding: 4px 10px;
font-size: var(--neu-font-sm);
border-radius: var(--neu-radius-sm);
}
.neu-btn--lg {
padding: 12px 24px;
font-size: var(--neu-font-lg);
border-radius: var(--neu-radius-lg);
}
.neu-btn--icon {
width: 40px;
height: 40px;
padding: 0;
border-radius: var(--neu-radius-md);
}
.neu-btn--ghost {
background: transparent;
box-shadow: none;
border: 1px solid var(--neu-border);
}
.neu-btn--ghost:hover:not(:disabled) {
background: var(--neu-surface);
box-shadow:
2px 2px 5px var(--neu-shadow-dark),
-1px -1px 3px var(--neu-shadow-light);
}
/* ── Inputs ─────────────────────────────────────────────────────────────────── */
.neu-input {
width: 100%;
padding: var(--neu-space-sm) var(--neu-space-md);
background: var(--neu-bg);
color: var(--neu-text);
border: 1px solid var(--neu-border);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-md);
outline: none;
transition: var(--neu-transition);
box-shadow:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -2px -2px 5px var(--neu-shadow-light);
}
.neu-input:focus {
border-color: var(--neu-primary);
box-shadow:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -2px -2px 5px var(--neu-shadow-light),
0 0 0 2px rgba(108, 142, 244, 0.25);
}
.neu-input::placeholder {
color: var(--neu-text-muted);
}
.neu-input--error {
border-color: var(--neu-danger) !important;
}
/* ── Toggle switch ───────────────────────────────────────────────────────────── */
.neu-toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.neu-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.neu-toggle__slider {
position: absolute;
inset: 0;
background: var(--neu-bg);
border-radius: var(--neu-radius-full);
cursor: pointer;
transition: var(--neu-transition);
box-shadow:
inset 2px 2px 5px var(--neu-shadow-dark),
inset -1px -1px 3px var(--neu-shadow-light);
}
.neu-toggle__slider::before {
content: '';
position: absolute;
width: 18px;
height: 18px;
left: 3px;
bottom: 3px;
background: var(--neu-text-muted);
border-radius: 50%;
transition: var(--neu-transition);
box-shadow: 2px 2px 4px var(--neu-shadow-dark);
}
.neu-toggle input:checked + .neu-toggle__slider {
background: var(--neu-primary);
box-shadow:
inset 2px 2px 5px rgba(0,0,0,0.3),
inset -1px -1px 3px rgba(255,255,255,0.1);
}
.neu-toggle input:checked + .neu-toggle__slider::before {
transform: translateX(20px);
background: #fff;
}
/* ── Badges / Tags ───────────────────────────────────────────────────────────── */
.neu-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--neu-radius-full);
font-size: var(--neu-font-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.neu-badge--success { background: rgba(76, 187, 138, 0.15); color: var(--neu-success); }
.neu-badge--danger { background: rgba(240, 92, 107, 0.15); color: var(--neu-danger); }
.neu-badge--warning { background: rgba(245, 166, 35, 0.15); color: var(--neu-warning); }
.neu-badge--info { background: rgba(61, 191, 207, 0.15); color: var(--neu-info); }
.neu-badge--primary { background: rgba(108, 142, 244, 0.15); color: var(--neu-primary); }
/* ── Séparateurs ────────────────────────────────────────────────────────────── */
.neu-divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--neu-border), transparent);
border: none;
margin: var(--neu-space-md) 0;
}
/* ── Utilitaires de layout ──────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-sm { gap: var(--neu-space-sm); }
.gap-md { gap: var(--neu-space-md); }
.gap-lg { gap: var(--neu-space-lg); }
.w-full { width: 100%; }
.h-full { height: 100%; }
/* ── Grid responsive ────────────────────────────────────────────────────────── */
.neu-grid {
display: grid;
gap: var(--neu-space-md);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* ── Scrollbar personnalisée ────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track { background: var(--neu-bg); }
::-webkit-scrollbar-thumb {
background: var(--neu-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: var(--neu-text-muted); }
/* ── Animations ─────────────────────────────────────────────────────────────── */
@keyframes neu-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes neu-spin {
to { transform: rotate(360deg); }
}
.neu-loading {
animation: neu-spin 1s linear infinite;
}
.neu-pulse {
animation: neu-pulse 2s ease-in-out infinite;
}
/* ── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.neu-grid {
grid-template-columns: 1fr;
}
:root {
--sidebar-width: 100%;
}
}

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>

View file

@ -0,0 +1,25 @@
<template>
<div class="files-page">
<div class="page-header">
<h2>{{ t('nav.files') }}</h2>
<p class="text-muted">{{ t('files.desc') }}</p>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.files-page { max-width: 1400px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -0,0 +1,480 @@
<template>
<!-- Page d'installation wizard multi-étapes -->
<div class="install-page">
<div class="install-container">
<!-- En-tête -->
<div class="install-header">
<div class="install-logo">PX</div>
<h1>ProxmoxPanel</h1>
<p class="install-subtitle">{{ t('install.subtitle') }}</p>
</div>
<!-- Indicateur de progression -->
<div class="install-steps">
<div
v-for="(step, i) in steps"
:key="i"
class="install-step"
:class="{
'install-step--active': currentStep === i,
'install-step--done': currentStep > i,
}"
>
<div class="install-step__dot">
<svg v-if="currentStep > i" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span v-else>{{ i + 1 }}</span>
</div>
<span class="install-step__label">{{ t(step.label) }}</span>
</div>
</div>
<!-- Contenu des étapes -->
<div class="neu-card install-card">
<!-- Étape 1 : Configuration générale -->
<div v-if="currentStep === 0">
<h2>{{ t('install.step1.title') }}</h2>
<p class="step-desc">{{ t('install.step1.desc') }}</p>
<div class="form-group">
<label>{{ t('install.instanceName') }}</label>
<input v-model="form.instanceName" class="neu-input" :placeholder="t('install.instanceNamePlaceholder')" />
</div>
<div class="form-group">
<label>{{ t('install.publicUrl') }}</label>
<input v-model="form.publicUrl" class="neu-input" :placeholder="detectedURL" />
<small>{{ t('install.publicUrlHint', { url: detectedURL }) }}</small>
</div>
<div class="form-group">
<label>{{ t('install.defaultLang') }}</label>
<select v-model="form.defaultLang" class="neu-input">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
<!-- Étape 2 : Configuration SSH -->
<div v-if="currentStep === 1">
<h2>{{ t('install.step2.title') }}</h2>
<p class="step-desc">{{ t('install.step2.desc') }}</p>
<div class="form-group">
<label>{{ t('install.sshHost') }}</label>
<input v-model="form.sshHost" class="neu-input" placeholder="10.0.0.1:2244" />
</div>
<div class="form-group">
<label>{{ t('install.sshUsername') }}</label>
<input v-model="form.sshUsername" class="neu-input" placeholder="enzo" />
</div>
<div class="form-group">
<label>{{ t('install.sshPassword') }}</label>
<input v-model="form.sshPassword" type="password" class="neu-input" />
</div>
<!-- Bouton test SSH -->
<button
class="neu-btn neu-btn--primary"
:disabled="testingSSH || !form.sshHost || !form.sshUsername || !form.sshPassword"
@click="testSSH"
>
<span v-if="testingSSH" class="neu-loading"></span>
{{ t('install.testSSH') }}
</button>
<div v-if="sshTestResult" :class="['install-result', sshTestResult.success ? 'install-result--success' : 'install-result--error']">
{{ sshTestResult.message }}
</div>
</div>
<!-- Étape 3 : Token Proxmox -->
<div v-if="currentStep === 2">
<h2>{{ t('install.step3.title') }}</h2>
<p class="step-desc">{{ t('install.step3.desc') }}</p>
<div class="form-group">
<label>{{ t('install.proxmoxUrl') }}</label>
<input v-model="form.proxmoxUrl" class="neu-input" placeholder="https://10.0.0.1:8006" />
</div>
<div class="form-group">
<label>{{ t('install.proxmoxToken') }}</label>
<input v-model="form.proxmoxToken" class="neu-input" placeholder="PVEAPIToken=enzo@pam!panel=xxxx" />
<small>{{ t('install.proxmoxTokenHint') }}</small>
</div>
</div>
<!-- Étape 4 : Confirmation -->
<div v-if="currentStep === 3">
<h2>{{ t('install.step4.title') }}</h2>
<div class="install-summary">
<div class="summary-item">
<span class="summary-key">{{ t('install.instanceName') }}</span>
<span class="summary-value">{{ form.instanceName }}</span>
</div>
<div class="summary-item">
<span class="summary-key">{{ t('install.sshHost') }}</span>
<span class="summary-value">{{ form.sshHost }}</span>
</div>
<div class="summary-item">
<span class="summary-key">{{ t('install.defaultLang') }}</span>
<span class="summary-value">{{ form.defaultLang === 'fr' ? 'Français' : 'English' }}</span>
</div>
</div>
</div>
<!-- Erreur globale -->
<div v-if="error" class="install-result install-result--error">{{ error }}</div>
<!-- Actions navigation -->
<div class="install-actions">
<button v-if="currentStep > 0" class="neu-btn" @click="currentStep--">
{{ t('install.back') }}
</button>
<div class="spacer" />
<button
v-if="currentStep < steps.length - 1"
class="neu-btn neu-btn--primary"
:disabled="!canProceed"
@click="nextStep"
>
{{ t('install.next') }}
</button>
<button
v-else
class="neu-btn neu-btn--success"
:disabled="installing"
@click="finalize"
>
<span v-if="installing" class="neu-loading"></span>
{{ t('install.finish') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const currentStep = ref(0)
const detectedURL = ref('')
const testingSSH = ref(false)
const installing = ref(false)
const error = ref('')
const sshTestResult = ref<{ success: boolean; message: string } | null>(null)
const steps = [
{ label: 'install.step1.label' },
{ label: 'install.step2.label' },
{ label: 'install.step3.label' },
{ label: 'install.step4.label' },
]
const form = ref({
instanceName: 'ProxmoxPanel',
publicUrl: '',
defaultLang: 'fr',
sshHost: '10.0.0.1:2244',
sshUsername: 'enzo',
sshPassword: '',
proxmoxUrl: 'https://10.0.0.1:8006',
proxmoxToken: '',
})
const canProceed = computed(() => {
switch (currentStep.value) {
case 0: return !!form.value.instanceName
case 1: return sshTestResult.value?.success === true
case 2: return true // Token Proxmox optionnel
default: return true
}
})
onMounted(async () => {
// Récupérer les valeurs pré-remplies depuis l'API
try {
const res = await fetch('/api/install/status')
if (res.ok) {
const data = await res.json()
detectedURL.value = data.detected_url || window.location.origin
form.value.publicUrl = detectedURL.value
}
} catch {
detectedURL.value = window.location.origin
form.value.publicUrl = window.location.origin
}
})
async function testSSH() {
testingSSH.value = true
sshTestResult.value = null
try {
const res = await fetch('/api/install/test-ssh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: form.value.sshHost,
username: form.value.sshUsername,
password: form.value.sshPassword,
}),
})
const data = await res.json()
sshTestResult.value = {
success: data.success,
message: data.success ? t('install.sshSuccess') : (data.error || t('install.sshFailed')),
}
} catch (e) {
sshTestResult.value = { success: false, message: t('install.networkError') }
} finally {
testingSSH.value = false
}
}
function nextStep() {
if (currentStep.value < steps.length - 1) {
currentStep.value++
sshTestResult.value = null
error.value = ''
}
}
async function finalize() {
installing.value = true
error.value = ''
try {
const res = await fetch('/api/install/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instance_name: form.value.instanceName,
public_url: form.value.publicUrl || detectedURL.value,
default_lang: form.value.defaultLang,
ssh_host: form.value.sshHost,
ssh_username: form.value.sshUsername,
ssh_password: form.value.sshPassword,
proxmox_url: form.value.proxmoxUrl,
proxmox_token: form.value.proxmoxToken,
}),
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || t('install.error')
return
}
// Marquer comme installé et rediriger vers le login
authStore.isInstalled = true
localStorage.setItem('pxp_instance_name', form.value.instanceName)
router.push('/login')
} catch (e) {
error.value = t('install.networkError')
} finally {
installing.value = false
}
}
</script>
<style scoped>
.install-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--neu-bg);
padding: var(--neu-space-lg);
}
.install-container {
width: 100%;
max-width: 520px;
}
.install-header {
text-align: center;
margin-bottom: var(--neu-space-xl);
}
.install-logo {
width: 64px;
height: 64px;
background: var(--neu-primary);
color: #fff;
font-size: 24px;
font-weight: 700;
border-radius: var(--neu-radius-lg);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--neu-space-md);
box-shadow: 4px 4px 12px rgba(108, 142, 244, 0.4);
}
.install-header h1 {
font-size: var(--neu-font-2xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.install-subtitle {
color: var(--neu-text-muted);
font-size: var(--neu-font-md);
}
.install-steps {
display: flex;
justify-content: space-between;
margin-bottom: var(--neu-space-lg);
position: relative;
}
.install-steps::before {
content: '';
position: absolute;
top: 14px;
left: 14px;
right: 14px;
height: 2px;
background: var(--neu-border);
z-index: 0;
}
.install-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 1;
}
.install-step__dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: var(--neu-surface);
border: 2px solid var(--neu-border);
color: var(--neu-text-muted);
transition: var(--neu-transition);
}
.install-step--active .install-step__dot {
border-color: var(--neu-primary);
color: var(--neu-primary);
}
.install-step--done .install-step__dot {
background: var(--neu-success);
border-color: var(--neu-success);
color: #fff;
}
.install-step__label {
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
}
.install-step--active .install-step__label {
color: var(--neu-primary);
font-weight: 600;
}
.install-card {
padding: var(--neu-space-xl);
}
.install-card h2 {
font-size: var(--neu-font-xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.step-desc {
color: var(--neu-text-muted);
margin-bottom: var(--neu-space-lg);
}
.form-group {
margin-bottom: var(--neu-space-md);
}
.form-group label {
display: block;
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--neu-space-xs);
}
.form-group small {
display: block;
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
margin-top: 4px;
}
.install-result {
margin: var(--neu-space-md) 0;
padding: var(--neu-space-sm) var(--neu-space-md);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-sm);
}
.install-result--success {
background: rgba(76, 187, 138, 0.1);
color: var(--neu-success);
border: 1px solid rgba(76, 187, 138, 0.3);
}
.install-result--error {
background: rgba(240, 92, 107, 0.1);
color: var(--neu-danger);
border: 1px solid rgba(240, 92, 107, 0.3);
}
.install-summary {
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
padding: var(--neu-space-md);
margin-bottom: var(--neu-space-lg);
}
.summary-item {
display: flex;
justify-content: space-between;
padding: var(--neu-space-xs) 0;
border-bottom: 1px solid var(--neu-border);
font-size: var(--neu-font-sm);
}
.summary-item:last-child { border-bottom: none; }
.summary-key { color: var(--neu-text-muted); }
.summary-value { color: var(--neu-text); font-weight: 500; }
.install-actions {
display: flex;
align-items: center;
margin-top: var(--neu-space-xl);
gap: var(--neu-space-sm);
}
.spacer { flex: 1; }
</style>

View file

@ -0,0 +1,185 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- Logo + titre -->
<div class="login-header">
<div class="login-logo">PX</div>
<h1>{{ instanceName || 'ProxmoxPanel' }}</h1>
<p>{{ t('login.subtitle') }}</p>
</div>
<!-- Formulaire -->
<form class="neu-card login-card" @submit.prevent="handleLogin">
<div class="form-group">
<label>{{ t('login.username') }}</label>
<input
v-model="username"
class="neu-input"
:placeholder="t('login.usernamePlaceholder')"
autocomplete="username"
autofocus
:disabled="loading"
/>
</div>
<div class="form-group">
<label>{{ t('login.password') }}</label>
<input
v-model="password"
type="password"
class="neu-input"
:placeholder="t('login.passwordPlaceholder')"
autocomplete="current-password"
:disabled="loading"
/>
</div>
<div v-if="error" class="login-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ error }}
</div>
<button type="submit" class="neu-btn neu-btn--primary neu-btn--lg w-full" :disabled="loading || !username || !password">
<span v-if="loading" class="neu-loading"></span>
{{ loading ? t('login.loading') : t('login.submit') }}
</button>
<p class="login-hint">{{ t('login.hint') }}</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useUiStore } from '@/stores/ui.store'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const uiStore = useUiStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
async function handleLogin() {
loading.value = true
error.value = ''
try {
await authStore.login(username.value, password.value)
// Appliquer les préférences de l'utilisateur
const user = authStore.user
if (user) {
uiStore.setTheme(user.theme as 'dark' | 'light')
uiStore.setSidebarPosition(user.sidebar_position as 'left' | 'right')
}
// Rediriger vers la page demandée ou le dashboard
const redirect = route.query.redirect as string || '/'
router.push(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : t('login.error')
password.value = ''
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--neu-bg);
padding: var(--neu-space-lg);
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: var(--neu-space-xl);
}
.login-logo {
width: 72px;
height: 72px;
background: var(--neu-primary);
color: #fff;
font-size: 28px;
font-weight: 700;
border-radius: var(--neu-radius-xl);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--neu-space-md);
box-shadow:
6px 6px 14px rgba(108, 142, 244, 0.4),
-3px -3px 8px rgba(255,255,255,0.05);
}
.login-header h1 {
font-size: var(--neu-font-2xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.login-header p {
color: var(--neu-text-muted);
}
.login-card {
padding: var(--neu-space-xl);
}
.form-group {
margin-bottom: var(--neu-space-md);
}
.form-group label {
display: block;
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.login-error {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
padding: var(--neu-space-sm) var(--neu-space-md);
background: rgba(240, 92, 107, 0.1);
color: var(--neu-danger);
border: 1px solid rgba(240, 92, 107, 0.3);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-sm);
margin-bottom: var(--neu-space-md);
}
.login-hint {
text-align: center;
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
margin-top: var(--neu-space-md);
}
</style>

View file

@ -0,0 +1,24 @@
<template>
<div class="logs-page">
<div class="page-header">
<h2>{{ t('nav.logs') }}</h2>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.logs-page { max-width: 1200px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -0,0 +1,107 @@
<template>
<div class="modules-page">
<div class="page-header">
<h2>{{ t('nav.modules') }}</h2>
<p class="text-muted">{{ t('modules.desc') }}</p>
</div>
<div class="modules-grid">
<div v-for="mod in modules" :key="mod.id" class="neu-card module-card">
<div class="module-header">
<div class="module-title-row flex items-center gap-sm">
<span class="module-name">{{ mod.name }}</span>
<span v-if="mod.is_core" class="neu-badge neu-badge--info">CORE</span>
<span :class="['neu-badge', mod.is_enabled ? 'neu-badge--success' : 'neu-badge--danger']">
{{ mod.is_enabled ? t('modules.enabled') : t('modules.disabled') }}
</span>
</div>
<div class="module-version text-muted">v{{ mod.version }}</div>
</div>
<p class="module-description text-muted">{{ mod.description }}</p>
<div class="module-actions flex gap-sm">
<button
v-if="!mod.is_core"
:class="['neu-btn neu-btn--sm', mod.is_enabled ? 'neu-btn--danger' : 'neu-btn--success']"
:disabled="actionLoading === mod.id"
@click="toggleModule(mod)"
>
{{ mod.is_enabled ? t('modules.disable') : t('modules.enable') }}
</button>
<span v-else class="text-muted" style="font-size:11px">{{ t('modules.coreProtected') }}</span>
</div>
</div>
</div>
<div v-if="restartNeeded" class="neu-card restart-notice">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ t('modules.restartNotice') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
const modules = ref<any[]>([])
const actionLoading = ref<string | null>(null)
const restartNeeded = ref(false)
onMounted(loadModules)
async function loadModules() {
const res = await fetch('/api/modules', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) modules.value = await res.json() || []
}
async function toggleModule(mod: any) {
actionLoading.value = mod.id
const action = mod.is_enabled ? 'disable' : 'enable'
const res = await fetch(`/api/modules/${mod.id}/${action}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
mod.is_enabled = !mod.is_enabled
restartNeeded.value = true
}
actionLoading.value = null
}
</script>
<style scoped>
.modules-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.modules-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--neu-space-md); margin-bottom: var(--neu-space-lg); }
.module-header { margin-bottom: var(--neu-space-sm); }
.module-name { font-weight: 600; color: var(--neu-text); }
.module-version { font-size: var(--neu-font-xs); margin-top: 2px; }
.module-description { font-size: var(--neu-font-sm); margin-bottom: var(--neu-space-md); }
.module-actions { border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
.restart-notice {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
color: var(--neu-warning);
border-color: var(--neu-warning);
font-size: var(--neu-font-sm);
}
</style>

View file

@ -0,0 +1,290 @@
<template>
<div class="proxmox-page">
<div class="page-header flex items-center justify-between">
<h2>{{ t('nav.proxmox') }}</h2>
<button class="neu-btn" @click="loadResources">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" :class="{ 'neu-loading': loading }">
<path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
{{ t('common.refresh') }}
</button>
</div>
<!-- Filtre par type -->
<div class="filter-bar flex gap-sm">
<button
v-for="f in filters"
:key="f.value"
:class="['neu-btn neu-btn--sm', activeFilter === f.value ? 'neu-btn--primary' : '']"
@click="activeFilter = f.value"
>
{{ t(f.label) }}
</button>
</div>
<!-- Loader -->
<div v-if="loading" class="loading-state">
<span class="neu-loading" style="font-size:32px"></span>
</div>
<!-- Erreur -->
<div v-else-if="error" class="neu-card error-card">
<p class="error-msg">{{ error }}</p>
</div>
<!-- Grille des ressources -->
<div v-else class="resources-grid">
<div
v-for="resource in filteredResources"
:key="`${resource.type}-${resource.vmid}`"
class="neu-card resource-card neu-card--hover"
>
<!-- En-tête -->
<div class="resource-header flex items-center gap-sm">
<span :class="['status-dot', resource.status === 'running' ? 'status-dot--running' : 'status-dot--stopped']" />
<div class="resource-title">
<div class="resource-name">{{ resource.name || `${resource.type.toUpperCase()} ${resource.vmid}` }}</div>
<div class="resource-meta">
<span class="neu-badge neu-badge--info">{{ resource.type.toUpperCase() }}</span>
<span class="resource-id">#{{ resource.vmid }}</span>
</div>
</div>
<span :class="['neu-badge', resource.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
{{ resource.status === 'running' ? t('proxmox.running') : t('proxmox.stopped') }}
</span>
</div>
<!-- Métriques -->
<div v-if="resource.status === 'running'" class="resource-metrics">
<div class="metric">
<div class="metric-bar">
<div class="metric-bar-fill" :style="{ width: `${Math.round(resource.cpu * 100)}%` }" />
</div>
<div class="metric-label">CPU {{ Math.round(resource.cpu * 100) }}%</div>
</div>
<div class="metric">
<div class="metric-bar">
<div class="metric-bar-fill metric-bar-fill--mem" :style="{ width: `${Math.round((resource.mem / resource.maxmem) * 100)}%` }" />
</div>
<div class="metric-label">RAM {{ formatBytes(resource.mem) }} / {{ formatBytes(resource.maxmem) }}</div>
</div>
</div>
<!-- Actions (admin uniquement) -->
<div v-if="authStore.user?.is_admin" class="resource-actions flex gap-sm">
<button
v-if="resource.status === 'stopped'"
class="neu-btn neu-btn--sm neu-btn--success"
:disabled="actionLoading === resource.vmid"
@click="startResource(resource)"
>
{{ t('proxmox.start') }}
</button>
<button
v-else
class="neu-btn neu-btn--sm neu-btn--danger"
:disabled="actionLoading === resource.vmid"
@click="stopResource(resource)"
>
{{ t('proxmox.stop') }}
</button>
</div>
</div>
</div>
<!-- Connexion WebSocket indicator -->
<div class="ws-indicator">
<span :class="['ws-dot', wsConnected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
<span class="text-muted" style="font-size:11px">
{{ wsConnected ? t('proxmox.liveUpdates') : t('proxmox.disconnected') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Resource {
vmid: number
name: string
node: string
type: string
status: string
cpu: number
maxcpu: number
mem: number
maxmem: number
}
const resources = ref<Resource[]>([])
const loading = ref(false)
const error = ref('')
const activeFilter = ref('all')
const actionLoading = ref<number | null>(null)
const wsConnected = ref(false)
let wsConnection: WebSocket | null = null
const filters = [
{ value: 'all', label: 'proxmox.all' },
{ value: 'lxc', label: 'proxmox.lxc' },
{ value: 'qemu', label: 'proxmox.vm' },
]
const filteredResources = computed(() => {
if (activeFilter.value === 'all') return resources.value.filter(r => r.type === 'lxc' || r.type === 'qemu')
return resources.value.filter(r => r.type === activeFilter.value)
})
onMounted(() => {
loadResources()
connectWebSocket()
})
onUnmounted(() => {
wsConnection?.close()
})
async function loadResources() {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
resources.value = await res.json() || []
} else {
const data = await res.json()
error.value = data.error || t('proxmox.error')
}
} catch {
error.value = t('common.networkError')
} finally {
loading.value = false
}
}
async function startResource(resource: Resource) {
actionLoading.value = resource.vmid
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
await fetch(`/api/proxmox/${path}/${resource.vmid}/start?node=${resource.node}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
actionLoading.value = null
}
async function stopResource(resource: Resource) {
actionLoading.value = resource.vmid
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
await fetch(`/api/proxmox/${path}/${resource.vmid}/stop?node=${resource.node}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
actionLoading.value = null
}
function connectWebSocket() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${authStore.accessToken}`)
wsConnection.onopen = () => {
wsConnected.value = true
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
}
wsConnection.onclose = () => {
wsConnected.value = false
setTimeout(() => connectWebSocket(), 5000)
}
wsConnection.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'resources_update' && msg.payload) {
resources.value = msg.payload
}
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)} MB`
return `${bytes} B`
}
</script>
<style scoped>
.proxmox-page { max-width: 1400px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.filter-bar { margin-bottom: var(--neu-space-lg); flex-wrap: wrap; }
.loading-state { display: flex; justify-content: center; padding: var(--neu-space-xl); color: var(--neu-primary); }
.error-card { border-color: var(--neu-danger); }
.error-msg { color: var(--neu-danger); }
.resources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--neu-space-md);
}
.resource-card { cursor: default; }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot--running { background: var(--neu-success); box-shadow: 0 0 6px var(--neu-success); }
.status-dot--stopped { background: var(--neu-text-muted); }
.resource-header { margin-bottom: var(--neu-space-sm); }
.resource-name { font-weight: 600; color: var(--neu-text); }
.resource-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.resource-id { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
.resource-metrics { margin: var(--neu-space-sm) 0; display: flex; flex-direction: column; gap: 6px; }
.metric-bar {
height: 6px;
background: var(--neu-bg);
border-radius: 3px;
overflow: hidden;
box-shadow: inset 1px 1px 3px var(--neu-shadow-dark);
}
.metric-bar-fill {
height: 100%;
background: var(--neu-primary);
border-radius: 3px;
transition: width 0.5s ease;
}
.metric-bar-fill--mem { background: var(--neu-info); }
.metric-label { font-size: 10px; color: var(--neu-text-muted); margin-top: 2px; }
.resource-actions { margin-top: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
.ws-indicator {
display: flex;
align-items: center;
gap: 6px;
margin-top: var(--neu-space-lg);
}
.ws-dot { width: 8px; height: 8px; border-radius: 50%; }
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
.ws-dot--disconnected { background: var(--neu-text-muted); }
</style>

View file

@ -0,0 +1,24 @@
<template>
<div class="services-page">
<div class="page-header">
<h2>{{ t('nav.services') }}</h2>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.services-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -0,0 +1,259 @@
<template>
<div class="settings-page">
<div class="page-header">
<h2>{{ t('nav.settings') }}</h2>
</div>
<div class="settings-layout">
<!-- Onglets -->
<div class="settings-tabs neu-card">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['settings-tab', activeTab === tab.id ? 'settings-tab--active' : '']"
@click="activeTab = tab.id"
>
<span v-html="tab.icon" class="tab-icon" />
{{ t(tab.label) }}
</button>
</div>
<!-- Contenu -->
<div class="settings-content neu-card">
<!-- Général -->
<div v-if="activeTab === 'general'">
<h3>{{ t('settings.general') }}</h3>
<div class="settings-form">
<div class="form-row">
<label>{{ t('settings.instanceName') }}</label>
<input v-model="settings.instance_name" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.publicUrl') }}</label>
<input v-model="settings.public_url" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.defaultLang') }}</label>
<select v-model="settings.default_lang" class="neu-input">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
<!-- SSH / Proxmox -->
<div v-if="activeTab === 'infrastructure'">
<h3>{{ t('settings.infrastructure') }}</h3>
<div class="settings-form">
<div class="form-row">
<label>{{ t('settings.sshHost') }}</label>
<input v-model="settings.ssh_host" class="neu-input" placeholder="10.0.0.1:2244" />
</div>
<div class="form-row">
<label>{{ t('settings.sshUsername') }}</label>
<input v-model="settings.ssh_username" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.proxmoxUrl') }}</label>
<input v-model="settings.proxmox_url" class="neu-input" placeholder="https://10.0.0.1:8006" />
</div>
</div>
</div>
<!-- Apparence -->
<div v-if="activeTab === 'appearance'">
<h3>{{ t('settings.appearance') }}</h3>
<div class="settings-form">
<div class="form-row form-row--toggle">
<label>{{ t('settings.darkMode') }}</label>
<label class="neu-toggle">
<input type="checkbox" :checked="uiStore.theme === 'dark'" @change="uiStore.toggleTheme()" />
<span class="neu-toggle__slider" />
</label>
</div>
<div class="form-row">
<label>{{ t('settings.sidebarPosition') }}</label>
<div class="flex gap-sm">
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'left' ? 'neu-btn--primary' : '']" @click="setSidebar('left')">
{{ t('settings.left') }}
</button>
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'right' ? 'neu-btn--primary' : '']" @click="setSidebar('right')">
{{ t('settings.right') }}
</button>
</div>
</div>
</div>
</div>
<!-- Audit log -->
<div v-if="activeTab === 'audit'">
<h3>{{ t('settings.audit') }}</h3>
<div class="audit-list">
<div v-for="entry in auditLog" :key="entry.id" class="audit-entry">
<span class="audit-action neu-badge neu-badge--info">{{ entry.action }}</span>
<span class="audit-user">{{ entry.username }}</span>
<span v-if="entry.resource" class="audit-resource text-muted">{{ entry.resource }}</span>
<span class="audit-date text-muted">{{ formatDate(entry.created_at) }}</span>
</div>
<p v-if="auditLog.length === 0" class="text-muted">{{ t('settings.noAuditLog') }}</p>
</div>
</div>
<!-- Bouton sauvegarder (sauf audit) -->
<div v-if="activeTab !== 'audit'" class="settings-actions">
<button class="neu-btn neu-btn--primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
<span v-if="saveSuccess" class="neu-badge neu-badge--success">{{ t('common.saved') }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
import { useUiStore } from '@/stores/ui.store'
const { t } = useI18n()
const authStore = useAuthStore()
const uiStore = useUiStore()
const activeTab = ref('general')
const saving = ref(false)
const saveSuccess = ref(false)
const auditLog = ref<any[]>([])
const tabs = [
{ id: 'general', label: 'settings.general', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>` },
{ id: 'infrastructure', label: 'settings.infrastructure', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/></svg>` },
{ id: 'appearance', label: 'settings.appearance', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>` },
{ id: 'audit', label: 'settings.audit', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12"/></svg>` },
]
const settings = ref({
instance_name: '',
public_url: '',
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
proxmox_url: '',
})
onMounted(async () => {
await loadSettings()
await loadAuditLog()
})
async function loadSettings() {
const res = await fetch('/api/settings', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
const data = await res.json()
Object.assign(settings.value, data)
}
}
async function loadAuditLog() {
const res = await fetch('/api/settings/audit', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) auditLog.value = await res.json() || []
}
async function saveSettings() {
saving.value = true
saveSuccess.value = false
const keys = Object.entries(settings.value)
for (const [key, value] of keys) {
await fetch(`/api/settings/${key}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ value }),
})
}
saving.value = false
saveSuccess.value = true
setTimeout(() => (saveSuccess.value = false), 3000)
}
function setSidebar(pos: 'left' | 'right') {
uiStore.setSidebarPosition(pos)
authStore.updatePreferences({ sidebar_position: pos })
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.settings-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.settings-layout { display: grid; grid-template-columns: 200px 1fr; gap: var(--neu-space-md); }
.settings-tabs { display: flex; flex-direction: column; gap: 2px; padding: var(--neu-space-sm); align-self: start; }
.settings-tab {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
padding: 10px var(--neu-space-sm);
border-radius: var(--neu-radius-md);
background: none;
border: none;
cursor: pointer;
color: var(--neu-text-muted);
font-size: var(--neu-font-sm);
text-align: left;
transition: var(--neu-transition);
}
.settings-tab:hover {
background: var(--neu-bg);
color: var(--neu-text);
}
.settings-tab--active {
background: var(--neu-bg);
color: var(--neu-primary);
box-shadow: inset 2px 2px 5px var(--neu-shadow-dark);
}
.settings-content h3 { margin-bottom: var(--neu-space-lg); color: var(--neu-text); }
.settings-form { display: flex; flex-direction: column; gap: var(--neu-space-md); }
.form-row { display: grid; grid-template-columns: 200px 1fr; align-items: center; gap: var(--neu-space-md); }
.form-row label { font-size: var(--neu-font-sm); color: var(--neu-text-muted); }
.form-row--toggle { grid-template-columns: 200px auto; }
.settings-actions { margin-top: var(--neu-space-xl); display: flex; align-items: center; gap: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-lg); }
.audit-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
.audit-entry { display: flex; align-items: center; gap: var(--neu-space-sm); padding: 6px 0; border-bottom: 1px solid var(--neu-border); font-size: var(--neu-font-sm); flex-wrap: wrap; }
.audit-entry:last-child { border-bottom: none; }
.audit-user { font-weight: 600; color: var(--neu-text); }
.audit-resource { font-family: monospace; }
.audit-date { margin-left: auto; }
.text-muted { color: var(--neu-text-muted); }
@media (max-width: 640px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-tabs { flex-direction: row; flex-wrap: wrap; }
.form-row { grid-template-columns: 1fr; }
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div class="terminal-page">
<div class="page-header flex items-center justify-between">
<h2>{{ t('nav.terminal') }}</h2>
<div class="flex gap-sm">
<input v-model="customHost" class="neu-input" placeholder="host:port (défaut: config)" style="width:200px" />
<button class="neu-btn neu-btn--primary" @click="reconnect">
{{ connected ? t('terminal.reconnect') : t('terminal.connect') }}
</button>
</div>
</div>
<div class="neu-card terminal-container">
<div class="terminal-status flex items-center gap-sm">
<span :class="['ws-dot', connected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
<span class="text-muted" style="font-size:11px">
{{ connected ? t('terminal.connected', { host: currentHost }) : t('terminal.disconnected') }}
</span>
</div>
<!-- Conteneur xterm.js -->
<div ref="terminalContainer" class="terminal-xterm" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '@/stores/auth.store'
import '@xterm/xterm/css/xterm.css'
const { t } = useI18n()
const authStore = useAuthStore()
const terminalContainer = ref<HTMLElement | null>(null)
const customHost = ref('')
const connected = ref(false)
const currentHost = ref('')
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
onMounted(() => {
// Initialiser xterm.js
terminal = new Terminal({
theme: {
background: 'var(--neu-bg, #1a1d2e)',
foreground: '#e2e6f6',
cursor: '#6c8ef4',
},
fontFamily: '"Courier New", Courier, monospace',
fontSize: 13,
cursorBlink: true,
scrollback: 1000,
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(terminalContainer.value!)
fitAddon.fit()
// Observer le redimensionnement
const ro = new ResizeObserver(() => fitAddon?.fit())
if (terminalContainer.value) ro.observe(terminalContainer.value)
// Connexion automatique
connect()
})
onUnmounted(() => {
ws?.close()
terminal?.dispose()
})
function connect() {
ws?.close()
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const hostParam = customHost.value ? `&host=${encodeURIComponent(customHost.value)}` : ''
const url = `${proto}//${window.location.host}/ws/terminal?token=${authStore.accessToken}${hostParam}`
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
connected.value = true
currentHost.value = customHost.value || 'ssh_host configuré'
terminal?.write('\r\n\x1b[32mConnecté au terminal SSH\x1b[0m\r\n\r\n')
}
ws.onclose = () => {
connected.value = false
terminal?.write('\r\n\x1b[31mDéconnecté\x1b[0m\r\n')
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal?.write(new Uint8Array(event.data))
} else {
terminal?.write(event.data)
}
}
ws.onerror = () => {
terminal?.write('\r\n\x1b[31mErreur de connexion WebSocket\x1b[0m\r\n')
}
// Envoyer les frappes clavier au serveur SSH
terminal?.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
// Envoyer le resize au serveur
terminal?.onResize(({ cols, rows }) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
})
}
function reconnect() {
terminal?.clear()
connect()
}
</script>
<style scoped>
.terminal-page { height: 100%; display: flex; flex-direction: column; max-width: 1200px; }
.page-header { margin-bottom: var(--neu-space-lg); flex-shrink: 0; }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.terminal-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: var(--neu-space-sm);
}
.terminal-status {
padding: var(--neu-space-xs) var(--neu-space-sm);
border-bottom: 1px solid var(--neu-border);
margin-bottom: var(--neu-space-sm);
flex-shrink: 0;
}
.terminal-xterm {
flex: 1;
min-height: 400px;
}
.ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
.ws-dot--disconnected { background: var(--neu-text-muted); }
</style>

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>

24
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

48
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,48 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
// Proxy vers le backend Go en développement
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
// Optimiser les chunks pour de meilleures performances
rollupOptions: {
output: {
manualChunks: {
'vue-core': ['vue', 'vue-router', 'pinia'],
'i18n': ['vue-i18n'],
'terminal': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-attach'],
'editor': [
'@codemirror/state',
'@codemirror/view',
'@codemirror/commands',
'@codemirror/language',
],
},
},
},
},
})