feat: initialisation complète du CORE ProxmoxPanel
Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5dbcb1df07
66 changed files with 10370 additions and 0 deletions
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
73
frontend/nginx.conf
Normal 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
1994
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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
49
frontend/src/App.vue
Normal 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>
|
||||
139
frontend/src/components/Layout.vue
Normal file
139
frontend/src/components/Layout.vue
Normal 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>
|
||||
121
frontend/src/components/Navbar.vue
Normal file
121
frontend/src/components/Navbar.vue
Normal 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>
|
||||
310
frontend/src/components/Sidebar.vue
Normal file
310
frontend/src/components/Sidebar.vue
Normal 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>
|
||||
140
frontend/src/locales/en.json
Normal file
140
frontend/src/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
140
frontend/src/locales/fr.json
Normal file
140
frontend/src/locales/fr.json
Normal 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
37
frontend/src/main.ts
Normal 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')
|
||||
128
frontend/src/router/index.ts
Normal file
128
frontend/src/router/index.ts
Normal 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
|
||||
180
frontend/src/stores/auth.store.ts
Normal file
180
frontend/src/stores/auth.store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
84
frontend/src/stores/ui.store.ts
Normal file
84
frontend/src/stores/ui.store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
46
frontend/src/styles/dark.css
Normal file
46
frontend/src/styles/dark.css
Normal 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);
|
||||
}
|
||||
70
frontend/src/styles/light.css
Normal file
70
frontend/src/styles/light.css
Normal 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
372
frontend/src/styles/neu.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
393
frontend/src/views/Dashboard.vue
Normal file
393
frontend/src/views/Dashboard.vue
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- En-tête avec bouton d'ajout de widget -->
|
||||
<div class="dashboard__header flex items-center justify-between">
|
||||
<div>
|
||||
<h2>{{ t('nav.dashboard') }}</h2>
|
||||
<p class="text-muted">{{ t('dashboard.welcome', { name: authStore.user?.username }) }}</p>
|
||||
</div>
|
||||
<button class="neu-btn neu-btn--primary" @click="showAddWidget = true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('dashboard.addWidget') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grille de widgets drag-and-drop -->
|
||||
<VueDraggable
|
||||
v-model="widgets"
|
||||
class="dashboard__grid"
|
||||
item-key="id"
|
||||
handle=".widget-drag-handle"
|
||||
@end="saveLayout"
|
||||
>
|
||||
<div
|
||||
v-for="widget in widgets"
|
||||
:key="widget.id"
|
||||
class="widget-wrapper"
|
||||
:style="{ gridColumn: `span ${widget.width}`, gridRow: `span ${widget.height}` }"
|
||||
>
|
||||
<!-- Widget raccourci service -->
|
||||
<div v-if="widget.type === 'shortcut'" class="neu-card widget widget--shortcut">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ widget.title }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a :href="widget.config.url" target="_blank" rel="noopener" class="shortcut-link">
|
||||
<div class="shortcut-icon">{{ widget.config.icon || '🔗' }}</div>
|
||||
<div class="shortcut-url">{{ widget.config.url }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Widget statut LXC -->
|
||||
<div v-else-if="widget.type === 'lxc_status'" class="neu-card widget">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ t('dashboard.lxcStatus') }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="lxc-list">
|
||||
<div v-for="lxc in proxmoxResources.filter(r => r.type === 'lxc').slice(0, 6)" :key="lxc.vmid" class="lxc-item">
|
||||
<span :class="['neu-badge', lxc.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
|
||||
{{ lxc.status === 'running' ? '●' : '○' }}
|
||||
</span>
|
||||
<span class="lxc-name">{{ lxc.name || `LXC ${lxc.vmid}` }}</span>
|
||||
<span class="lxc-id text-muted">{{ lxc.vmid }}</span>
|
||||
</div>
|
||||
<p v-if="proxmoxResources.length === 0" class="text-muted text-sm">
|
||||
{{ t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget métriques système -->
|
||||
<div v-else-if="widget.type === 'metrics'" class="neu-card widget">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ t('dashboard.metrics') }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">{{ t('dashboard.lxcCount') }}</div>
|
||||
<div class="metric-value">{{ proxmoxResources.filter(r => r.type === 'lxc').length }}</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">{{ t('dashboard.running') }}</div>
|
||||
<div class="metric-value text-success">{{ proxmoxResources.filter(r => r.status === 'running').length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
|
||||
<!-- Modal ajout de widget -->
|
||||
<div v-if="showAddWidget" class="modal-overlay" @click.self="showAddWidget = false">
|
||||
<div class="neu-card modal">
|
||||
<h3>{{ t('dashboard.addWidget') }}</h3>
|
||||
<div class="widget-types">
|
||||
<button
|
||||
v-for="type in availableWidgetTypes"
|
||||
:key="type.id"
|
||||
class="neu-btn widget-type-btn"
|
||||
@click="addWidget(type)"
|
||||
>
|
||||
<span class="widget-type-icon">{{ type.icon }}</span>
|
||||
<span>{{ t(type.label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="neu-btn w-full" @click="showAddWidget = false">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface Widget {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
config: Record<string, string>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const widgets = ref<Widget[]>([
|
||||
{ id: 1, type: 'lxc_status', title: 'LXC Status', config: {}, width: 2, height: 2 },
|
||||
{ id: 2, type: 'metrics', title: 'Métriques', config: {}, width: 1, height: 1 },
|
||||
{ id: 3, type: 'shortcut', title: 'Proxmox', config: { url: 'https://proxmox.geronzi.fr', icon: '🖥️' }, width: 1, height: 1 },
|
||||
{ id: 4, type: 'shortcut', title: 'Grafana', config: { url: 'https://grafana.geronzi.fr', icon: '📊' }, width: 1, height: 1 },
|
||||
])
|
||||
|
||||
const proxmoxResources = ref<any[]>([])
|
||||
const showAddWidget = ref(false)
|
||||
|
||||
const availableWidgetTypes = [
|
||||
{ id: 'shortcut', icon: '🔗', label: 'dashboard.widgetShortcut' },
|
||||
{ id: 'lxc_status', icon: '🖥️', label: 'dashboard.widgetLXC' },
|
||||
{ id: 'metrics', icon: '📊', label: 'dashboard.widgetMetrics' },
|
||||
]
|
||||
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
// Charger les données Proxmox
|
||||
await loadProxmoxData()
|
||||
|
||||
// Connecter le WebSocket pour les mises à jour temps réel
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsConnection?.close()
|
||||
})
|
||||
|
||||
async function loadProxmoxData() {
|
||||
try {
|
||||
const res = await fetch('/api/proxmox/resources', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
proxmoxResources.value = await res.json() || []
|
||||
}
|
||||
} catch { /* Silencieux — affiché via le widget */ }
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const token = authStore.accessToken
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${token}`)
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
|
||||
}
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'resources_update' && msg.payload) {
|
||||
proxmoxResources.value = msg.payload
|
||||
}
|
||||
} catch { /* Ignorer les messages invalides */ }
|
||||
}
|
||||
|
||||
wsConnection.onerror = () => {
|
||||
setTimeout(() => connectWebSocket(), 5000) // Reconnexion après 5s
|
||||
}
|
||||
}
|
||||
|
||||
function addWidget(type: { id: string; icon: string; label: string }) {
|
||||
const newId = Date.now()
|
||||
widgets.value.push({
|
||||
id: newId,
|
||||
type: type.id,
|
||||
title: t(type.label),
|
||||
config: type.id === 'shortcut' ? { url: 'https://example.com', icon: type.icon } : {},
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
showAddWidget.value = false
|
||||
saveLayout()
|
||||
}
|
||||
|
||||
function removeWidget(id: number) {
|
||||
widgets.value = widgets.value.filter(w => w.id !== id)
|
||||
saveLayout()
|
||||
}
|
||||
|
||||
function saveLayout() {
|
||||
// Sauvegarder via API (implémentation future avec endpoint dédié)
|
||||
localStorage.setItem('pxp_dashboard_layout', JSON.stringify(widgets.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.dashboard__header {
|
||||
margin-bottom: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.dashboard__header h2 {
|
||||
font-size: var(--neu-font-xl);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
.text-success { color: var(--neu-success); }
|
||||
.text-sm { font-size: var(--neu-font-sm); }
|
||||
|
||||
.dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--neu-space-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.widget__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-xs);
|
||||
margin-bottom: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.widget-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--neu-text-muted);
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.widget-drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.widget__title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: var(--neu-font-sm);
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.widget__remove {
|
||||
opacity: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.widget:hover .widget__remove { opacity: 1; }
|
||||
|
||||
.shortcut-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
text-decoration: none;
|
||||
color: var(--neu-text);
|
||||
padding: var(--neu-space-sm);
|
||||
border-radius: var(--neu-radius-md);
|
||||
transition: var(--neu-transition);
|
||||
}
|
||||
|
||||
.shortcut-link:hover {
|
||||
background: var(--neu-bg);
|
||||
}
|
||||
|
||||
.shortcut-icon { font-size: 24px; }
|
||||
|
||||
.shortcut-url {
|
||||
font-size: var(--neu-font-sm);
|
||||
color: var(--neu-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lxc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lxc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.lxc-name { flex: 1; color: var(--neu-text); }
|
||||
.lxc-id { color: var(--neu-text-muted); font-family: monospace; }
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
text-align: center;
|
||||
padding: var(--neu-space-sm);
|
||||
background: var(--neu-bg);
|
||||
border-radius: var(--neu-radius-md);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: var(--neu-font-xs);
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--neu-font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: var(--neu-space-md);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.widget-types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--neu-space-sm);
|
||||
margin-bottom: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.widget-type-btn {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
height: 70px;
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.widget-type-icon { font-size: 24px; }
|
||||
</style>
|
||||
25
frontend/src/views/Files.vue
Normal file
25
frontend/src/views/Files.vue
Normal 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>
|
||||
480
frontend/src/views/Install.vue
Normal file
480
frontend/src/views/Install.vue
Normal 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>
|
||||
185
frontend/src/views/Login.vue
Normal file
185
frontend/src/views/Login.vue
Normal 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>
|
||||
24
frontend/src/views/Logs.vue
Normal file
24
frontend/src/views/Logs.vue
Normal 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>
|
||||
107
frontend/src/views/Modules.vue
Normal file
107
frontend/src/views/Modules.vue
Normal 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>
|
||||
290
frontend/src/views/Proxmox.vue
Normal file
290
frontend/src/views/Proxmox.vue
Normal 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>
|
||||
24
frontend/src/views/Services.vue
Normal file
24
frontend/src/views/Services.vue
Normal 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>
|
||||
259
frontend/src/views/Settings.vue
Normal file
259
frontend/src/views/Settings.vue
Normal 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>
|
||||
162
frontend/src/views/Terminal.vue
Normal file
162
frontend/src/views/Terminal.vue
Normal 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>
|
||||
187
frontend/src/views/Updates.vue
Normal file
187
frontend/src/views/Updates.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<div class="updates-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.updates') }}</h2>
|
||||
<p class="text-muted">{{ t('updates.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélection de la cible -->
|
||||
<div class="neu-card target-card">
|
||||
<h3>{{ t('updates.selectTarget') }}</h3>
|
||||
<div class="targets">
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.value"
|
||||
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
|
||||
@click="selectedTarget = target.value"
|
||||
>
|
||||
{{ t(target.label) }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="neu-btn neu-btn--success"
|
||||
:disabled="!selectedTarget || running"
|
||||
@click="startUpdate"
|
||||
style="margin-top: var(--neu-space-md);"
|
||||
>
|
||||
{{ running ? t('updates.running') : t('updates.start') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Terminal de sortie -->
|
||||
<div v-if="currentJob" class="neu-card output-card">
|
||||
<div class="output-header flex items-center justify-between">
|
||||
<h3>{{ t('updates.output') }}</h3>
|
||||
<span :class="['neu-badge', jobStatus === 'success' ? 'neu-badge--success' : jobStatus === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${jobStatus}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="output-terminal neu-inset" ref="terminalEl">
|
||||
<pre class="output-text">{{ outputText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historique -->
|
||||
<div class="neu-card">
|
||||
<h3>{{ t('updates.history') }}</h3>
|
||||
<div v-if="history.length === 0" class="empty-state">
|
||||
<p class="text-muted">{{ t('updates.noHistory') }}</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="entry in history" :key="entry.job_id" class="history-item">
|
||||
<div class="history-meta flex items-center gap-sm">
|
||||
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${entry.status}`) }}
|
||||
</span>
|
||||
<span class="history-target">{{ entry.target }}</span>
|
||||
<span class="text-muted history-date">{{ formatDate(entry.started_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const selectedTarget = ref('host')
|
||||
const running = ref(false)
|
||||
const currentJob = ref<string | null>(null)
|
||||
const outputText = ref('')
|
||||
const jobStatus = ref('running')
|
||||
const history = ref<any[]>([])
|
||||
const terminalEl = ref<HTMLElement | null>(null)
|
||||
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
const targets = [
|
||||
{ value: 'host', label: 'updates.targetHost' },
|
||||
{ value: 'all', label: 'updates.targetAll' },
|
||||
]
|
||||
|
||||
onMounted(loadHistory)
|
||||
onUnmounted(() => wsConnection?.close())
|
||||
|
||||
async function loadHistory() {
|
||||
const res = await fetch('/api/updates/history', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) history.value = await res.json() || []
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
running.value = true
|
||||
outputText.value = ''
|
||||
jobStatus.value = 'running'
|
||||
|
||||
const res = await fetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ target: selectedTarget.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
running.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
currentJob.value = data.job_id
|
||||
|
||||
// Connecter le WebSocket de streaming
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${data.job_id}?token=${authStore.accessToken}`)
|
||||
|
||||
wsConnection.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'update_output' && msg.payload?.chunk) {
|
||||
outputText.value += msg.payload.chunk
|
||||
await nextTick()
|
||||
if (terminalEl.value) {
|
||||
terminalEl.value.scrollTop = terminalEl.value.scrollHeight
|
||||
}
|
||||
} else if (msg.type === 'update_done') {
|
||||
jobStatus.value = 'success'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
loadHistory()
|
||||
} else if (msg.type === 'update_error') {
|
||||
jobStatus.value = 'error'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.updates-page { max-width: 900px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
|
||||
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
|
||||
.target-card h3, .output-card h3 { margin-bottom: var(--neu-space-md); color: var(--neu-text); }
|
||||
|
||||
.targets { display: flex; flex-wrap: wrap; gap: var(--neu-space-sm); }
|
||||
|
||||
.output-header { margin-bottom: var(--neu-space-sm); }
|
||||
|
||||
.output-terminal {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: var(--neu-space-md);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
color: var(--neu-success);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state { padding: var(--neu-space-md) 0; }
|
||||
|
||||
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
|
||||
|
||||
.history-item { padding: var(--neu-space-sm) 0; border-bottom: 1px solid var(--neu-border); }
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
|
||||
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
|
||||
.history-date { font-size: var(--neu-font-xs); }
|
||||
</style>
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
48
frontend/vite.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue